Parse the shrub class header

Shrub is the third class-geometry family and is not the moby one: all 358
spans on the disc have a leading word of 0 or one too large to index a
table into, so none contain a moby-style part table. The rule that holds
for 43,467 of 43,467 moby parts holds for zero of them.

Two header fields do self-identify, exactly, on all 358 spans: +0x24 equals
the owning class entry's own o_class, and +0x2c is a data offset landing
inside the span. Both are checked rather than merely reported -- a span
naming the wrong class is a different thing, and quietly returning the
mismatched value would hide that.

The body past +0x2c is not decoded. It tiles at no stride consistent with
the count at +0x28 (tails of 240, 240, 192 and 224 bytes against counts of
5, 6, 5 and 3), and the header's leading bytes are packed rather than
float even though +0x10 lands exactly on 5.0f on 281 of 358 spans.

Shrub class entries are 0x30 bytes rather than 0x20, because they carry
billboard texture fields the other two tables lack; the parser records that
so the stride is not re-derived.
This commit is contained in:
2026-10-11 02:38:35 +02:00
parent f6bbbf8d5f
commit 7107f395d0
3 changed files with 231 additions and 0 deletions
+67
View File
@@ -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");
}
+1
View File
@@ -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;
+163
View File
@@ -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<Option<Shrub<'a>>> {
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<u8> {
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());
}
}