The shrub mesh geometry is in the class blob after all

The open question from the shrub decode was where the mesh lives: the blob
looked like header, palette and optional billboard, with the 333 classes
without a billboard having nothing to draw. Answering it took the running
game, not the disc.

Diffing a loaded shrub class against its disc bytes in the live PCSX2 (level
1, class 1) shows what the loader rewrites: a table at +0x40 of (offset,
size) pairs, whose offsets are span-relative on disc and absolute in RAM with
the sizes unchanged -- all 21 entries matching offset for offset and size for
size -- plus a relocated palette pointer at +0x2c and a pointer at +0x18,
zero on disc, that asserts the parser enforces.

So the blob is header, packet table, packets, [billboard], palette: the mesh
was here the whole time. Why it was missed is the part worth recording --
data_offset points at the END of the span, so reading the body as
data_offset..span_end accounts for the last few hundred bytes and leaves the
20 KB in front of it unexamined. A correct bound and a body small enough to
look plausible are together enough to hide a region.

Range measured: every shrub span on the disc, 358 over 24 levels, each bounded
by its successor class or the located chunk container.

Method: read count at +0x28, then the entries at +0x40, and require them to
tile -- the first packet at align16(0x40 + count*8), every entry exactly size
past the previous, and the last ending at the palette, or 0x40 short of it on
the 25 spans that carry a billboard.

Result: 358 of 358 tile exactly, holding 2,239 packets. That is what pins the
field meanings: count is the packet count (1..55 on the disc), data_offset is
the palette's offset, and the earlier "the body is not sized by count" note
was true for a reason nobody had understood -- the count sizes the packets.

Shrub::packets() exposes them and returns None rather than a partial list
when a table does not tile. Still open: the packet contents (a VIF reading was
already retracted once, so this needs a control rather than a pattern match),
and the 0x20-byte records the loader points at from +0x18.
This commit is contained in:
2026-10-12 02:02:01 +02:00
parent c83f5104d2
commit aa2b14ad66
4 changed files with 216 additions and 9 deletions
+53 -7
View File
@@ -1123,12 +1123,58 @@ three classes on different levels carry identical `fade`, floats, K and scale
blobs themselves are shared between levels, which is the same behaviour the
chunk container showed with its byte-identical tables.
### The packets: the mesh geometry is in the blob (2026-10-11, later)
The open question above -- where the mesh geometry lives -- answered itself
once the running game was consulted. The running PCSX2 loads shrub classes
into RAM, and diffing a loaded blob against its disc bytes shows what the
loader rewrites:
- **`+0x40` holds a table of `count` entries of `(offset, size)`**, both
relative to the span start on disc, rewritten to absolute addresses in RAM
with the sizes unchanged. Verified on level 1 class 1: all 21 entries match
the disc offset for offset and the size for size after adding the blob's
RAM base.
- **`+0x2c` (the palette offset) is relocated** the same way, to an absolute
pointer.
- **`+0x18` -- zero on disc, asserted by the parser -- is written with a
pointer at load**, to a table of 0x20-byte records that look instance-shaped
(a position, a size, a count, and a `0xffff` light slot). Not established;
recorded because it is where the next question starts.
So the blob is header, packet table, packets, [billboard], palette -- the same
shape as R&C1's, and the mesh was here the whole time. The reason it was
missed is worth writing down: **`data_offset` points at the *end* of the
span**, so reading the body as `data_offset..span_end` accounts for the last
few hundred bytes and leaves the 20 KB in front of it unexamined. A bound that
is correct and a body that is small enough to look plausible are together
enough to hide a region.
**Range measured:** every shrub class span on the disc -- 358 spans over 24
levels, each bounded by its successor class or the located chunk container.
**Method:** for each span, read `count` from `+0x28`, then the entries at
`+0x40`, and require them to tile: the first packet starts at
`align16(0x40 + count * 8)` -- the table, rounded up to 16 -- every entry is
exactly `size` past the previous one, and the last ends at the palette, or
exactly 0x40 short of it on the spans that carry a billboard record in
between.
**Result: 358 of 358 tile exactly, holding 2,239 packets.** No span's
deviations: the first-packet offsets, the sizes and the ends all agree with
the rule. That is what pins the field meanings -- `count` is the **packet
count** (1..55 on the disc), and `data_offset` is the palette's offset -- and
it is why the earlier "the body is not sized by count" observation was true
for a reason that had not been understood: the body is not sized by the count
because the count sizes the packets.
`gc_formats::shrub::Shrub::packets()` exposes them, and returns `None` rather
than a partial list when a table does not tile.
### Still open
- The three billboard floats, `size`, `mode_bits`, and `count`.
- **Where shrub mesh geometry lives.** R&C1 keeps the class's mesh packets in
the blob between the header and the palette; R&C2's blob is header, palette
and optional billboard only -- 24 normals and nothing else. The 333 classes
without a billboard still have to draw a mesh, and it is not here. The
candidates are the chunk container that follows shrub and the gameplay
extent, and neither is decoded.
- The three billboard floats, `size`, and `mode_bits` (4 on 16 spans).
- The packet contents. They are VIF command lists in R&C1; the R&C2 packets
have not been decoded, and the VIF reading of a shrub body was already
retracted once, so this needs a control, not a pattern match.
- The 0x20-byte records the loader points at from `+0x18.
+5
View File
@@ -235,6 +235,11 @@ Phase 1 hands over the extents; this phase works out what each one contains.
three floats in the billboard record, `size`, `mode_bits`, `count`, and
**where the mesh geometry lives** — R&C1 puts the mesh packets in this
blob and R&C2's blob holds only the palette.
- [x] **The shrub mesh geometry is in the class blob.** A packet table at
`+0x40` names `count` packets between the table and the palette, and
they tile on **358 of 358 spans** (2,239 packets), which makes `count`
the packet count. Confirmed against the running game, which relocates
the table's offsets to absolute addresses and `data_offset` likewise.
- [x] `moby` — per-class model geometry. A class span opens with a `u32`
pointing at a part table of 16-byte records; parts tile exactly, with
`vertex_offset + 16 * vertex_count == next_part` holding for **43,467 of