diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index e76064d..82ab5bd 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -1459,3 +1459,70 @@ fn sound_bank_framing_holds_on_every_level() { unexplained tail {narrowest}..{widest} bytes" ); } + +/// Collision container framing. +/// +/// The invariant is the header's own self-description: `header_size` is 0x40 +/// on every level, and `header_size + payload_size` stays inside `core_data`. +/// The payload is deliberately left undecoded -- see `collision.rs`. +#[test] +fn collision_framing_holds_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::collision::{CollisionHeader, COLLISION_HEADER_SIZE}; + use gc_formats::level_core::LevelCoreHeader; + let mut levels = 0usize; + let mut empty = 0usize; + let mut payload_bytes = 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 region = CollisionHeader::region(&core, h.collision) + .unwrap_or_else(|e| panic!("level{n}: collision: {e}")); + let Some(r) = region else { + empty += 1; + continue; + }; + assert_eq!(r.header.header_size, COLLISION_HEADER_SIZE, "level{n}"); + assert!( + r.header.payload_size as usize <= r.payload.len(), + "level{n}" + ); + if r.payload.is_empty() { + // Present but empty. Counted once, not twice. + empty += 1; + continue; + } + assert_eq!(r.payload.len(), r.header.payload_size as usize, "level{n}"); + payload_bytes += r.payload.len(); + levels += 1; + } + + assert_eq!( + levels + empty, + 27, + "expected all 27 levels, got {levels}+{empty}" + ); + assert!( + payload_bytes > 10_000_000, + "only {payload_bytes} collision bytes" + ); + eprintln!("collision: {payload_bytes} payload bytes across {levels} levels, {empty} empty"); +} diff --git a/crates/gc-formats/src/collision.rs b/crates/gc-formats/src/collision.rs new file mode 100644 index 0000000..3ad91d4 --- /dev/null +++ b/crates/gc-formats/src/collision.rs @@ -0,0 +1,146 @@ +//! Collision: container framing. +//! +//! Spec: `docs/formats/collision.md`. +//! +//! Collision lives inside the level's `core_data`, at the offset the core +//! index gives. Every level begins it with a 0x40-byte header holding two +//! `u32` -- the header's own size and the payload's -- and the rest of the +//! header is zero. That pairing is the invariant: **the declared header size is +//! exactly 0x40 and the payload stays inside the lump on all 27 levels**. +//! +//! The cells inside the payload are *not* decoded here. That layout is open: +//! the payload does not walk as fixed-stride cells, and treating faces as +//! variable-length records stops partway into the first cell on level 0 rather +//! than tiling the payload. Getting that wrong in a way that still "looks +//! plausible" is the specific failure this crate is written to avoid, so the +//! framing is what gets committed and the cell layout stays marked open. + +use crate::buf::{Buf, Error, ErrorKind, Result}; + +/// The measured size of a collision header. +pub const COLLISION_HEADER_SIZE: u32 = 0x40; + +/// The head of a collision region. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CollisionHeader { + /// Size of this header; 0x40 on every level. + pub header_size: u32, + /// Size of the payload that follows. + pub payload_size: u32, +} + +impl CollisionHeader { + /// Parse the 8 meaningful bytes at the head. + /// + /// The remaining 0x38 bytes are zero on all 27 levels, but they are not + /// required to be: only the two fields below are load-bearing. + pub fn parse(bytes: &[u8]) -> Result { + let mut b = Buf::new(bytes); + Ok(CollisionHeader { + header_size: b.u32("collision header size")?, + payload_size: b.u32("collision payload size")?, + }) + } +} + +/// A collision region, sliced out of `core_data`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Collision<'a> { + pub header: CollisionHeader, + /// The whole header, 0x40 bytes. + pub raw_header: &'a [u8], + /// The cell data. Its internal layout is not yet decoded. + pub payload: &'a [u8], +} + +impl CollisionHeader { + /// Slice a collision region starting at `offset` within `core_data`. + /// + /// Returns `Ok(None)` when the level has no collision data, which is + /// distinct from a zero-length one. + pub fn region<'a>(core: &'a [u8], offset: i32) -> Result>> { + if offset <= 0 { + return Ok(None); + } + let start = offset as usize; + let head = core.get(start..).ok_or(Error { + offset: start, + context: "collision offset runs past the lump", + kind: ErrorKind::OutOfBounds { + needed: 8, + available: core.len().saturating_sub(start), + }, + })?; + let header = CollisionHeader::parse(head)?; + let hsize = header.header_size as usize; + let raw_header = core.get(start..start + hsize).ok_or(Error { + offset: start, + context: "collision header runs past the lump", + kind: ErrorKind::OutOfBounds { + needed: hsize, + available: core.len().saturating_sub(start), + }, + })?; + let pstart = start + hsize; + let pend = pstart + header.payload_size as usize; + let payload = core.get(pstart..pend).ok_or(Error { + offset: pstart, + context: "collision payload runs past the lump", + kind: ErrorKind::OutOfBounds { + needed: header.payload_size as usize, + available: core.len().saturating_sub(pstart), + }, + })?; + Ok(Some(Collision { + header, + raw_header, + payload, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 16 bytes of leading lump, then the header, then the payload. Offset 0 + /// is reserved to mean "no collision", so the fixture cannot start there. + const BASE: usize = 16; + + fn core(header_size: u32, payload_size: u32) -> Vec { + let mut v = vec![0u8; BASE + 64]; + v[BASE..BASE + 4].copy_from_slice(&header_size.to_le_bytes()); + v[BASE + 4..BASE + 8].copy_from_slice(&payload_size.to_le_bytes()); + v.extend(std::iter::repeat_n(0xabu8, payload_size as usize)); + v + } + + #[test] + fn slices_the_payload_out() { + let c = core(0x40, 8); + let r = CollisionHeader::region(&c, BASE as i32) + .unwrap() + .expect("present"); + assert_eq!(r.raw_header.len(), 0x40); + assert_eq!(r.payload.len(), 8); + assert!(r.payload.iter().all(|&b| b == 0xab)); + } + + #[test] + fn a_zero_offset_means_absent_not_present() { + let c = core(0x40, 8); + assert!(CollisionHeader::region(&c, 0).unwrap().is_none()); + assert!(CollisionHeader::region(&c, -1).unwrap().is_none()); + } + + #[test] + fn a_payload_past_the_lump_is_an_error_not_a_panic() { + // A header that starts inside the lump but runs off the end of it. + let mut c = core(0x40, 8); + c[BASE + 8..BASE + 12].copy_from_slice(&9_000u32.to_le_bytes()); + assert!(CollisionHeader::region(&c, (BASE + 8) as i32).is_err()); + let mut c = core(0x40, 8); + c[BASE + 4..BASE + 8].copy_from_slice(&9_000u32.to_le_bytes()); + assert!(CollisionHeader::region(&c, BASE as i32).is_err()); + } +} diff --git a/crates/gc-formats/src/lib.rs b/crates/gc-formats/src/lib.rs index b139b27..f09f502 100644 --- a/crates/gc-formats/src/lib.rs +++ b/crates/gc-formats/src/lib.rs @@ -1,4 +1,5 @@ pub mod buf; +pub mod collision; pub mod global_wad; pub mod iso9660; pub mod level_core; diff --git a/docs/roadmap.md b/docs/roadmap.md index 8ebefe4..251a95c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -206,7 +206,12 @@ Phase 1 hands over the extents; this phase works out what each one contains. - [ ] `tie` — instanced environment geometry, fat vertices - [ ] `shrub` — foliage and decorative meshes - [ ] `moby` — dynamic actors; `moby_anim` — skeletal animation -- [ ] `collision` — cells, packed vertices, faces +- [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 + levels; 8 levels declare zero length. The cell layout is **not** decoded + -- the payload does not walk as fixed-stride cells, and variable-length + faces stop partway into the first cell rather than tiling it. - [x] `occlusion` — the PVS grid. A z/y/x tree of u16 node offsets over 4x4x4-unit cells, populated cells naming 128-byte masks. **412,521 cells over 140,939 masks** across the 24 levels that have one; the mask array ends @@ -214,7 +219,7 @@ Phase 1 hands over the extents; this phase works out what each one contains. data, and an offset of 0 means *absent* rather than "at the start" — the latter parses as a grid whose mask array happens to fit, which is how it nearly went wrong. -- [ ] `sky`, `volumes`, `textures` (CSM1 CLUT + PS2 alpha) +- [ ] `sky`, `volumes`; textures are done (CSM1 CLUT + PS2 alpha) - [ ] `level_overlay` — the seven-section parser and relocation from Phase 3 **Gate:** every parser has a golden test against bytes from the real disc, and