Parse the shrub packet header, a fixed signature on all 2056 disc packets

The packets found by the +0x40 table have a header the whole disc agrees on.

Range measured: every packet on the disc -- 2056 of them, across the 334
successor-bounded spans -- read at each packet's span-relative offset.

  bytes 0-3   04 04 00 01   2056 / 2056
  bytes 4-7   zero           2056 / 2056
  byte  8     zero           2056 / 2056
  byte  11    0x05           2056 / 2056
  size % 16   zero           2056 / 2056

Zero exceptions on any of the five.

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. Bytes 8-15 are rewritten wholesale on load.

That settles what the disc header is for. It carries no count of its own, and
the count 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. parse returns
None unless all the invariants hold, rather than a default, so a packet that
does not match is reported as not-understood instead of half-interpreted.

The gated test asserts all four header invariants and the size alignment on
every packet of every bounded span.

This is the first piece of the mesh packet format represented in gc-formats;
everything known about the packet payloads so far is RAM measurement, since the
disc form of bytes 8-15 is rewritten at load.
This commit is contained in:
2026-10-12 02:35:00 +02:00
parent af0b1aa949
commit 5ade83cf0a
3 changed files with 141 additions and 1 deletions
+26 -1
View File
@@ -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"
);
+82
View File
@@ -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<ShrubPacketHeader> {
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<Item = Option<ShrubPacketHeader>> + 'a {
self.packets()
.unwrap_or_default()
.into_iter()
.map(move |p| {
self.raw
.get(p.offset..p.offset + 16)
.and_then(ShrubPacketHeader::parse)
})
.collect::<Vec<_>>()
.into_iter()
}
/// The palette: the first [`SHRUB_NORMAL_COUNT`] records, parsed.
///
/// Returns fewer only if the body is shorter than the palette, which does
+33
View File
@@ -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.