Parse the collision container framing

Collision sits inside core_data at the offset the core index gives, behind
a 0x40-byte header that self-describes: it carries its own size and the
payload size. That pairing is the invariant -- the declared header size is
exactly 0x40 on all 27 levels and header + payload stays inside the lump.

41,481,520 payload bytes across the 19 levels that have any; 8 declare
zero length, which is present-but-empty rather than absent, so the two are
counted separately.

The cells inside the payload are not decoded. They do 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. The
first cell reads as 11 verts and 33 faces under every hypothesis tried,
which is exactly the kind of agreement that looks like a result and is
not one -- three different strides all produce the same first cell and all
of them are wrong. Left open rather than committed as a guess.
This commit is contained in:
2026-10-11 02:21:10 +02:00
parent 2dcc6e9710
commit dc941a8eec
4 changed files with 221 additions and 2 deletions
+67
View File
@@ -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");
}
+146
View File
@@ -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<CollisionHeader> {
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<Option<Collision<'a>>> {
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<u8> {
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());
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod buf;
pub mod collision;
pub mod global_wad;
pub mod iso9660;
pub mod level_core;
+7 -2
View File
@@ -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