diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 82ab5bd..704a84e 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -1526,3 +1526,96 @@ fn collision_framing_holds_on_every_level() { ); eprintln!("collision: {payload_bytes} payload bytes across {levels} levels, {empty} empty"); } + +/// Moby model geometry: the per-class part table. +/// +/// The invariant is `vertex_offset + 16 * vertex_count == next_part`, which is +/// what makes the 16-byte record size and the field boundaries fall out of the +/// data instead of being assumed. The parser rejects any span that violates it, +/// so this test is really checking that no span on the disc does. +#[test] +fn moby_parts_tile_on_every_level() { + require_disc!(); + let data = std::env::var_os("GC_EXTRACTED") + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join("Documents/gc-extracted") + }); + if !data.exists() { + eprintln!("skipped: unpack the disc first"); + return; + } + + use gc_formats::level_core::LevelCoreHeader; + use gc_formats::moby::parse_moby_geometry; + let mut levels = 0usize; + let mut classes = 0usize; + let mut parts = 0usize; + let mut verts = 0usize; + let mut empties = 0usize; + + for n in 0..27 { + let dir = data.join(format!("level{n}")); + let (Ok(core), Ok(index)) = ( + std::fs::read(dir.join("core_data.bin")), + std::fs::read(dir.join("core_index.bin")), + ) else { + continue; + }; + let h = LevelCoreHeader::parse(&index).expect("core header"); + let range = h.moby_classes; + assert!( + range.count > 0 && range.offset > 0, + "level{n}: no moby class table" + ); + + // Class spans tile between consecutive geometry offsets. + let stride = 0x20usize; + let mut offsets: Vec = (0..range.count as usize) + .map(|i| { + let at = range.offset as usize + i * stride; + let e = gc_formats::level_core::ClassEntry::parse(&index[at..at + stride]) + .unwrap_or_else(|e| panic!("level{n}: class {i}: {e}")); + e.offset_in_asset_wad + }) + .map(|o| o.max(0) as usize) + .filter(|&o| o > 0) + .collect(); + offsets.sort_unstable(); + assert!(!offsets.is_empty(), "level{n}: no moby geometry offsets"); + + for i in 0..offsets.len() { + let start = offsets[i]; + let end = offsets.get(i + 1).copied().unwrap_or(core.len()); + if end <= start { + continue; + } + let g = parse_moby_geometry(&core[start..end]) + .unwrap_or_else(|e| panic!("level{n}: moby class at {start}: {e}")); + if g.is_empty() { + empties += 1; + } + // Every part's vertices are readable and inside the span. + for p in &g.parts { + assert!( + g.vertices(p).is_some(), + "level{n}: part vertices past the end of the class span" + ); + } + parts += g.parts.len(); + verts += g.vertex_count(); + classes += 1; + } + levels += 1; + } + + assert_eq!(levels, 27, "expected all 27 levels, got {levels}"); + assert!(classes > 4_000, "only {classes} moby class spans"); + assert!(parts > 40_000, "only {parts} moby parts"); + assert!(verts > 1_200_000, "only {verts} moby vertices"); + eprintln!( + "moby: {parts} parts tiling exactly, {verts} vertices, {classes} class spans \ + ({empties} with no geometry) across {levels} levels" + ); +} diff --git a/crates/gc-formats/src/lib.rs b/crates/gc-formats/src/lib.rs index f09f502..d608f42 100644 --- a/crates/gc-formats/src/lib.rs +++ b/crates/gc-formats/src/lib.rs @@ -5,6 +5,7 @@ pub mod iso9660; pub mod level_core; pub mod level_data; pub mod level_wad; +pub mod moby; pub mod occlusion; pub mod sound_bank; pub mod texture; diff --git a/crates/gc-formats/src/moby.rs b/crates/gc-formats/src/moby.rs new file mode 100644 index 0000000..def4fc9 --- /dev/null +++ b/crates/gc-formats/src/moby.rs @@ -0,0 +1,238 @@ +//! 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()); + } +} diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index 28673e0..4162c6f 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -794,7 +794,32 @@ Class entries are 0x20 bytes in `core_index.bin`, fields `geom_offset, o_class, unknown_8, unknown_c, textures[16]`; all 238 level-0 moby classes carry `textures == ffff..ff`, i.e. no texture slots. -`geom_offset` points into `core_data`, and the spans tile without overlap. The -data at that offset does not start with a recognisable header -- it opens with -zero words and then packed half-float VIF data (`55 55 15 3e` shows up 4 bytes -in). Not decoded. +`geom_offset` points into `core_data`, and the spans tile without overlap. + +The span does not start with the geometry. Its first `u32` is the offset of a +part table, and the table is 16-byte records: + +``` ++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 +``` + +Parts tile: `vertex_offset + 16 * vertex_count == next_part`. The stride is not +assumed -- it falls out of that identity, which is what pins the record size +and the field boundaries. It holds for 43,467 of 43,467 part records across +every moby class on all 27 levels. + +Two terminations are needed, not one. A zero `vertex_offset` ends the run, but +172 records across the disc have a zero `vertex_offset` *and* a zero +`next_part` while still carrying a vertex count; stopping only on the second +is what leaves 99.6% instead of 100%. A zero `next_part` means the record +starts a different table rather than a part. + +Totals: 1,270,802 vertices over 4,560 class spans, 116 of which carry no +geometry. + +Vertices are 16 bytes of VIF-packed half-float quads, cycling through four +distinct 32-bit words. Not unpacked -- handed back raw. diff --git a/docs/roadmap.md b/docs/roadmap.md index 251a95c..55eb9c8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -205,7 +205,14 @@ Phase 1 hands over the extents; this phase works out what each one contains. - [ ] `tfrag` — terrain: LOD chains, strips, morph targets - [ ] `tie` — instanced environment geometry, fat vertices - [ ] `shrub` — foliage and decorative meshes -- [ ] `moby` — dynamic actors; `moby_anim` — skeletal animation +- [x] `moby` — per-class model geometry. A class span opens with a `u32` + pointing at a part table of 16-byte records; parts tile exactly, with + `vertex_offset + 16 * vertex_count == next_part` holding for **43,467 of + 43,467** parts across every class on all 27 levels. That identity is what + makes the record size fall out of the data rather than being assumed. + 1,270,802 vertices over 4,560 class spans; 116 spans carry no geometry. + Vertices are 16-byte VIF-packed half-float quads, handed back raw. +- [ ] `moby_anim` — skeletal animation, still open - [x] `collision` — container framing. A 0x40-byte header self-describes: it carries its own size and the payload size, and `header + payload` stays inside `core_data` on all 27 levels. 41,481,520 payload bytes across 19