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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod buf;
|
||||
pub mod collision;
|
||||
pub mod global_wad;
|
||||
pub mod iso9660;
|
||||
pub mod level_core;
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user