//! 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, } 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> { 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 { 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 ); } }