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:
@@ -1619,3 +1619,70 @@ fn moby_parts_tile_on_every_level() {
|
|||||||
({empties} with no geometry) across {levels} levels"
|
({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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod level_data;
|
|||||||
pub mod level_wad;
|
pub mod level_wad;
|
||||||
pub mod moby;
|
pub mod moby;
|
||||||
pub mod occlusion;
|
pub mod occlusion;
|
||||||
|
pub mod shrub;
|
||||||
pub mod sound_bank;
|
pub mod sound_bank;
|
||||||
pub mod texture;
|
pub mod texture;
|
||||||
pub mod tfrag;
|
pub mod tfrag;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user