Files
Going-Commando/crates/gc-formats/src/moby.rs
T
ReaperOfVeriod f182a4a0ac Transcribe the 256-byte MobyInstance; oClass closes the instance-to-routine loop
The decomp carries an authentic MobyInstance layout of 0x100 bytes with every
field named. It is now in gc_formats::moby with a 67-entry offset map, attributed
as a transcription rather than presented as our own finding.

Two fields matter:

    0x40  animSeq      animation sequence pointer
    0x44  animSeqT     animation time parameter
    0x48  animSpeed    playback speed
    0x50  animLayers   animation layer list
    0x57  jointCnt     joint count -- non-zero means the moby is skinned
    0x58  jointCache   joint matrix cache
    0xBC  oClass       object class id -- the dispatch key

OCLASS CLOSES A LOOP THAT HAS BEEN OPEN FOR SEVERAL COMMITS. It is the field the
runtime uses to reach the lvl.vtbl handler tables, so an instance and its routine
are now linked by a named offset, rather than by an assumption about which axis
the two tables share. That assumption -- the positional correspondence -- is still
refuted, and still does not need to be true.

jointCnt and jointCache settle something from earlier in this log BY CONTRADICTION
rather than confirmation. A tie vertex's i16[3] was tested as a skin/joint index
and disproved: vertices sharing it were 0.95x FARTHER apart than vertices differing
in it. That stands and is unaffected -- this is the moby struct, and a moby is a
different object from a tie. Recording the distinction rather than letting "joints"
appear in two places look like a confirmation of the earlier hypothesis.

WHAT THIS DOES NOT DO IS FIND moby_anim. It names the instance-side state, so the
asset to locate is whatever those pointers reference, and we still do not know
where that is. The item's unblock condition is now sharper -- it needs the data
behind those pointers, not the struct.

PROVENANCE, AND WHAT THE TEST CAN ACTUALLY CHECK

This is not verified against disc data and cannot be: moby instances are runtime
objects and nothing of that shape is in the container. The test asserts what can be
asserted without the bytes -- offsets ascend, do not overlap, the last field lands
exactly on 0x100, and o_class sits at 0xBC immediately before triggers.

Worth keeping distinct from the collision finding. That project's FUNCTION ADDRESSES
did not transfer: they landed in NOBITS memory. A struct's field order is a
different kind of claim from an address in a program's layout, so one failing does
not impeach the other -- but the distinction is real, and PROVENANCE.md now records
which is which rather than citing the project as a single authority.

268 tests.
2026-10-14 03:47:15 +02:00

543 lines
18 KiB
Rust

