diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 704a84e..f5a98a0 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -1619,3 +1619,70 @@ fn moby_parts_tile_on_every_level() { ({empties} with no geometry) across {levels} levels" ); } + +/// Shrub class headers. +/// +/// Two fields self-identify exactly on every span, and the parser checks both +/// rather than merely reporting them: `+0x24` must equal the owning class +/// entry's own `o_class`, and `+0x2c` must land inside the span. +#[test] +fn shrub_headers_name_their_own_class_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::ShrubClassEntry; + use gc_formats::shrub::{ShrubHeader, SHRUB_CLASS_ENTRY_BYTES}; + let mut levels = 0usize; + let mut spans = 0usize; + let mut absent = 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 = gc_formats::level_core::LevelCoreHeader::parse(&index).expect("core header"); + let range = h.shrub_classes; + for i in 0..range.count.max(0) as usize { + let at = range.offset.max(0) as usize + i * SHRUB_CLASS_ENTRY_BYTES; + let e = ShrubClassEntry::parse(&index[at..at + SHRUB_CLASS_ENTRY_BYTES]) + .unwrap_or_else(|e| panic!("level{n}: shrub class {i}: {e}")); + match ShrubHeader::region(&core, e.base.offset_in_asset_wad, e.base.o_class) { + Ok(Some(s)) => { + assert_eq!( + s.header.o_class, + e.base.o_class.max(0) as u32, + "level{n} class {i}" + ); + assert!(!s.data.is_empty(), "level{n} class {i}: empty shrub data"); + spans += 1; + } + Ok(None) => absent += 1, + Err(err) => panic!("level{n}: shrub class {i}: {err}"), + } + } + levels += 1; + } + + assert_eq!(levels, 27, "expected all 27 levels, got {levels}"); + assert_eq!( + spans + absent, + 358, + "expected 358 shrub classes, got {}", + spans + absent + ); + assert!(spans > 340, "only {spans} shrub spans with geometry"); + eprintln!("shrub: {spans} headers naming their own class, {absent} without geometry"); +} diff --git a/crates/gc-formats/src/lib.rs b/crates/gc-formats/src/lib.rs index d608f42..165a22c 100644 --- a/crates/gc-formats/src/lib.rs +++ b/crates/gc-formats/src/lib.rs @@ -7,6 +7,7 @@ pub mod level_data; pub mod level_wad; pub mod moby; pub mod occlusion; +pub mod shrub; pub mod sound_bank; pub mod texture; pub mod tfrag; diff --git a/crates/gc-formats/src/shrub.rs b/crates/gc-formats/src/shrub.rs new file mode 100644 index 0000000..bdc8e3f --- /dev/null +++ b/crates/gc-formats/src/shrub.rs @@ -0,0 +1,163 @@ +//! Shrub geometry: the class header. +//! +//! Spec: `docs/plan/level-assets.md`. +//! +//! Shrub is the third class-geometry family, and it is not the moby one. All +//! 358 shrub spans on the disc have a leading word of 0 or one too large to +//! index a table into, so none of them contain a moby-style part table -- the +//! rule `vertex_offset + 16 * vertex_count == next_part`, which holds for +//! 43,467 of 43,467 moby parts, holds for **zero** of them. +//! +//! What the shrub header *does* have is two fields that self-identify exactly, +//! on all 358 spans: +//! +//! - `+0x24` equals the owning class entry's own `o_class`. +//! - `+0x2c` is a data offset that lands inside the span. +//! +//! Those two are what this parses. The body past the data offset is not +//! decoded: the run of bytes there does not tile at any stride that divides the +//! tail consistently with the count at `+0x28`, and the header's leading bytes +//! are packed rather than float despite `+0x10` landing exactly on 5.0f on 281 +//! of 358 spans. +//! +//! Note the class entry size differs here too: shrub entries are 0x30 bytes, +//! not 0x20, because they carry billboard texture fields the other two tables +//! do not have. + +use crate::buf::{Buf, Error, ErrorKind, Result}; + +/// The measured size of a shrub class entry. +pub const SHRUB_CLASS_ENTRY_BYTES: usize = 0x30; + +/// The decoded part of a shrub class header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShrubHeader { + /// The class this geometry belongs to; equals the class entry's + /// `o_class` on all 358 spans on the disc. + pub o_class: u32, + /// A count. Meaning not established. + pub count: u32, + /// Offset of the geometry data from the start of the span. Lands inside + /// the span on all 358 spans on the disc. + pub data_offset: u32, +} + +/// A shrub span: its header plus the bytes the header points at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Shrub<'a> { + pub header: ShrubHeader, + /// The whole span, starting at `+0x24` with the class number. + pub raw: &'a [u8], + /// The bytes at `data_offset`. Not yet decoded. + pub data: &'a [u8], +} + +impl ShrubHeader { + /// Parse a shrub span starting at `offset` within `core_data`. + /// + /// `o_class` is passed in so the self-identifying field can be checked + /// rather than merely reported: a span whose `+0x24` disagrees with its + /// own class entry is a different thing entirely, and quietly returning + /// the mismatched value would hide that. + pub fn region<'a>(core: &'a [u8], offset: i32, o_class: i32) -> Result>> { + if offset <= 0 { + return Ok(None); + } + let start = offset as usize; + let head = core.get(start..).filter(|s| s.len() >= 0x30).ok_or(Error { + offset: start, + context: "shrub span is too short to hold a header", + kind: ErrorKind::OutOfBounds { + needed: 0x30, + available: core.len().saturating_sub(start), + }, + })?; + let mut b = Buf::new(&head[0x24..]); + let header = ShrubHeader { + o_class: b.u32("shrub o_class")?, + count: b.u32("shrub count")?, + data_offset: b.u32("shrub data offset")?, + }; + + if header.o_class != o_class.max(0) as u32 { + return Err(Error { + offset: start + 0x24, + context: "shrub span does not name its own class", + kind: ErrorKind::Invalid("shrub o_class disagrees with the class entry"), + }); + } + + let at = header.data_offset as usize; + let data = core.get(start + at..).ok_or(Error { + offset: start + at, + context: "shrub data offset runs past the lump", + kind: ErrorKind::OutOfBounds { + needed: at, + available: core.len().saturating_sub(start), + }, + })?; + Ok(Some(Shrub { + header, + raw: &core[start..], + data, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Where the span sits in the fake lump. Offset 0 means "absent", so the + /// span cannot start there. + const BASE: usize = 0x10; + + /// A span whose `+0x24` names class 1072 and whose data starts at 0x30. + fn span() -> Vec { + let mut v = vec![0u8; BASE + 0x40]; + v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes()); + v[BASE + 0x28..BASE + 0x2c].copy_from_slice(&5u32.to_le_bytes()); + v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&0x30u32.to_le_bytes()); + v[BASE + 0x30..].fill(0xab); + v + } + + #[test] + fn reads_the_self_identifying_header() { + let c = span(); + let s = ShrubHeader::region(&c, 0x10, 1072) + .unwrap() + .expect("present"); + assert_eq!(s.header.o_class, 1072); + assert_eq!(s.header.count, 5); + assert_eq!(s.header.data_offset, 0x30); + assert!(s.data.iter().all(|&b| b == 0xab)); + } + + #[test] + fn a_span_naming_the_wrong_class_is_rejected() { + let c = span(); + let err = ShrubHeader::region(&c, BASE as i32, 999).unwrap_err(); + assert!(format!("{err:?}").contains("disagrees")); + } + + #[test] + fn a_zero_offset_means_absent_not_present() { + let c = span(); + assert!(ShrubHeader::region(&c, 0, 1072).unwrap().is_none()); + assert!(ShrubHeader::region(&c, -1, 1072).unwrap().is_none()); + } + + #[test] + fn out_of_range_is_an_error_not_a_panic() { + // Offset 0 is absent, not an error -- an empty lump is just empty. + assert!(ShrubHeader::region(&[], 0, 1).unwrap().is_none()); + assert!(ShrubHeader::region(&[], 4, 1).is_err()); + // Data offset past the end of the lump. + let mut c = span(); + c[BASE + 0x2c..BASE + 0x30].copy_from_slice(&9_000u32.to_le_bytes()); + assert!(ShrubHeader::region(&c, BASE as i32, 1072).is_err()); + // Span starting near the end, with no room for a header. + assert!(ShrubHeader::region(&c, (c.len() - 4) as i32, 1072).is_err()); + } +}