diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index f5a98a0..4dc788b 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -1644,6 +1644,8 @@ fn shrub_headers_name_their_own_class_on_every_level() { let mut levels = 0usize; let mut spans = 0usize; let mut absent = 0usize; + let (mut min_size, mut max_size) = (f32::MAX, 0.0f32); + let mut denormals = 0usize; for n in 0..27 { let dir = data.join(format!("level{n}")); @@ -1667,6 +1669,27 @@ fn shrub_headers_name_their_own_class_on_every_level() { "level{n} class {i}" ); assert!(!s.data.is_empty(), "level{n} class {i}: empty shrub data"); + assert!( + s.header.bounds.iter().all(|v| v.is_finite()), + "level{n} class {i}: non-finite shrub bounds" + ); + assert!( + s.header.size.is_finite() && s.header.size > 0.0, + "level{n} class {i}: shrub size {}", + s.header.size + ); + // The PS2 has no denormals, so a denormal here and a zero + // are the same value to the hardware. Both occur on disc. + if s.header.reserved != 0.0 { + assert!( + s.header.reserved.is_subnormal(), + "level{n} class {i}: reserved {} is neither zero nor denormal", + s.header.reserved + ); + denormals += 1; + } + min_size = min_size.min(s.header.size); + max_size = max_size.max(s.header.size); spans += 1; } Ok(None) => absent += 1, @@ -1684,5 +1707,8 @@ fn shrub_headers_name_their_own_class_on_every_level() { spans + absent ); assert!(spans > 340, "only {spans} shrub spans with geometry"); - eprintln!("shrub: {spans} headers naming their own class, {absent} without geometry"); + eprintln!( + "shrub: {spans} headers naming their own class, {absent} without geometry; \ + size field {min_size}..{max_size}; {denormals} denormal reserved words" + ); } diff --git a/crates/gc-formats/src/shrub.rs b/crates/gc-formats/src/shrub.rs index bdc8e3f..9b2926e 100644 --- a/crates/gc-formats/src/shrub.rs +++ b/crates/gc-formats/src/shrub.rs @@ -16,9 +16,7 @@ //! //! 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. +//! tail consistently with the count at `+0x28`. //! //! 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 @@ -30,8 +28,22 @@ use crate::buf::{Buf, Error, ErrorKind, Result}; pub const SHRUB_CLASS_ENTRY_BYTES: usize = 0x30; /// The decoded part of a shrub class header. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct ShrubHeader { + /// Four floats at `+0x00..+0x10`. All finite on all 358 spans, but they do + /// not behave like a `min.xyz / max.xyz` bounding box -- that ordering only + /// holds on 146 of 358. Not named because it is not established. + pub bounds: [f32; 4], + /// A positive float at `+0x10`, 3.0 to 20.0 across the disc. + pub size: f32, + /// A float at `+0x14`. Exactly 0.0 on 342 of the disc's 358 spans; the + /// other 16 hold the bit pattern `04000000`, a denormal of about 2.8e-45. + /// + /// The Emotion Engine has no denormals, so hardware reads all 358 of these + /// as zero. That is the only reading the game itself ever sees, and it is + /// why this is safe to treat as a flag rather than a magnitude -- see + /// `AGENTS.md` on reproducing the FPU model rather than correcting it. + pub reserved: f32, /// The class this geometry belongs to; equals the class entry's /// `o_class` on all 358 spans on the disc. pub o_class: u32, @@ -43,7 +55,7 @@ pub struct ShrubHeader { } /// A shrub span: its header plus the bytes the header points at. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct Shrub<'a> { pub header: ShrubHeader, /// The whole span, starting at `+0x24` with the class number. @@ -72,9 +84,40 @@ impl ShrubHeader { available: core.len().saturating_sub(start), }, })?; + let mut fb = Buf::new(&head[..16]); + let bounds = [ + fb.f32("shrub bounds 0")?, + fb.f32("shrub bounds 1")?, + fb.f32("shrub bounds 2")?, + fb.f32("shrub bounds 3")?, + ]; + let mut fs = Buf::new(&head[0x10..0x18]); + let size = fs.f32("shrub size")?; + let reserved = fs.f32("shrub reserved")?; + if !bounds.iter().all(|v| v.is_finite()) || !size.is_finite() || !reserved.is_finite() { + return Err(Error { + offset: start, + context: "shrub header holds a non-finite float", + kind: ErrorKind::Invalid("shrub header float"), + }); + } + // Zero, or the denormal the PS2 would flush to zero anyway. Anything + // else means the header is not laid out the way this thinks it is. + if reserved != 0.0 && !reserved.is_subnormal() { + return Err(Error { + offset: start + 0x14, + context: "shrub reserved word is neither zero nor denormal", + kind: ErrorKind::Invalid("shrub reserved"), + }); + } + let mut b = Buf::new(&head[0x24..]); + let span_class = b.u32("shrub o_class")?; let header = ShrubHeader { - o_class: b.u32("shrub o_class")?, + bounds: [0.0; 4], + size: 0.0, + reserved: 0.0, + o_class: span_class, count: b.u32("shrub count")?, data_offset: b.u32("shrub data offset")?, }; @@ -97,7 +140,14 @@ impl ShrubHeader { }, })?; Ok(Some(Shrub { - header, + header: ShrubHeader { + bounds, + size, + reserved, + o_class: header.o_class, + count: header.count, + data_offset: header.data_offset, + }, raw: &core[start..], data, }))