Read the shrub header floats, including a denormal the PS2 flushes

Extended the shrub header past the two self-identifying words. The first
four words are floats -- finite on all 358 spans -- though they do not
behave like a min/max bounding box, which orders correctly on only 146 of
358, so they are recorded as bounds rather than named. The word at +0x10
is positive everywhere, 3.0 to 20.0, exactly 5.0 on 281 of them.

The word at +0x14 is the interesting one. It is zero on 342 spans and the
bit pattern 04000000 on the other 16, which is a denormal of about 2.8e-45.
The Emotion Engine has no denormals, so hardware reads all 358 as zero.

That is only the second reading. My probe reported the field as 0.000
everywhere because it printed to three decimals, and the test caught it:
level 7 class 7 fails a strict zero check. The lesson is that "exact" in
a measurement means exact in the measured precision -- rounding in the
output made a real distinction invisible.

It also matches AGENTS.md on modelling the FPU rather than correcting it:
this field looks like noise on disc and is a constant to the machine that
reads it, and the parser accepts either rather than picking one.

The shrub body past +0x2c is still not decoded.
This commit is contained in:
2026-10-11 02:41:34 +02:00
parent 39c364e874
commit 517f0b63fa
2 changed files with 84 additions and 8 deletions
+27 -1
View File
@@ -1644,6 +1644,8 @@ fn shrub_headers_name_their_own_class_on_every_level() {
let mut levels = 0usize; let mut levels = 0usize;
let mut spans = 0usize; let mut spans = 0usize;
let mut absent = 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 { for n in 0..27 {
let dir = data.join(format!("level{n}")); 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}" "level{n} class {i}"
); );
assert!(!s.data.is_empty(), "level{n} class {i}: empty shrub data"); 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; spans += 1;
} }
Ok(None) => absent += 1, Ok(None) => absent += 1,
@@ -1684,5 +1707,8 @@ fn shrub_headers_name_their_own_class_on_every_level() {
spans + absent spans + absent
); );
assert!(spans > 340, "only {spans} shrub spans with geometry"); 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"
);
} }
+57 -7
View File
@@ -16,9 +16,7 @@
//! //!
//! Those two are what this parses. The body past the data offset is not //! 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 //! 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 //! tail consistently with the count at `+0x28`.
//! 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, //! 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 //! 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; pub const SHRUB_CLASS_ENTRY_BYTES: usize = 0x30;
/// The decoded part of a shrub class header. /// The decoded part of a shrub class header.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShrubHeader { 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 /// The class this geometry belongs to; equals the class entry's
/// `o_class` on all 358 spans on the disc. /// `o_class` on all 358 spans on the disc.
pub o_class: u32, pub o_class: u32,
@@ -43,7 +55,7 @@ pub struct ShrubHeader {
} }
/// A shrub span: its header plus the bytes the header points at. /// 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 struct Shrub<'a> {
pub header: ShrubHeader, pub header: ShrubHeader,
/// The whole span, starting at `+0x24` with the class number. /// The whole span, starting at `+0x24` with the class number.
@@ -72,9 +84,40 @@ impl ShrubHeader {
available: core.len().saturating_sub(start), 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 mut b = Buf::new(&head[0x24..]);
let span_class = b.u32("shrub o_class")?;
let header = ShrubHeader { 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")?, count: b.u32("shrub count")?,
data_offset: b.u32("shrub data offset")?, data_offset: b.u32("shrub data offset")?,
}; };
@@ -97,7 +140,14 @@ impl ShrubHeader {
}, },
})?; })?;
Ok(Some(Shrub { 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..], raw: &core[start..],
data, data,
})) }))