diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index ed88bb2..edb9d61 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -1664,6 +1664,7 @@ fn shrub_headers_name_their_own_class_on_every_level() { let mut billboards = 0usize; let mut billboard_entries = 0usize; let mut packets_total = 0usize; + let mut packet_headers_total = 0usize; for n in 0..27 { let dir = data.join(format!("level{n}")); @@ -1803,6 +1804,30 @@ fn shrub_headers_name_their_own_class_on_every_level() { ); packets_total += packets.len(); + // The packet headers themselves. The disc form is a fixed + // signature -- magic, four zero bytes, tag 0x05 -- and the + // loader rewrites bytes 8-15 on load, so the count and the + // pointer live in RAM, not here. + let hdrs: Vec<_> = s.packet_headers().collect(); + assert_eq!( + hdrs.len(), + packets.len(), + "level{n} class {i}: header count disagrees with packet count" + ); + for (pi, h) in hdrs.iter().enumerate() { + assert!( + h.is_some(), + "level{n} class {i}: packet {pi} header does not match the \ + disc invariants (magic 04 04 00 01, bytes 4-8 zero, tag 0x05)" + ); + assert_eq!( + packets[pi].size % 16, + 0, + "level{n} class {i}: packet {pi} size is not a multiple of 16" + ); + } + packet_headers_total += hdrs.len(); + // The billboard: present exactly when the class entry // carries billboard texture fields. let entry_has_billboard = e.billboard_width != 0 || e.billboard_height != 0; @@ -1856,7 +1881,7 @@ fn shrub_headers_name_their_own_class_on_every_level() { all {bounded_bodies} bodies bounded, {bounded_records} records, \ {palette_records} of them palette normals and {padding_records} zero padding; \ {billboards} billboards, all with texture fields; \ - {packets_total} packets across all blobs; \ + {packets_total} packets across all blobs, {packet_headers_total} headers; \ mode_bits {mode_hist:?}, radius {radius_range:?}, scale {scale_range:?}, \ largest palette deviation from unit length {norm2_deviation} of a possible 65535" ); diff --git a/crates/gc-formats/src/shrub.rs b/crates/gc-formats/src/shrub.rs index 6c4f4e4..dc44416 100644 --- a/crates/gc-formats/src/shrub.rs +++ b/crates/gc-formats/src/shrub.rs @@ -202,6 +202,70 @@ pub struct Shrub<'a> { pub span_end_is_a_successor: bool, } +/// The 16-byte header at the front of a mesh packet. +/// +/// Measured on **every packet on the disc** -- 2,056 of them, across the 334 +/// successor-bounded spans -- the header is almost entirely constant: +/// +/// | bytes | value | packets | +/// |---|---|---| +/// | 0-3 | `04 04 00 01` | 2056 / 2056 | +/// | 4-7 | zero | 2056 / 2056 | +/// | 8 | zero | 2056 / 2056 | +/// | 11 | `0x05` | 2056 / 2056 | +/// +/// and every packet's size is a multiple of 16 (2056 / 2056). +/// +/// **What the loader does to it.** Read back out of the running game's memory, +/// the same records have byte 8 holding a **count** (0x0b to 0x29 observed), +/// byte 11 holding one of four tag values, and bytes 12-15 holding an absolute +/// EE RAM pointer where the disc has a span-relative offset. So bytes 8-15 are +/// rewritten wholesale on load, and the count this header does *not* carry on +/// disc is the one the `+0x40` table already supplies. +/// +/// That is why the disc header is worth asserting and the RAM one is not: the +/// disc form is a fixed signature, the RAM form is populated state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShrubPacketHeader { + /// The constant leading magic, `04 04 00 01`. + pub magic: [u8; 4], + /// The tag byte at 11. `0x05` on every packet on the disc; rewritten on + /// load to one of `0x00`, `0x20`, `0x30`, `0xbc`. + pub tag: u8, +} + +/// The magic every mesh packet opens with, on every span on the disc. +pub const SHRUB_PACKET_MAGIC: [u8; 4] = [0x04, 0x04, 0x00, 0x01]; + +/// The tag byte every mesh packet carries on the disc, before the loader +/// replaces it. +pub const SHRUB_PACKET_TAG: u8 = 0x05; + +impl ShrubPacketHeader { + /// Parse a packet header, bounds-checked against `bytes`. + /// + /// Returns `None` unless the magic matches, bytes 4-8 are zero and the tag + /// is [`SHRUB_PACKET_TAG`] -- the three invariants that hold on 2,056 of + /// 2,056 packets. Rejecting anything else is deliberate: a packet that does + /// not match is not one this parser understands, and passing it back + /// half-understood is how a wrong reading gets treated as a finding. + pub fn parse(bytes: &[u8]) -> Option { + if bytes.len() < 16 || bytes[..4] != SHRUB_PACKET_MAGIC { + return None; + } + if bytes[4..9].iter().any(|&b| b != 0) { + return None; + } + if bytes[11] != SHRUB_PACKET_TAG { + return None; + } + Some(ShrubPacketHeader { + magic: SHRUB_PACKET_MAGIC, + tag: bytes[11], + }) + } +} + /// One mesh packet: where it sits in the span and how long it is. /// /// The offsets are relative to the start of the span, like every other @@ -268,6 +332,24 @@ impl<'a> Shrub<'a> { (cursor == expected_end).then_some(out) } + /// The mesh packets' headers, parsed. + /// + /// Yields `None` for any packet whose header does not match the disc + /// invariants, so a caller can tell a bad packet from a good one rather + /// than receiving a default. + pub fn packet_headers(&self) -> impl Iterator> + 'a { + self.packets() + .unwrap_or_default() + .into_iter() + .map(move |p| { + self.raw + .get(p.offset..p.offset + 16) + .and_then(ShrubPacketHeader::parse) + }) + .collect::>() + .into_iter() + } + /// The palette: the first [`SHRUB_NORMAL_COUNT`] records, parsed. /// /// Returns fewer only if the body is shorter than the palette, which does diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index febf260..365901d 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -1263,3 +1263,36 @@ PS2 primitive lists, which is the obvious next reference to bring in. VIF stream: the 16-byte tiling is uniform across all 29 packets with no command-shaped tail, and a VIF packet would show a changing command byte in its last position rather than the constant `6c` at header byte 15. + +## Shrub packet headers: a fixed disc signature, rewritten on load + +The packets found by the `+0x40` table have a header the whole disc agrees on. + +**Range measured:** every packet on the disc -- **2,056 of them**, across the +334 successor-bounded spans -- read at each packet's span-relative offset. + +**Result:** + +| bytes | value | packets | +|---|---|---| +| 0-3 | `04 04 00 01` | **2056 / 2056** | +| 4-7 | zero | **2056 / 2056** | +| 8 | zero | **2056 / 2056** | +| 11 | `0x05` | **2056 / 2056** | + +Every packet's size is also a multiple of 16 (2056 / 2056). Zero exceptions on +any of the four. + +**And the loader fills the rest in.** Read back out of the running game, the +same records carry byte 8 as a **count** (0x0b to 0x29 observed), byte 11 as +one of four tag values, and bytes 12-15 as an absolute EE RAM pointer where the +disc has a span-relative offset. + +So **bytes 8-15 are rewritten wholesale on load.** What that means for reading +the disc: the header carries no count of its own, and the one the RAM copy has +is the `+0x40` table's `count` arriving by another route. The disc header is a +signature; the RAM header is populated state. + +`ShrubPacketHeader::parse` and `Shrub::packet_headers()` expose this, and the +gated test asserts all four invariants plus the size alignment on every packet +of every bounded span.