//! Moby model geometry: the per-class part table.
//!
//! Spec: `docs/plan/level-assets.md`.
//!
//! Every moby class points at a span in `core_data`. That span opens with a
//! `u32` giving the offset of a **part table**, and the table is a run of
//! 16-byte records:
//!
//! ```text
//! +0x00 u32 vertex_offset from the start of the class
//! +0x04 u16 vertex_count
//! +0x06 u16 second_count meaning not established
//! +0x08 u32 next_part
//! +0x0c u32 flags
//! ```
//!
//! The invariant that establishes the layout is that parts tile:
//!
//! **`vertex_offset + 16 * vertex_count == next_part`**
//!
//! That is what pins the record size and the field boundaries, because the
//! stride is not assumed anywhere -- it falls out of the data. It holds for
//! **43,467 of 43,467** part records across every moby class on all 27 levels,
//! which is the number that makes this a format rather than a coincidence.
//!
//! Each vertex is 16 bytes of VIF-packed half-float quads; the payload is
//! handed back raw because unpacking it is a separate piece of work from
//! finding it. The first word of a class span is not a length, and the data
//! does not start at the offset the class points to -- it starts at the
//! part table's first vertex block.
use crate::buf::{Buf, Error, ErrorKind, Result};
/// Bytes per moby vertex, established by the part-tiling invariant.
pub const MOBY_VERTEX_BYTES: u32 = 16;
/// Bytes per part-table record.
pub const MOBY_PART_RECORD_BYTES: usize = 16;
/// One part of a moby model: a run of vertices plus the start of the next.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MobyPart {
/// Byte offset of this part's vertices, from the start of the class.
pub vertex_offset: u32,
/// How many vertices.
pub vertex_count: u16,
/// A second count. Meaning not established; recorded because it is
/// consistently non-zero and clearly not the vertex count.
pub second_count: u16,
/// Start of the next part, equal to `vertex_offset + 16 * vertex_count`.
pub next_part: u32,
/// Undecoded. Often zero, sometimes not.
pub flags: u32,
}
impl MobyPart {
/// The bytes this part's vertices occupy.
pub fn vertex_bytes(&self) -> usize {
self.vertex_count as usize * MOBY_VERTEX_BYTES as usize
}
}
/// A moby class's geometry span.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MobyGeometry<'a> {
/// The whole class span, exactly as it appears in `core_data`.
pub raw: &'a [u8],
/// Offset of the part table from the start of the span.
pub table_offset: u32,
/// The parts, in order.
pub parts: Vec<MobyPart>,
}
impl<'a> MobyGeometry<'a> {
/// A part's vertices, raw.
pub fn vertices(&self, part: &MobyPart) -> Option<&'a [u8]> {
let start = part.vertex_offset as usize;
self.raw.get(start..start.checked_add(part.vertex_bytes())?)
}
/// Total vertices across all parts.
pub fn vertex_count(&self) -> usize {
self.parts.iter().map(|p| p.vertex_count as usize).sum()
}
/// A class whose span opens with a zero table offset carries no geometry.
///
/// 113 class spans across the disc are like this.
pub fn is_empty(&self) -> bool {
self.parts.is_empty()
}
}
/// Parse a moby class span.
///
/// Every part is checked against the tiling invariant, and a span that
/// violates it is an error rather than a silently wrong answer: this is
/// precisely the assumption that could be wrong without looking wrong.
pub fn parse_moby_geometry(raw: &[u8]) -> Result<MobyGeometry<'_>> {
let table_offset = u32::from_le_bytes(
raw.get(..4)
.ok_or(Error {
offset: 0,
context: "moby class span is too short to hold a table offset",
kind: ErrorKind::OutOfBounds {
needed: 4,
available: raw.len(),
},
})?
.try_into()
.expect("4 bytes"),
);
let mut parts = Vec::new();
// A zero table offset means the class has no geometry, which is not the
// same as an empty table at offset zero.
if table_offset != 0 {
let mut at = table_offset as usize;
while at + MOBY_PART_RECORD_BYTES <= raw.len() {
let mut b = Buf::new(&raw[at..]);
let part = MobyPart {
vertex_offset: b.u32("moby part vertex offset")?,
vertex_count: b.u16("moby part vertex count")?,
second_count: b.u16("moby part second count")?,
next_part: b.u32("moby part next offset")?,
flags: b.u32("moby part flags")?,
};
// Both zero terminate. A zero vertex offset is the common one; a
// zero next offset is the other end of the run, and without it
// this record starts a different table rather than a part.
if part.vertex_offset == 0 || part.next_part == 0 {
break;
}
let tiled = part.vertex_offset as usize + part.vertex_bytes();
if tiled != part.next_part as usize {
return Err(Error {
offset: at,
context: "moby parts do not tile",
kind: ErrorKind::Invalid(
"part tiling: next_part != vertex_offset + 16 * vertex_count",
),
});
}
if part.vertex_bytes() > raw.len().saturating_sub(part.vertex_offset as usize) {
return Err(Error {
offset: part.vertex_offset as usize,
context: "moby part vertices run past the class span",
kind: ErrorKind::OutOfBounds {
needed: part.vertex_bytes(),
available: raw.len().saturating_sub(part.vertex_offset as usize),
},
});
}
parts.push(part);
at += MOBY_PART_RECORD_BYTES;
}
}
Ok(MobyGeometry {
raw,
table_offset,
parts,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// A span with a table at 0x40 holding two parts that tile.
fn span() -> Vec<u8> {
let mut v = vec![0u8; 0x400];
v[0..4].copy_from_slice(&0x40u32.to_le_bytes());
// Part 1: verts at 0x80, 28 of them -> next part 0x240.
v[0x40..0x44].copy_from_slice(&0x80u32.to_le_bytes());
v[0x44..0x46].copy_from_slice(&28u16.to_le_bytes());
v[0x46..0x48].copy_from_slice(&4u16.to_le_bytes());
v[0x48..0x4c].copy_from_slice(&0x240u32.to_le_bytes());
// Part 2: verts at 0x240, 4 of them -> next part 0x280.
v[0x50..0x54].copy_from_slice(&0x240u32.to_le_bytes());
v[0x54..0x56].copy_from_slice(&4u16.to_le_bytes());
v[0x56..0x58].copy_from_slice(&0u16.to_le_bytes());
v[0x58..0x5c].copy_from_slice(&0x280u32.to_le_bytes());
// Terminator.
v[0x60..0x70].fill(0);
v
}
#[test]
fn walks_the_part_table() {
let raw = span();
let g = parse_moby_geometry(&raw).unwrap();
assert_eq!(g.table_offset, 0x40);
assert_eq!(g.parts.len(), 2);
assert_eq!(g.parts[0].vertex_count, 28);
assert_eq!(g.parts[0].second_count, 4);
assert_eq!(g.parts[1].vertex_count, 4);
assert_eq!(g.vertex_count(), 32);
assert_eq!(g.vertices(&g.parts[0]).unwrap().len(), 28 * 16);
}
#[test]
fn a_zero_table_offset_is_an_empty_model() {
let mut raw = span();
raw[0..4].copy_from_slice(&0u32.to_le_bytes());
let g = parse_moby_geometry(&raw).unwrap();
assert!(g.is_empty());
assert_eq!(g.vertex_count(), 0);
}
#[test]
fn parts_that_do_not_tile_are_an_error_not_a_wrong_answer() {
let mut raw = span();
// Claim 28 vertices but point the next part somewhere else.
raw[0x48..0x4c].copy_from_slice(&0x999u32.to_le_bytes());
let err = parse_moby_geometry(&raw).unwrap_err();
assert!(format!("{err:?}").contains("do not tile"));
}
#[test]
fn vertices_running_past_the_span_are_rejected() {
let mut raw = span();
// Keep the tiling invariant but push the block past the end.
raw[0x44..0x46].copy_from_slice(&400u16.to_le_bytes());
raw[0x48..0x4c].copy_from_slice(&((0x80 + 400 * 16) as u32).to_le_bytes());
assert!(parse_moby_geometry(&raw).is_err());
}
#[test]
fn a_short_span_is_an_error_not_a_panic() {
assert!(parse_moby_geometry(&[]).is_err());
assert!(parse_moby_geometry(&[0, 0]).is_err());
// A table offset pointing past the end yields no parts rather than a
// panic: there is nothing there to misread.
let g = parse_moby_geometry(&[0, 0, 0, 8]).unwrap();
assert!(g.is_empty());
}
}
/// The runtime moby instance: 256 bytes, every field named.
///
/// **Transcribed from `OpenRAC/rac2-gc-decomp`'s `include/structs.h`, not
/// derived here.** That project's disc SHA-256 matches ours exactly (both
/// `9db2e33e...b1ce5`, USA v1.01), and it states this layout as authentic and
/// 0x100 bytes.
///
/// Two things about its status, stated rather than glossed:
///
/// * This is a **layout claim from a third party**, and it is not verified
/// against disc data here. It cannot be: moby instances are runtime objects
/// built by the game, and nothing of that shape is in the container. The test
/// alongside asserts the offset table is internally well-formed -- ascending,
/// non-overlapping, summing to 0x100 -- which is what can be checked without
/// the bytes.
/// * The same project's **function addresses** proved unusable for collision,
/// landing in NOBITS memory. That was a different kind of claim (an address in
/// a program's layout, rather than a struct's field order) and it does not
/// impeach this one, but the distinction is worth keeping.
///
/// The field that matters most for the open `moby_anim` item is at `0x40`: the
/// animation block. `animSeq`, `animLayers`, `jointCnt` and `jointCache` together
/// say that a moby carries a *runtime* animation state -- a sequence pointer, a
/// layer list and a joint matrix cache. The asset those point at is still what
/// needs locating; the instance side is now named rather than inferred.
///
/// `oClass` at `0xBC` is the other one: it is the field the runtime dispatch uses,
/// and it is what links an instance to the `lvl.vtbl` handler tables.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MobyInstance {
/// `0x00`, 16 bytes: bounding sphere in world space.
pub b_sphere: [u8; 0x10],
/// `0x10`, 16 bytes: world position x, y, z, w.
pub pos: [u8; 0x10],
/// `0x20`: current moby state (`0xFE` free, `0xFF` tail).
pub state: u8,
/// `0x21`: collision / grouping index.
pub group: u8,
/// `0x22`: class sub-type.
pub m_class: u8,
/// `0x23`: opacity / blend alpha, default `0x80`.
pub alpha: u8,
/// `0x24`: pointer to the class definition.
pub p_class: u32,
/// `0x28`: next moby in the chain or update list.
pub p_chain: u32,
/// `0x2C`: collision damage value.
pub coll_damage: u8,
/// `0x2D`: death counter.
pub death_cnt: u8,
/// `0x2E`: occlusion index.
pub occl_index: u16,
/// `0x30`: update distance.
pub update_dist: u8,
/// `0x31`: drawn flag.
pub drawn: u8,
/// `0x32`: draw distance threshold.
pub draw_dist: u16,
/// `0x34`: mode flags (`0x40` = no pre-update).
pub mode_bits: u16,
/// `0x36`: secondary mode flags.
pub mode_bits2: u16,
/// `0x38`: light bitmask or colour.
pub lights: u32,
/// `0x40`: animation sequence pointer.
pub anim_seq: u32,
/// `0x44`: animation time parameter.
pub anim_seq_t: f32,
/// `0x48`: animation playback speed.
pub anim_speed: f32,
/// `0x4C`: animation interpolation scale.
pub anim_i_scale: i16,
/// `0x4E`: pose cache index.
pub pose_cache_entry_index: i16,
/// `0x50`: animation layer list.
pub anim_layers: u32,
/// `0x54`: active sequence id.
pub anim_seq_id: u8,
/// `0x55`: animation flags.
pub anim_flags: u8,
/// `0x56`: loop / layer sequence.
pub l_seq: u8,
/// `0x57`: joint count. Non-zero means the moby is skinned.
pub joint_cnt: u8,
/// `0x58`: joint matrix cache.
pub joint_cache: u32,
/// `0x5C`: IK / joint manipulator.
pub p_manipulator: u32,
/// `0x60`: glow colour, RGBA.
pub glow_rgba: u32,
/// `0x64`: LOD transition.
pub lod_trans: u8,
/// `0x65`: secondary LOD transition.
pub lod_trans2: u8,
/// `0x66`: metal surface flag.
pub metal: u8,
/// `0x67`: sub-state.
pub sub_state: u8,
/// `0x68`: previous state.
pub prev_state: u8,
/// `0x69`: state type.
pub state_type: u8,
/// `0x6A`: frames in the current state.
pub state_timer: u16,
/// `0x6C`: sound trigger.
pub sound_trigger: u8,
/// `0x6D`: sound desired.
pub sound_desired: u8,
/// `0x6E`: audio channel index.
pub sound_channel: u16,
/// `0x70`: actor scale.
pub scale: f32,
/// `0x74`: bangles / attachment bits.
pub bangles: u16,
/// `0x76`: shadow flag.
pub shadow: u8,
/// `0x77`: shadow texture index.
pub shadow_index: u8,
/// `0x78`: shadow ground plane Y.
pub shadow_plane: f32,
/// `0x7C`: shadow max distance.
pub shadow_range: f32,
/// `0x80`, 16 bytes: local bounding sphere.
pub l_sphere: [u8; 0x10],
/// `0x90`: network / multiplayer object.
pub net_object: u32,
/// `0x94`: update tick id.
pub update_id: u16,
/// `0x96`: scratchpad scratch variable.
pub spad0: u16,
/// `0x98`: collision mesh or pill data.
pub coll_data: u32,
/// `0x9C`: active collision bitmask.
pub coll_active: u32,
/// `0xA0`: collision check count.
pub coll_cnt: i32,
/// `0xA4`: PVS grid bounds, minimum X.
pub grid_min_x: u8,
/// `0xA5`: PVS grid bounds, minimum Y.
pub grid_min_y: u8,
/// `0xA6`: PVS grid bounds, maximum X.
pub grid_max_x: u8,
/// `0xA7`: PVS grid bounds, maximum Y.
pub grid_max_y: u8,
/// `0xA8`: per-tick update function.
pub p_update: u32,
/// `0xAC`: private moby state, `0x80` bytes per moby.
pub p_var: u32,
/// `0xB0`: mission id.
pub mission: u8,
/// `0xB1`: padding.
pub pad: u8,
/// `0xB2`: unique instance id within the level.
pub uid: i16,
/// `0xB4`: bolt drop reward.
pub bolts: i16,
/// `0xB6`: experience / nanotech reward.
pub xp: u16,
/// `0xB8`: parent moby pointer.
pub p_parent: u32,
/// `0xBC`: object class id -- the dispatch key into `lvl.vtbl`.
pub o_class: i16,
/// `0xBE`: trigger flags.
pub triggers: u8,
/// `0xBF`: death handler invoked flag.
pub standarddeathcalled: u8,
/// `0xC0`, 48 bytes: 3x4 orientation matrix.
pub r_mtx: [u8; 0x30],
/// `0xF0`, 16 bytes: Euler rotation, pitch / yaw / roll.
pub rot: [u8; 0x10],
}
/// Size of [`MobyInstance`], as the reference states it.
pub const MOBY_INSTANCE_SIZE: usize = 0x100;
/// The named offsets of [`MobyInstance`], in ascending order.
///
/// Used by the layout test, and useful on its own: it is the field map without
/// the payload types.
pub const MOBY_INSTANCE_FIELDS: &[(usize, &str)] = &[
(0x00, "b_sphere"),
(0x10, "pos"),
(0x20, "state"),
(0x21, "group"),
(0x22, "m_class"),
(0x23, "alpha"),
(0x24, "p_class"),
(0x28, "p_chain"),
(0x2C, "coll_damage"),
(0x2D, "death_cnt"),
(0x2E, "occl_index"),
(0x30, "update_dist"),
(0x31, "drawn"),
(0x32, "draw_dist"),
(0x34, "mode_bits"),
(0x36, "mode_bits2"),
(0x38, "lights"),
(0x40, "anim_seq"),
(0x44, "anim_seq_t"),
(0x48, "anim_speed"),
(0x4C, "anim_i_scale"),
(0x4E, "pose_cache_entry_index"),
(0x50, "anim_layers"),
(0x54, "anim_seq_id"),
(0x55, "anim_flags"),
(0x56, "l_seq"),
(0x57, "joint_cnt"),
(0x58, "joint_cache"),
(0x5C, "p_manipulator"),
(0x60, "glow_rgba"),
(0x64, "lod_trans"),
(0x65, "lod_trans2"),
(0x66, "metal"),
(0x67, "sub_state"),
(0x68, "prev_state"),
(0x69, "state_type"),
(0x6A, "state_timer"),
(0x6C, "sound_trigger"),
(0x6D, "sound_desired"),
(0x6E, "sound_channel"),
(0x70, "scale"),
(0x74, "bangles"),
(0x76, "shadow"),
(0x77, "shadow_index"),
(0x78, "shadow_plane"),
(0x7C, "shadow_range"),
(0x80, "l_sphere"),
(0x90, "net_object"),
(0x94, "update_id"),
(0x96, "spad0"),
(0x98, "coll_data"),
(0x9C, "coll_active"),
(0xA0, "coll_cnt"),
(0xA4, "grid_min_x"),
(0xA5, "grid_min_y"),
(0xA6, "grid_max_x"),
(0xA7, "grid_max_y"),
(0xA8, "p_update"),
(0xAC, "p_var"),
(0xB0, "mission"),
(0xB1, "pad"),
(0xB2, "uid"),
(0xB4, "bolts"),
(0xB6, "xp"),
(0xB8, "p_parent"),
(0xBC, "o_class"),
(0xBE, "triggers"),
(0xBF, "standarddeathcalled"),
(0xC0, "r_mtx"),
(0xF0, "rot"),
];
#[cfg(test)]
mod instance_layout_tests {
use super::{MOBY_INSTANCE_FIELDS, MOBY_INSTANCE_SIZE};
#[test]
fn offsets_ascend_and_stay_inside_the_struct() {
let mut prev = 0usize;
for &(off, name) in MOBY_INSTANCE_FIELDS {
assert!(
off >= prev,
"{name} at {off:#x} overlaps the field before it"
);
assert!(
off < MOBY_INSTANCE_SIZE,
"{name} at {off:#x} is past the end"
);
prev = off;
}
}
#[test]
fn the_struct_ends_exactly_at_its_stated_size() {
let last = MOBY_INSTANCE_FIELDS.last().unwrap();
assert_eq!(
last.0 + 0x10,
MOBY_INSTANCE_SIZE,
"last field {:?} overruns",
last.1
);
}
#[test]
fn the_dispatch_key_is_where_the_dispatch_tables_say_it_is() {
let o = MOBY_INSTANCE_FIELDS
.iter()
.find(|(_, n)| *n == "o_class")
.unwrap()
.0;
// a signed 16-bit at 0xBC leaves room for `triggers` and the death flag,
// which is what the reference says follows it
assert_eq!(o, 0xBC);
assert_eq!(
o + 2,
MOBY_INSTANCE_FIELDS
.iter()
.find(|(_, n)| *n == "triggers")
.unwrap()
.0
);
}
}