Commit Graph
50 Commits
Author SHA1 Message Date
ReaperOfVeriod 32a24574be Identify data.bin as the level file descriptor, not geometry
data.bin is the largest per-level file -- 13.3 MB on level 0, 4.2 to 21.9 MB
across the disc -- and had been characterised only as "4-byte structured".

Range measured: the first 64 u32 of data.bin on levels 0, 1, 2 and 5, compared
against the actual byte lengths of the other extracted level files.

Method: read the first 64 u32 and look for any equal to the size of
core_index.bin, gs_ram.bin, overlay.bin, gameplay.bin, sound_bank.bin or
core_data.bin.

Result: three exact matches, at the same indices on every level.

  u32 index 1  overlay.bin     2751268  2763668  2928424  2863676
  u32 index 3  core_index.bin    31600    34016    33808    23536
  u32 index 5  gs_ram.bin       518656   569344   513024   434176

Each is the exact byte length of the named file and each lands on the same index
on all four levels. Three coincidences at fixed positions is not a coincidence.

So data.bin is a descriptor for the level's other files, not geometry in its own
right. The roadmap's "the large one: 4.2 to 21.9 MB of geometry per level" entry
was wrong and is corrected.

The intervening indices 0, 2 and 4 hold values that are not file sizes and their
meaning is not established; they are plausibly offsets, but no cumulative-sum or
offset+size relation has been checked yet.

What follows the descriptor is still unknown, and the file is large enough that
the descriptor is unlikely to be all of it.
2026-10-12 02:50:24 +02:00
ReaperOfVeriod 5610d173b0 Narrow the collision header to two fields plus a third used by five levels
Range measured: all 27 levels that carry collision, reading the 0x40-byte
header each.

Method: read +0x00, +0x04 and +0x08, then check +0x08..+0x40 for zero.

Result:

  u32@+0x00 is 0x40 on 27 of 27 -- the header is self-sizing
  u32@+0x04 is the payload size, 20 distinct values across the disc
  bytes +0x08..+0x40 zero on 22 of 27

Five levels carry a large non-zero word at +0x08:

  level  7  1248048        level 12  2541616
  level  9  1766832        level 13  3376592
                      level 14  4060640

So the header is not two fields and 56 bytes of padding: there is a third field
most levels leave at zero and five use. Whether it is a size, an offset or a
second block's length is not established, and nothing measured here separates
those readings. Exposed as CollisionHeader::field_08.

Correction: the collision header was previously described as self-describing
across its full 0x40, which is true for 22 levels and overstates the case for
the other five.

Still not decoded: the payload. It contains ascending u32 runs on levels 0 and
2 -- level 0 at +280 with deltas 12, 12, 12 then a jump, level 2 at +13868 with
deltas 204, 204, 200, 196 -- but irregular deltas fit neither a flat table nor a
simple stride, and level 1's apparent run is a byte-shift false positive.
Nothing about the payload is asserted.
2026-10-12 02:49:25 +02:00
ReaperOfVeriod 681e7e62ef Parse the sky block: 149 sub-objects with a bounding sphere, and correct the count field
Range measured: all 27 levels, each sky region from the core index's sky offset
to the next region.

Method: read the 32-bit offset table at +0x20, bounded by staying inside the
region, and parse each sub-object it names.

Result: 149 sub-objects across 27 levels. Each opens with a count, then a
bounding sphere of four floats at +0x10, then a texture offset at +0x20 and
dimensions at +0x24/+0x26:

  +000: 26 00 00 00 | 00 00 00 00 00 00 00 00 00 00 00 00
  +010: e0 8d b2 c1 | 9f d6 f6 3d | ef fc fb be | 1c 61 4e 41
  +020: 90 99 05 00 | 1d 00 | 1d 00 | 00 00 e8 00 ...

Count 1 to 38, width 3 to 81, radius 1.95 to 14.56, and the radius is positive
on 149 of 149 -- the same sphere shape and the same reasoning as the shrub class
header's.

Correction to an earlier claim: the word at +0x0c is not the sub-object count. It
reads 5 on level 0 where the table holds 6 entries, 3 on level 1 against 4, 7 on
level 3 against 8, and 196617 on level 2 against 4. Across the disc
offsets.len() == count+1 holds on 10 of 27 levels.

The field is reported as SkyHeader::field_0c and nothing depends on it; the
offset table enumerates the sub-objects and is self-limiting. That earlier claim
came from level 0 alone, where 5 against 6 looked like a consistent off-by-one.
It is not an off-by-one -- it is a field that happens to be small on most levels
and is not a count at all.

gc_formats::sky parses the header and the sub-objects. The gated test asserts the
sphere is finite, the radius positive where positive at all, the dimensions
non-zero, and the sub-object list non-empty on every level.
2026-10-12 02:43:28 +02:00
ReaperOfVeriod bbce492c91 Establish that the loader produces the RAM packet forms, not the disc
Everything previously reported about five-block sub-lists and fixed-point
payloads was read out of the running game. Checking the same packets on disc
settles what the disc actually stores.

Range measured: level 0, all 29 packets named by the +0x40 tables of the 6
bounded spans, read at their span-relative offsets; 1636 whole 16-byte records
after the 16-byte header.

Neither RAM form exists on disc:

                     disc              RAM
  00 00 00 30 per    exactly 1         exactly 5
  sub-list packet
  blocks at 0/32/64  0 of 10           11 of 11
  fixed-point byte3  0 of 19           100%
  == 0x80

The 29 packets split 10 sub-list / 19 fixed-point by whether they contain
00 00 00 30 at all, and not one of the ten has the block structure, and not one
of the nineteen has the fixed-point marker.

The disc body is a compact 16-byte record stream:

  +000: 04 04 00 01 | 00 00 00 00 | 00 00 00 05 | 00 80 0e 6c
  +010: 02 00 00 00 | 05 00 00 00 | 32 00 00 00 | 0e 00 00 00
  +020: 09 00 00 00 | 00 40 3e 30 | 12 04 00 00 | 05 00 00 00
  +030: 09 00 00 00 | 00 40 3e 30 | 12 04 00 00 | 21 00 00 00

Small u32 fields and a repeating 00 40 3e 30 12 04 00 00. Per position over all
1636 records the same two outliers hold: byte 7 takes 4 distinct values and byte
15 takes 2, against 101 to 153 everywhere else.

Byte 7 is per-record, not per-packet: the first record of all 29 packets has
byte 7 = 0x00. The four-value vocabulary varies within a packet body rather than
tagging the packet, which is why grouping packets by it never separated the two
kinds.

Consequence for Phase 2: the payload is encoded on disc and expanded at load, so
a disc parser can bound it and check its signature but cannot read it as either
RAM form. Decoding it means reversing the expansion, not re-describing what the
loader produced. That is the honest boundary of what is known.
2026-10-12 02:37:55 +02:00
ReaperOfVeriod 5ade83cf0a 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.
2026-10-12 02:35:00 +02:00
ReaperOfVeriod af0b1aa949 Repeat the packet measurements per level, correcting two over-general claims
The five-block result was measured on level 0 only. Rule 3a asks for the same
measurement per partition, so it was repeated on level 1's savestate before
being written up as a property.

Range measured: savestate slot 2 (level 1), RAM 0x22a000..0x28f52f, 415024
bytes, then six of its pointer-bearing records read to count*16.

The six sampled pointers split 3/3 between the two payload kinds:

  00949430  41  00 00 00 30  sub-list
  009496c0  13  69 b0 bf 80  fixed-point
  00957970  36  00 00 00 30  sub-list
  00957bb0  10  58 7e 8d 80  fixed-point
  0095b740  12  37 49 41 80  fixed-point
  0095d6d0  57  00 00 00 30  sub-list

Taking all six as sub-list gives 3/6 with five blocks. Taking only the three
that actually open 00 00 00 30 gives 3/3, with the first three blocks at 0, 32,
64 in all three. The three fixed-point payloads have zero such blocks, as
expected. The earlier 3/6 was my sampling, not a counterexample -- the six
pointers were never all the same kind of object.

Combined: 11 of 11 sub-list payloads across both levels have exactly five
blocks, the first three at offsets 0, 32, 64.

The fixed-point payloads have an exact signature: byte 3 of every 4-byte group
is 0x80 in 100% of cases -- 52/52, 40/40, 48/48 over the first 512 bytes of the
three level 1 samples, and the same across all nine level 0 payloads. That
separates the two payload kinds without the record tag, which does not select
them.

Correction: level 1's record tags are not level 0's. The same scan finds 18
04 04 00 01 headers against 19, plus 6 02 01 00 01 against 10, and byte 11 is
no longer constant: 0x30 on 10, 0x00 on 5, 0x20 on 2, 0xbc on 1, where level 0
had 0x30 on all 19. "Byte 11 is 0x30, constant" was a level-0 partition result
written up as universal; across both levels it takes four values.

Fixed-point payload entropy over all nine level 0 payloads is 5.51 to 5.89 bits
per byte against 8.0 for uniform, and the mean of the s16 reading varies per
payload from -82 to -907 -- structured, and consistent with vertex positions
relative to a per-payload origin.

Not established: what the five blocks are, and how the fixed-point data decodes
to vertices. Its entropy is far below random, so it is compressed or permuted
rather than stored plainly, and the permutation table seen once in 0x009376f0
remains unexplained.
2026-10-12 02:31:27 +02:00
ReaperOfVeriod 186a700f32 Establish the sub-list payload as a fixed five-block shape
All eight sub-list payloads from level 0 were read to their exact extent
(count * 16) and compared block by block.

Range measured: savestate slot 1, the packets at 0x009376f0, 0x00939c50,
0x0093cc70, 0x00998880, 0x009c19c0, 0x009cf370, 0x009d2da0 and 0x009f5aa0,
counts 21 to 41, each truncated to exactly count*16 bytes.

Every payload contains exactly five blocks tagged 00 00 00 30, and the first
three are always at offsets 0, 32 and 64:

  009376f0  22  0, 32, 64, 184, 304
  00939c50  36  0, 32, 64, 268, 400
  0093cc70  23  0, 32, 64, 200, 288
  00998880  36  0, 32, 64, 272, 404
  009c19c0  25  0, 32, 64, 196, 300
  009cf370  24  0, 32, 64, 204, 316
  009d2da0  41  0, 32, 64, 308, 448
  009f5aa0  21  0, 32, 64, 176, 280

Blocks 4 and 5 move with the payload; the first three do not. Eight payloads,
eight different block-4/5 positions, and 0/32/64 in every case.

Per-block fields constant across all eight:

  block 0  byte 4 varies 0x78..0xcd
  block 1  byte 4 = 0 in 8/8, trailer 00000000 in 8/8
  block 2  byte 4 varies independently, bytes 16-20 zero in 8/8
  block 3  byte 4 EQUALS BLOCK 0 in 8/8
  block 4  byte 4 = 0 in 8/8

Block 2's word is unrelated to block 0's in every case, so the equality is not
every block reading one shared field.

Correction: the "byte permutation table" reported in the previous commit does
not generalise. It appears in 0x009376f0 at offset 212, but a scan across all
eight payloads finds no such run outside that one. It was one payload's block
content, not a format feature. The five-block shape, the fixed 0/32/64 offsets
and the block-0/block-3 equality are what survive eight-way comparison.

Not established: what the five blocks are, why blocks 0 and 3 share a word, and
what moves blocks 4 and 5 -- their offsets are not monotone in count (21 gives
280, 36 gives 400 and 404), so something inside the earlier blocks sets them.
2026-10-12 02:30:06 +02:00
ReaperOfVeriod 3fa64b7003 Read a full sub-list payload: tagged blocks and a byte permutation table
With the extent known from the previous entry (count * 16), a whole sub-list
payload can be read. This is the packet at 0x009376f0, count 22, 352 bytes.

Range measured: savestate slot 1, 0x009376f0..0x0093784f.

The payload is a sequence of blocks, each opening 00 00 00 30, carrying a
repeating word and a short trailer:

  00 00 00 30 | cd 00 00 00 cd 00 00 00 cd 00 00 00 cd 00 00 00
              01 00 00 05 | 22 c1 01 6e | 00 00 00 00
  00 00 00 30 | 00 00 00 45 00 00 00 45 1d 00 00 00 1d 00 00 00 1f
              81 01 6d 00 | 00 00 00 00 | 00 00 a4 00
  00 00 00 30 | bc c0 03 00 23 17 03 00 e5 dd 00 00 00 00 00 00
              02 01 00 01 | c1 80 01 69 | 00 00 00 00

The repeating word differs per block (0xcd, 0x45, 0x03) and the trailer's
second word varies in its high bytes (22 c1 01 6e, 81 01 6d 00, c1 80 01 69).
Every block ends 6e, 69 or 6d at byte 23 -- the same 6e the 02 01 00 01 records
carried at bytes 8-9.

The payload contains a byte permutation table at 0x009377c4:

  00 01 02 03 3e 3f 53 54 40 41 42 43 4e 4f 50 51 04 05 06 07 08 ...

Ascending byte values with a handful (3e 3f, 53 54, 40 41 42 43, 4e 4f 50 51)
lifted out of order ahead of the sequential run. Read as a table it maps each
of 0x00..0x39 to a slot -- the shape of a code-length or symbol-ordering table,
and the first thing in this format that is self-evidently a table rather than
data to be interpreted.

Not said: whether it is a Huffman-style code, an index reorder for the vertex
data that follows, or a palette permutation. The sequence continues to 39 then
23 c1 01 6e, after which the next block opens.

Corroboration: the fixed-point payload 0x00937850 begins exactly where this one
ends. The 22-record identity made the boundary exact and the next packet starts
on a payload boundary with nothing in between, so the two payload kinds sit
adjacent in RAM -- consistent with them being two records of the same blob.
2026-10-12 02:28:07 +02:00
ReaperOfVeriod f1cf6cd164 Show the byte-8 count sizes sub-list payloads at exactly 16 bytes each
The previous entry left one test: compare the count at byte 8 against the
payload extent. Pointer spacing gives the extent, since the packets lie in
ascending order in EE RAM.

Range measured: savestate slot 1, the 17 packets in RAM 0x22a000..0x24037f,
each with its record address, byte-8 count and pointer, sorted by pointer.

Result: eight packets sit exactly count*16 bytes after their predecessor -- a
ratio of 16.00 in every one, no rounding.

  0x22a6e8  count 22  ptr 009376f0  gap 352   16.00
  0x22a728  count 36  ptr 00939c50  gap 576   16.00
  0x22a768  count 23  ptr 0093cc70  gap 368   16.00
  0x22a7a8  count 36  ptr 00998880  gap 576   16.00
  0x22a7e8  count 25  ptr 009c19c0  gap 400   16.00
  0x22a828  count 24  ptr 009cf370  gap 384   16.00
  0x22a868  count 41  ptr 009d2da0  gap 656   16.00
  0x22a8a8  count 21  ptr 009f5aa0  gap 336   16.00

Eight exact hits at a ratio that must be an integer is not luck. The count at
byte 8 is the record count, and these payloads are 16 bytes per record.

These are exactly the sub-list payloads -- 009376f0, 00939c50, 0093cc70,
00998880 and 009d2da0 were read directly in earlier passes and all open
00 00 00 30 followed by a repeated word.

The other nine have ratios of 168, 419, 559, 31288, 7985, 3458, 764 and 6452
-- no shared factor, far too large to be a record count times anything. Either
their payload is not followed immediately by the next packet in pointer order,
or they are not 16 bytes per record. Ruled out either way: they do not follow
the same rule.

This resolves byte 8, measured two entries ago as "a count, 0x0b to 0x29" and
now shown to size a payload exactly in eight independent cases within one
level. Whether it also sizes the fixed-point payloads is the open half.

Still open: why the fixed-point packets differ, and what the 16 bytes per
sub-list record are -- the repeated word (0xcd, 0xc0, 0xc8, 0x78) differs per
packet, so the records are not identical to each other.
2026-10-12 02:27:28 +02:00
ReaperOfVeriod db76319600 Find a second packet record magic interleaved with the first
Range measured: savestate slot 1, RAM 0x22a000..0x24037f.

Result: a second four-byte record magic appears at comparable density beside
04 04 00 01 -- 02 01 00 01, ten occurrences against nineteen. The two interleave:
every 02 01 00 01 sits between two 04 04 00 01 records.

  0x22a6b8  04 04 00 01  13 00 00 30  70 6a 93 00
  0x22a6c8  02 01 00 01  19 c0 4c 6e  00 00 10 00  00 00 00 00
  0x22a6e8  04 04 00 01  16 00 00 30  f0 76 93 00
  0x22a6f8  04 04 00 01  16 00 00 30  50 78 93 00
  0x22a708  02 01 00 01  1e c0 58 6e  00 00 10 00  00 00 00 00
  0x22a728  04 04 00 01  24 00 00 30  50 9c 93 00

The 02 01 00 01 records are 16 bytes like the others but carry no pointer --
bytes 12-15 are zero in all ten. Their fields are a count at 4-5 (0x14, 0x19,
0x1e), a constant 0xc0 at 6, a varying byte at 7 (0x30, 0x4c, 0x54, 0x58),
0x6e at 8-9, and 10 00 at 10-11.

The 0x6e at bytes 8-9 matches the ".. c1 01 6e" the sub-list payloads carry at
offset 20-23. That is a second correspondence, and unlike the one retracted in
the previous commit it is a shared value rather than a shared position.

Not settled: this still does not say what discriminates the two payload kinds
among the 04 04 00 01 records, since those share the same magic and the same
0x30 at byte 11 and differ only in the count at byte 8. The 02 01 00 01 records
sit between them rather than replacing them, so they are a companion structure
rather than the missing discriminator.

Next: compare the count at byte 8 against the payload's own length.
2026-10-12 02:24:22 +02:00
ReaperOfVeriod 2d7de9f13e Correct the packet tag mapping: the tag does not select the payload
The previous commit mapped the disc tag vocabulary onto the three payload
classes. A wider scan says that is wrong.

Range measured: savestate slot 1, RAM 0x22a000..0x24037f -- 91008 bytes, 20
times the span scanned before.

Result: 19 packet headers, and all 19 carry byte 11 = 0x30. There are no 0x6d
or 0x80 tags anywhere in this range. Reading six payloads:

  rec 0022a6b8 tag 30 -> 8d 8c 80 80 89 87 7c 80 ...   fixed point
  rec 0022a6e8 tag 30 -> 00 00 00 30 cd 00 00 00 ...   sub-list
  rec 0022a6f8 tag 30 -> 92 90 84 80 94 94 86 80 ...   fixed point
  rec 0022a728 tag 30 -> 00 00 00 30 c0 00 00 00 ...   sub-list
  rec 0022a738 tag 30 -> 4c 4c 4b 80 4c 4c 4b 80 ...   fixed point
  rec 0022a768 tag 30 -> 00 00 00 30 78 00 00 00 ...   sub-list

Records of identical tag alternate between two payload kinds. The tag does not
select the payload.

What went wrong: the previous entry took the 0x30 and 0x6d that head the
payloads to be the disc-side tag vocabulary, and matched them to payload classes
by that correspondence. The disc vocabulary at record byte 7 (0x00, 0x30, 0x6d,
0x80) and RAM record byte 11 are different fields that merely overlap in two
values. Three payloads each made the coincidence look like a rule.

What is actually established about the RAM header:

  0-3    04 04 00 01, constant
  4-7    zero, constant
  8      a count, 0x0b to 0x29 across the 19 records
  9-10   zero, constant
  11     0x30, constant on all 19 in this range
  12-15  an EE RAM pointer

Byte 8 is the finding that survives: a small count varying per record, and the
one field here doing arithmetic rather than tagging.

Still open: what 0x30 at byte 11 means, what discriminates payload kinds given
the tag does not, and 0x80, which has no sample in 91 KB of RAM.
2026-10-12 02:22:06 +02:00
ReaperOfVeriod 0b1793d6af Map shrub packet tags onto three payload kinds
Dereferenced all nine pointers in the level 0 table that carry the 0x30 tag at
byte 11 and an EE pointer at 12..15, reading 32 bytes each. They fall into
exactly three classes, three payloads each, with no overlap.

A, Q16.15 fixed-point vertex data (3 of 9): every high byte 0x80 or 0x7c,
repeating on an 8-byte boundary, values differing per payload while the
4e 4e 4c 80 tail is shared by two.

  00936a70  8d 8c 80 80 89 87 7c 80 ... 4e 4e 4c 80
  0093cde0  8a 8a 7f 80 92 90 84 80 ... 4e 4e 4c 80
  009cf4f0  6e 6f 68 80 6e 6f 68 80 ... 54 54 51 80

B, a 0x30 sub-list with a repeated word (3 of 9), structurally identical
apart from that word (0xcd, 0xc0, 0xc8), all carrying 01 00 00 05 at offset 16
and a ".. c1 01 6e" at 20.

  009376f0  00 00 00 30 cd 00 00 00 cd 00 00 00 ...
  00998880  00 00 00 30 c0 00 00 00 c0 00 00 00 ...
  009d2da0  00 00 00 30 c8 00 00 00 c8 00 00 00 ...

C, an index/attribute list (3 of 9), both opening 00 c0 05 6d, then a count,
then a run of small ascending u16.

  00939700  00 c0 05 6d 3e 00 00 00 01 00 00 00 ...
  009c1540  00 c0 05 6d 36 00 00 00 01 00 00 00 ...

So the tag vocabulary measured on disc at record byte 7 -- 0x00, 0x30, 0x6d,
0x80 -- selects the payload:

  0x30  class B, the 00 00 00 30 sub-list
  0x6d  class C, the 00 c0 05 6d attribute list
  0x00  class A, the fixed-point vertex data

0x30 heads the class B payloads and 0x6d heads the class C payloads. That was
flagged as a single coincidence two commits back; three payloads each makes it
a rule.

0x80 has no sample among these nine and its meaning stays open.

Correction: the record spacing in this table is not uniformly 0x30. The find
output shows 0x30, 0x10, 0x30 between consecutive packet headers, and only 9
of the 5632 bytes scanned carry both the 0x30 tag and an EE pointer. The
earlier "0x30 spacing" was inferred from a truncated hit list.
2026-10-12 02:20:04 +02:00
ReaperOfVeriod ad70e4c4be Follow shrub packet pointers: adjacent records hold unrelated payloads
The header pointers found in the previous commit are live, so they can be
dereferenced. Two records 0x30 apart in the level 0 table at 0x22a6b8 were
followed.

Range measured: savestate slot 1, reading 0x00936a70 (from the record at
0x22a6b8) and 0x009376f0 (from the record at 0x22a6e8), 64 bytes each.

The two payloads have nothing in common.

0x00936a70 is fixed-point vertex data:

  8d 8c 80 80  89 87 7c 80  8d 8c 80 80  89 87 7c 80
  4e 4e 4c 80  4e 4e 4c 80  4e 4e 4c 80  4e 4e 4c 80   repeating

Every high byte is 0x80 or 0x7c and the pattern repeats on an 8-byte boundary.
As s16 these are large negatives in a narrow band around -32500, consistent
with Q16.15 fixed point rather than f32, and with a stride of 4 s16 per vertex.

0x009376f0 is a tagged sub-list, not vertex data:

  00 00 00 30  cd 00 00 00  cd 00 00 00  cd 00 00 00
  cd 00 00 00  01 00 00 05  22 c1 01 6e  00 00 00 00

Structurally different: small integers, a repeating 0xcd, 0x30 tags in the high
byte. Nothing here is position data.

This supports the type reading of the header: the table is a tagged collection
where the tag selects the payload kind, not a uniform array. It also rules out
the alternative that the 16-byte tiling was one uniform record format with
varying fields -- two adjacent records with unrelated payloads is not that.

Still open: the mapping from tag value to payload kind. The 0x30 appearing in
record bytes 8-11 in RAM also heads the second payload, which is suggestive
but is one coincidence so far and is deliberately not written up as a link.
2026-10-12 02:18:18 +02:00
ReaperOfVeriod 3854e52019 Show the loader rewrites shrub packet headers into RAM pointers
Answering the "what is the tag byte" question needed an anchor. Searching live
RAM from scratch was the wrong way to get one: 04 04 00 01 matches unrelated
data at 0x1ac988 and there is no way to tell a real hit from a coincidental
one. The savestates are the anchor -- slot 1 is level 0, and searching one
gives candidates that can be checked rather than guessed at.

Range measured: savestate slot 1,
~/.config/PCSX2/sstates/SCUS-97268 (38996035).01.p2s, records at 0x22a6b8
onward, read with gc-trace read --state.

Result: 40+ records beginning 04 04 00 01 00 00 00 00 at 0x30 spacing -- the
packet framing identified on disc -- with the second half of each header
rewritten:

  disc:  04 04 00 01 | 00 00 00 00 | 00 00 00 05 | 00 80 0e 6c
  RAM:   04 04 00 01 | 00 00 00 00 | 13 00 00 30 | 70 6a 93 00

Bytes 0-7 unchanged; bytes 8-15 are not. On disc bytes 12-13 are the constant
00 80 and byte 15 the constant 6c; in RAM bytes 12-15 hold a pointer --
0x00936a70, 0x009376f0, 0x00937850, 0x00939c50 in consecutive records, all
inside EE RAM and ascending across the table as a walk of consecutive objects
would be. Byte 11 becomes 0x30 in every record seen, against a constant 0x05 on
disc, and bytes 8-9 hold a varying small value (0x13, 0x16, 0x24).

So the packet table at +0x40 is what the loader walks: span-relative on disc,
absolute in RAM. This was inferred from one class before; these 40+ records
show it directly.

Consequence for the tag bytes: the vocabularies at record bytes 7 and 15 are
real, but byte 15 sits in a field the loader overwrites with a pointer. Its
disc values are read as data by something rather than stored as a tag.

Not settled: whether byte 7's vocabulary is also rewritten, and whether 0x6d
marks payload the loader expands. That needs these records compared one by one
against their disc spans, which is cheap now that the anchor exists.
2026-10-12 02:15:30 +02:00
ReaperOfVeriod d1aec9f866 Show shrub packet records are two tagged 8-byte halves, fixed length
Range measured: level 0, the 1636 sixteen-byte records following the header
across all 29 packets. Per-byte distinct counts, not pooled.

Method: count distinct values and zero frequency at each of the 16 positions,
then tabulate the joint distribution of the two low-entropy positions.

Result: fourteen positions are uniformly varied, 101 to 153 distinct values,
zero in 13% to 58% of records. Two are not:

  byte 7   4 distinct: 0 (1432), 0x30 (135), 0x6d (40), 0x80 (29)
  byte 15  2 distinct: 0 (1618), 0x6d (18)

Both sit in the last byte of an 8-byte half, eight bytes apart, and both take
values from a small closed vocabulary rather than the full byte range. The
joint distribution shows no record where both are non-zero, and 0x6d appears in
either position but never both:

  (0,0) 1414   (0,0x6d) 18   (0x30,0) 135   (0x6d,0) 40   (0x80,0) 29

Each 8-byte half carries a tag or subtype byte in its last position, from a
small closed set. The shared 0x6d is what suggests one vocabulary used twice
rather than two unrelated fields.

This answers the question the previous commit left open. There is no per-record
length field: the best candidate offsets hold the value 16 on at most 13% of
records, which is the chance rate for small integers, not a self-describing
record. The records are therefore fixed 16 bytes, and the tiling is not a
coincidence of variable-length records that happen to be multiples of 16.

Still open: what the four and two tag values mean, and what the 14 varied bytes
encode. The tag vocabulary is small enough to enumerate against known PS2
primitive lists, which is the reference to bring in next.
2026-10-12 02:09:56 +02:00
ReaperOfVeriod e6dd60ec3a Characterise shrub mesh packets: 16-byte header, 16-byte tiling
The packet table decode left the packet contents open, with a warning that a
VIF reading had already been retracted once and needed a control rather than a
pattern match. This is that measurement.

Range measured: level 0, the 29 packets of the 6 successor-bounded shrub spans,
26640 bytes. Per-packet; nothing pooled.

Method: autocorrelation within the largest packet against a distribution
matched shuffled control, then per-byte distinct counts across all 29 packets.

Result 1: all 29 packet sizes are exact multiples of 16, and the internal stride
scan peaks at 16 (+0.283) with 32, 48, 64, 80 and 128 all elevated behind it --
the signature of a record array, not a coincidental spacing.

Result 2: record 0 is a 16-byte header. Constant across all 29 packets at bytes
0-3 (04 04 00 01), 4-11 (zero), 12-13 (00 80) and 15 (6c). Bytes 14 and 16-23
vary. The 04 04 00 01 prefix looks like a magic or format version.

Result 3: header byte 14 is echoed in the body. It takes values 06, 07, 0e and
11 across packets, and the same value appears at offset 12 of record 1 in every
packet sampled. A real repeated field, not two small values coinciding.

Not established: no header field matches the record count. The packet holds
1056/16 = 66 records and none of the candidate u32 or u16 header offsets at 8,
10, 12, 14, 16, 18 or 20 equals 66, 65 or 67 on more than half the packets. So
the count is either computed from a size field elsewhere in the span, or the
records are variable length and merely 16-byte aligned.

That distinction is unresolved and matters. Fixed 16-byte records make the
payload a flat table; variable records make the 16-byte alignment a coincidence
of every record being a multiple of 16 long. The stride scan cannot separate
the two, because both peak at 16.

The retracted VIF reading is not revived: the tiling is uniform across all 29
packets with no command-shaped tail, and a VIF packet would show a changing
command byte last rather than the constant 6c at header byte 15.

Next: read the record count from the packet table in the span rather than from
the packet header, which is what would separate the two readings.
2026-10-12 02:08:12 +02:00
ReaperOfVeriod aa2b14ad66 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.
2026-10-12 02:02:01 +02:00
ReaperOfVeriod c83f5104d2 Document the PCSX2 test setup
PCSX2 was set up and working but existed only in this machine's filesystem
and in people's heads, so every comparison looked like a one-off observation
rather than something re-runnable. The setup section in
docs/plan/pcsx2-verification.md now records where the emulator, the BIOS, the
disc, the savestates and the PINE socket are, how a state is captured, and
which command the disc integration tests need -- and AGENTS.md and README.md
point at it from their orientation sections. One operational detail worth
spelling out: the emulator's directory is lowercase (pcsx2), which is not
what people type.
2026-10-11 20:59:19 +02:00
ReaperOfVeriod bbdb51e2f9 Verify level 1's tfrag lighting against the game
Savestate slot 2 is the second planet, so the comparison runs on a level with
a different geometry extent, light bank and runtime-set population:

  level1: 401/401 headers, 401/401 pointers, bank byte-identical
  vertices compared: 26470 (105880 colour bytes)  -- equal: 100.00%

The live emulator agrees with the savestate byte for byte. Level 1 is a
different planet, so the two fixes in the previous commit are not level-0
coincidences: the normal's z sign and the unlit-record base colour both hold
across 268,280 colour bytes on two levels.
2026-10-11 20:50:36 +02:00
ReaperOfVeriod 69a8398f6e Verify the tfrag lighting magnitudes against the running game
The harness that compares the port against the game's own memory existed but
had never been compiled: tools/trace did not build (27 errors, all in the
argument plumbing), and the tfrag_light.rs light-bank correction it motivated
was unverified. This finishes it and runs it, and the run finds two real bugs.

Result, on level 0 against three inputs -- the live emulator over PINE and two
PCSX2 savestates from the same level, agreeing byte for byte:

  headers matching the disc on invariant bytes: 833/833
  data pointers relocated as table + disc offset: 833/833
  light bank in RAM at 0x50ab20, byte-identical to the disc's
  vertices compared: 40600 (162400 colour bytes)
  equal: 162400 (100.00%)

The locator finds the level's tfrags block in RAM by tfrag 0's bounding
sphere and accepts a candidate only when every header matches the disc on the
bytes the game leaves alone and every data pointer is the relocated form, so
"the block was found" is itself a verification rather than an assumption. The
bank is searched in RAM by its exact section bytes, which is what settles its
framing: offset at +0x04, an i32 set count, twelve bytes of pad, then the sets.
The previous reading (offset at +0x00, count at +0x04) was wrong.

Two bugs the comparison found, both of which produced plausible output:

1. vertex_normal_bits negated x and y and left z positive, while
   NormalTable::normal negates all three. dA came out -0.707 where the game
   had +0.707, so max(dA, dA*wA) took the wrong branch and every vertex lit
   near its base colour: dark, but not wrong-dark. 95.88% agreement with the
   bug, 100.00% without.
2. A record whose select names a set the bank lacks drew transparent black.
   The game writes the record's base colour; 3,130 of level 0's 40,600
   vertices do this (select 0x000f, set 15 -- a runtime slot the level's
   directional bank does not have). The vertex is unlit, not invisible.

Both are now regression tests in the engine. The comparison prints the pass's
per-vertex terms (light_vertex_terms) for the first mismatches, because a
colour mismatch alone cannot be attributed and the comparison is worthless
without attribution -- that is how the second bug was identified from a
three-line read rather than guessed at.

Docs: docs/plan/pcsx2-verification.md records the harness, the result, the
two bugs and what to compare next; roadmap and level-assets.md stop carrying
lighting magnitudes as an open item. Findings reach tests only as numbers --
no test reads a savestate, a dump or a live emulator.
2026-10-11 19:51:25 +02:00
ReaperOfVeriod 2c7555fe16 Decode the shrub class blob: a 24-normal palette and a billboard record
The shrub body's payload encoding was the open question after the span-bound
work, and it answered itself once the R&C1 reference was in view: R&C1's shrub
class header keeps the field offsets R&C2 shows (o_class at 0x24, a count at
0x28, the data offset at 0x2c), which says which bytes to look at, and the disc
says what they hold.

Range measured: all 358 shrub class spans over the 24 levels that have them,
each bounded by its successor class or the located chunk container. Nothing
outside the spans is read.

Result. The body is a palette of 24 unit-length s16 normals -- s16 x, y, z and
a zero pad word per 8-byte record -- followed by all-zero padding records.
Three independent measurements, each of which fails for any other reading:

- all 8,592 live entries have squared length within 65,535 of 32767^2, and
  the bound is tight (the largest observed deviation is exactly 65,535). That
  is 32768^2 - 32767^2, which is what a unit vector scaled by 32768 produces
  when an axis-aligned normal quantises to -32768 and the positive axis clamps
  to 32767; components run the full s16 range and 1,765 entries are
  axis-aligned. A random s16 triple is off by about a billion, so one entry in
  fifteen thousand would pass by chance: all 8,592 pass.
- the padding records are always at index 24 or later and always all zero,
  never a live entry. That is what separates the palette from geometry, and it
  is the observation the three earlier stride-fitting attempts were missing.
- 17 classes carry a palette of 24 identical (0, 0, 1) entries. A position or
  index table cannot produce that; a normal palette for a flat-facing class
  must.

The header decodes as: sphere at +0x00 (centre + radius, radius positive on
all 358 spans, 1.72..41.57), size at +0x10, mode_bits and scratch at +0x14,
a zero word at +0x18, the billboard offset at +0x1c, and scale at +0x20. The
parser asserts the two invariants that hold without exception (scratch and the
reserved word are zero on all 358) plus a positive radius and scale, and
reports mode_bits rather than asserting it: it is 4 on 16 of the 358 spans.

The billboard offset is the strongest self-identification after o_class
because it is confirmed from a second file: it is non-zero on exactly the 25
classes whose core-index entry carries billboard texture fields, pairing
correctly both ways. At the offset sits R&C1's ShrubBillboard shape, register
block and all -- fade_distance, then TEX1_1 (K -233..-168, MMIN 4, address
0x14), TEX0_1 (index 1..4, address 0x06) and MIPTBP1_1 (address 0x34), all
three register addresses checked in the disc test.

Two corrections this produces, both recorded in docs/plan/level-assets.md
because the old readings were confidently wrong rather than merely
unverified:

- "reserved is 0 or a denormal" had the two u16 halves the wrong way round:
  the 16 non-zero spans are mode_bits = 4 with scratch = 0. As an f32 the word
  is still a denormal, so the PS2 reads zero either way -- but the field is a
  flag word, not a magnitude, which is exactly the FPU point worth making.
- "bounds" was tested as a min.xyz/max.xyz box, which holds on only 146 of
  358 spans, and the field was left unnamed as a result. It is a bounding
  sphere: the fourth float is positive on all 358 spans and the centres
  cluster near the class origin. The 146/358 figure was a property of the
  wrong question.

Still open: the three floats in the billboard record (they repeat
byte-identically across levels -- 16 distinct triples among 25 records, one on
four classes across levels 1, 4, 12 and 21 -- so they are shared art data
rather than dimensions), size, mode_bits, count, and where the shrub mesh
geometry lives. R&C1 puts the mesh packets in this blob; R&C2's blob holds
only the palette, so the 333 classes without a billboard must draw meshes from
somewhere else.
2026-10-11 13:33:37 +02:00
ReaperOfVeriod ccea53807a Resolve the shrub region bound: the chunk container closes it
The shrub region's end was the last unlocated bound in core_data. It was not
in the core index, and unlike moby->tie it had no successor boundary to chain
against, so the last shrub class in every level ran out to the lump's end and
the aggregates over it (the "3.5M records / 28MB of shrub body") were
artifacts of that oversized span.

Range measured: for each level, from the last shrub class's geometry offset to
the constant trailer. Nothing outside that range is read.

Method: scan forward from the last shrub class for the pair (a) the constant
two bytes f6 7c sitting immediately in front of (b) a chunk signature -- a
16-byte header ending 0xff, ten zero bytes, then four or more 0xf0-tagged
offsets. The two halves are each weak alone, and the halves are measured
separately: the marker pair occurs 25 to 103 times in a 5 to 17 MB lump, so
matching it alone picks dozens of wrong offsets; the chunk shape matches
random data often enough to need four tagged offsets before it is believed.

Result: on all 27 levels the marker sits exactly two bytes before the first
chunk header, and the two parts of the signature are never found apart. No
chunk signature occurs inside any last shrub body, on any level. The three
levels without shrub (10, 25, 26) carry the same marker before the container
that closes tie instead, which is what makes the locator a property of the
region rather than a shrub rule.

Consequences:

- All 358 shrub class bodies are bounded and are whole 8-byte records. That
  was not assertable while the last class in each level ran to the lump's end,
  and it is the check the boundary earns its keep for.
- 9,600 records in total, against the 8,938 previously measurable in
  successor-bounded spans; 662 of them sit in the 24 last-class bodies. The
  shrub region totals 2,033,776 bytes across the 24 levels that have one.
- The chunk container is 25,775,850 bytes across 27 levels (1,236 chunks,
  22,562 records). The container measurements were previously taken over "the
  900,000 bytes before the trailer", a window that did not align to its real
  extent: on level 0 it is 690,302 bytes, and 27 of 1,123 chunks were
  truncated at that invented edge. chunk_containers_resolve_on_every_level now
  reads the region from core_layout instead of measuring backwards from the
  end of the lump.

core_layout takes core_data now: the container's start is the one bound that
cannot be derived from the index. It gains the chunks region, so every byte of
level-specific core_data belongs to a named region. Where the signature is not
found the end stays None -- the region is left visible rather than clamped to
the tail.

Still open: the marker's meaning (constant across 27 levels, at exactly this
position, unnamed) and the shrub body payload's encoding.
2026-10-11 06:20:48 +02:00
ReaperOfVeriod aec462df21 Record that shrub span bounds are wrong, and what that invalidates
A follow-up measurement contradicted the body size implied by the
differential analysis, and the cause is bad span bounds rather than
anything in the format.

Shrub spans were bounded by the next shrub offset, with the last running
to the end of core_data. On level 0 that gives the final shrub class a
925,008-byte span, absorbing most of the lump's tail. The moby-to-tie
boundary is clean by comparison: last moby offset 13414592, first tie
13416192, exactly 1600 bytes apart.

What that invalidates:

- The 3.5M record / 28 MB body figure is an artifact of the oversized final
  span and should not be quoted.
- The "payload looks uniform-random" measurement ran over the same
  contaminated range, so it says nothing about shrub geometry either way.
- The differential result itself still stands -- it was computed on groups,
  and the groups used had bounded spans. The global aggregates built on it
  do not.

What is needed is the offset ending the shrub region. It is not in the core
index, and there is no obvious successor boundary the way moby->tie has one.

This is the second confident-sounding number in this format that turned out
to rest on a bad bound, the first being the VIF reading. Aggregates across
many spans are only as good as the span bounds under them, and bounds are
the easy thing to get wrong because nothing errors when you do.
2026-10-11 02:54:19 +02:00
ReaperOfVeriod 52228c14ed Retract the VIF reading of the shrub body
The previous commit concluded the shrub body's 6-byte payload was
VIF-packed, on the strength of recurring patterns like 7ea5 and 1ab6. That
was pattern matching dressed up as a conclusion, and a control kills it.

Decoding every 32-bit word of every shrub body as a VifCode:

  shrub bodies                    24.9% unpack, 128 distinct cmds
  uniform random                  25.1% unpack, 128 distinct cmds
  sound_bank (definitely not VIF) 19.0% unpack, 122 distinct cmds
  gs_ram (mixed)                  13.6% unpack, 128 distinct cmds
  moby class 0 span (known VIF)   40.1% unpack, 105 distinct cmds

Shrub sits on the random baseline and exercises all 128 possible command
bytes, which is what unstructured data looks like.

Worth being precise about what this does and does not show. The test is
weak -- even the known-VIF region only reaches 40.1%, well short of a
clean signal -- so this does not prove shrub is not VIF. It proves the
claim was never established, which is the part that was wrong.

Spotting three recurring byte values and naming them a format is the same
mistake as the three stride guesses it was meant to replace, one level
down. A hypothesis needs a discriminating test before it goes in a commit
message, and I skipped that step because the finding was welcome.

What still stands is the part that rests on differential analysis rather
than recognition: 8-byte records, 6 varying and 2 constant, 144 varying
bytes per group regardless of count. The payload's encoding is unknown.
2026-10-11 02:53:04 +02:00
ReaperOfVeriod 572753e7c1 Find the shrub body stride by diffing spans instead of fitting it
Stride-fitting the shrub body failed three times, so this attacks it from
the other side: group spans by (count, data_offset, tail length) and byte
diff every member of each group. Positions that differ across a group are
geometry; positions that never differ are structure.

The answer is blunt. 144 bytes vary in every group, whether that group has
count 1, 2, 3, 5 or 6 and a tail of 192, 208 or 240 bytes. The varying
data is not sized by count at all, which is why three stride guesses were
fitting the wrong thing -- and why they kept looking nearly plausible.

Those 144 bytes fall in runs of 6, every 8 bytes. The body is an array of
8-byte records: 6 varying bytes and 2 constant.

The payload is not half-floats -- decoding as half3 gives 245.25 and
-168.375, which no shrub has. The patterns (7ea5, 1ab6, 43d9, af80) and
their repetition are VIF-packed data, the same quadruple-word packing moby
and tfrag use. Filler around it is the PS2 NaN 0x7fff0000 rather than
zeros, which is why the body read as noise in the earlier dumps.

So the open question is the VIF code assignment, not the stride, and the
decoder written for tfrag is the obvious place to start.
2026-10-11 02:44:15 +02:00
ReaperOfVeriod f6bbbf8d5f Add gc-extract probe to dump structures through the real parser
Reading these formats by unpacking raw offsets out of core_index.bin has
cost real time. The index interleaves single-word region offsets with
count/offset pairs, so miscounting one shifts every field after it: that
produced a page of implausible collision headers on all 27 levels, sent
me chasing a tie-breakoff that turned out not to exist, and nearly had me
"fix" a stride bug in code that was already right.

probe dumps a level's core header and all three class tables through
LevelCoreHeader, so a field name printed is the field name the parser
uses. It also reports how many entries carry geometry inside core_data,
which is what pins the class table strides rather than guessing at them:
shrub reads 7/7 sane at 0x30 against 4/7 read at 0x20.

Reading a structure by hand in a shell still works; this just stops it
being the default, and stops the offset arithmetic being re-derived every
time a new region is investigated.
2026-10-11 02:36:37 +02:00
ReaperOfVeriod c4ba84232a Record that tie does not reuse moby's part table
Checked the obvious hypothesis -- that tie, being the other class-geometry
table, would share moby's part-table format -- and it does not.

The tiling rule vertex_offset + 16 * vertex_count == next_part holds for
0 of 25,024 records across the 1,864 tie spans, against 43,467/43,467 for
moby. Tie's second field also reads as an absolute offset rather than a
span-relative one, which is the first sign the record shape differs.

Past the verified 0x48-byte header the bytes are f32-shaped rather than
the expected VIF marker, but they are not a fixed array either: the run of
consecutive plausible floats varies from 1 to 6 across spans (822 spans
have 6, 455 have 1, 373 have 2). So there is more header past 0x48 and
it is not one array of distances -- the floats after that point are a
different population from the LOD distances before it, including negative
values.

Recorded so the next attempt starts from that rather than re-trying the
moby template.
2026-10-11 02:34:54 +02:00
ReaperOfVeriod 3ca8522be1 Record shrub geometry findings and the per-table entry strides
The three class tables do not share an entry size. Measured by how many
entries give a geometry offset inside core_data plus a plausible o_class,
over all 27 levels:

  moby  stride 0x20   5769 / 5796
  tie   stride 0x20   1864 / 1864
  shrub stride 0x30    356 / 358

Reading shrub at 0x20 still yields 231 sane-looking entries out of 358,
which is the hazard: wrong often enough to look right. The committed code
already handles this -- ShrubClassEntry is 48 bytes and carries billboard
texture fields the other two lack -- so this is measurement, not a fix.

Shrub geometry is not moby's part-table format: all 358 spans have a
leading word of 0 or one too large to index a table into, so none contain
a moby-style part table.

Two header fields do self-identify, both exact across all 358 spans:
+0x24 equals the class entry's own o_class, and +0x2c is a data offset
landing inside the span. +0x10 reads 5.0f on 281 of 358, so it is a value
rather than a marker and carries no structural weight.

Rest of the shrub header undecoded.
2026-10-11 02:32:55 +02:00
ReaperOfVeriod bbb10c678d Parse the moby per-class part table
A moby class span does not start with its geometry. The first u32 is the
offset of a part table, and the table is 16-byte records of vertex
offset, vertex count, an unestablished second count, the next part offset
and flags.

Parts tile: vertex_offset + 16 * vertex_count == next_part. The stride is
never assumed anywhere -- it falls out of that identity, which is what
pins the record size and the field boundaries. The identity holds for
43,467 of 43,467 part records across every moby class on all 27 levels,
and the parser rejects any span that violates it rather than returning a
plausible wrong answer.

Two terminations are needed. A zero vertex_offset ends the run, but 172
records on the disc have a zero vertex_offset and a zero next_part while
still carrying a vertex count. Stopping only on the first leaves 99.6%
instead of 100% and quietly includes a second table as if it were parts.

1,270,802 vertices over 4,560 class spans; 116 spans carry no geometry.

Vertices are 16 bytes of VIF-packed half-float quads cycling through four
distinct words. Handed back raw -- unpacking them is separate work from
finding them, and second_count is recorded but not guessed at.
2026-10-11 02:29:57 +02:00
ReaperOfVeriod 1f96ef09b9 Record core index field order, sky and moby findings
Three things this session that are cheap to rediscover wrongly and
expensive to rediscover wrongly twice.

The core index interleaves single-word region offsets with count/offset
pairs, so gs_ram and the three class tables are two words each while
tfrags..collision are one. Treating the pairs as single words shifts
every field after them, which is how collision got read as sky and
produced a page of implausible collision headers on all 27 levels.

Sky is not uniform. Most levels share a header shape, but level 2 carries
extra fields and the count word at +0x0a over-reports against the number
of real offset slots, so no single fixed header fits.

Moby geometry starts with zero words rather than a header and then packed
half-float VIF data. Not decoded; recorded so the next attempt starts
from the data instead of re-deriving the layout.
2026-10-11 02:25:47 +02:00
ReaperOfVeriod 79025b22d6 Decode the tie class header prefix that RAC1 and RAC2 agree on
Tie is the easy one of the three geometry subsystems -- instanced
static props, no skeleton -- so it is the right place to start.

RAC1's class header is 0x80 bytes. RAC2 agrees with it over the first
0x48 and diverges after. From 0x48 the RAC1 layout does not describe
RAC2 data: lod_info at 0x50 reads back as noise, packet_count and
texture_count are zero on every class examined, and the normals offset
at 0x0c points 117,788,434 bytes into a 16.9 MB lump.

Only the agreed-on prefix is decoded. What makes it trustworthy:

* o_class at 0x44 equals the core index's class number for all 1,864 tie
  classes across all 27 levels. A field landing on the right number that
  often is not a coincidence, and it pins the header base.
* The LOD distances are ordered 0 < near < mid < far for 1,784 of 1,864.
  The 80 exceptions are real, unexplained, and are reported rather than
  asserted away.

Level 0's first tie class has distances 12.0 / 25.0 / 40.0, a plausible
prop LOD band, and packet offsets 256, 20,592 and 26,048.

The rest is deliberately not guessed at. A field order that produces
plausible output and is wrong is the specific failure this project keeps
recording, three times now: the gameplay extent, the tfrag record
framing and the class tables were all "reasonable" and all wrong.
2026-10-11 02:06:39 +02:00
ReaperOfVeriod 4c4a78d402 Identify the container the class tables point into: core_data itself
assets_compressed_size and assets_decompressed_size at index offsets
0x88 and 0x8c are 9,738,678 and 16,969,296 on level 0 -- exactly the
sizes of core_data before and after decompression. There is no separate
asset container. The "asset WAD" is core_data, and offset_in_asset_wad
indexes the decompressed form directly.

Checked rather than assumed, on all 27 levels: the decompressed size
equals core_data's exact length, every class offset is either 0 or inside
it, and the moby, tie and shrub spans do not overlap each other.

An offset of 0 means the slot has no geometry, not the start of the
lump; level 0's ninth moby class is 4909 at offset 0. Two assumptions
worth discarding while here -- the tables are not sorted by class number
(level 0's run 0, 1, 2, 14 ... 4909) and the offsets within a table are
not monotonic. Neither is needed, but both look true from the first few
entries.

The chunk descriptors after the named extents are not it: they exist on
only 3 of 27 levels, because level 0's four named extents tile the
container exactly. An asset blob there would be missing almost
everywhere.

What sits at the offsets is raw GS/VIF packet data rather than a further
container -- moby[0] begins 00 00 00 00 3f 16 02 55 6f 6f and tie[0]
00 01 00 00 70 50 00 00, which read as packet headers rather than a
magic number.

None of the three is decoded yet and this does not start that. Moby is
skeletal and is the largest single piece left before the rest of a level
can draw.

Also: the pipeline now writes each level's chunk descriptors where they
exist, and LevelCoreHeader decodes the asset lump's two sizes.
2026-10-11 02:03:31 +02:00
ReaperOfVeriod e1105543e4 Parse the level core class tables: moby, tie and shrub
These were ruled out for want of an entry size. The size is knowable
exactly, because each table ends precisely where the next begins:

  moby  238 entries at   192, 32 bytes:  192 + 238*32 =  7808 = tie's
  tie    74 entries at  7808, 32 bytes: 7808 +  74*32 = 10176 = shrub's
  shrub   7 entries at 10176, 48 bytes: 10176 +   7*48 = 10512 = textures

That closes on 27 of 27 levels, which is what makes it evidence rather
than a coincidence: any wrong entry size breaks the chain immediately.
It is the same "does this hold on every level" discriminator the project
already uses for the container pair table.

Across the disc: 5,796 moby, 1,864 tie and 358 shrub classes, plus the
1,595 tfrag textures. Class entries are 32 bytes -- asset offset, class
number, two unknown words and 16 bytes of texture slots -- and a shrub
entry adds 16 bytes of billboard slots for 48.

The header now decodes through to the texture tables rather than
skipping 0x18..0x30 wholesale, which also recovers the moby, tie and
shrub texture ranges. Two more pairs sit between shrub textures and the
textures base offset.

Two things worth recording. ClassEntry::parse read its 16 texture bytes
before its four i32s, because the struct literal that uses them comes
after the loop that advances the cursor -- so every entry was parsed
backwards. Struct literal field order does not reorder the reads that
already happened. And every shrub class on level 0 carries all-zero
billboard slots, so the dimensions are optional rather than something
worth asserting on.

This is the third instance of the same failure shape. The gameplay
extent was "an unidentified compression" because it was parsed
compressed; the tfrag record framing was "unrecoverable" because the
entry size was never known. Both looked like properties of the format
and were properties of the question.

Also records that point lights are a runtime feature rather than missing
work: every one of the 18,102 patches has point_lights == 0xffff and the
eight-slot bank is filled by gameplay, so there is nothing for them to
light in a static level render.

And that PCSX2 is not installed, so the comparison loop cannot be built
yet -- which is why lighting magnitudes stay unverified.
2026-10-10 03:44:47 +02:00
ReaperOfVeriod b8b4a1a3d3 Consolidate: tidy level.rs, correct stale docs, pin hardware models
No behaviour change. This is the pass over the last stretch of work to
make sure the accumulated result hangs together rather than only each
commit in isolation.

Code. lit_color had grown a `let _ = (depth, lod_distances)` and a dead
branch from repeated edits. The directional pass genuinely does not
depend on distance, so the parameters are gone rather than discarded,
with a note that the point lights -- not implemented -- will need them.
lit_at folded into lit_color, which had become a wrapper around it.
Checked for leftover scaffolding: only two env vars remain, both real
(GC_ENGINE_SHOT and GC_ENGINE_DEBUG_BOUNDS); GC_ENGINE_UNLIT,
GC_ENGINE_DEBUG_COLOR and GC_ENGINE_ABL were never actually written,
because those edits silently failed to apply against rustfmt'd source.

AGENTS.md. The crate table did not say that gc-engine owns hardware
models. ps2.rs is the Emotion Engine's FPU and the lighting pass runs
on it, so the boundary is now stated: gc-formats decodes inputs,
gc-engine reproduces how the hardware computes with them. That is the
distinction rule 2 depends on, and it was implicit.

Docs. Three claims had been overtaken by what we learned and were
actively misleading:

* docs/formats/level-wad.md still said gameplay "does not decode with
  the WAD decoder". It does; it is a WAD stream.
* docs/plan/level-assets.md carried a whole section, "a compression we
  have not identified", with a ruled-out list including "bit order",
  "header length" and "the WAD format at all". All three were ruled out
  against the wrong input. Replaced, with the failure mode kept: a
  uniform failure across 27 inputs was read as "the decoder is wrong"
  when it meant "the data was handed over uncompressed", and the
  positive control -- decompress and look -- was never run.
* The same file still said the tfrag "record framing is not recovered".
  True, but it was a false lead that has since been superseded by the
  VIF1 framing. Kept as a recorded wrong turn rather than deleted.

Also refreshed "Still open" to name what is actually outstanding:
point lights, the GS register set, the guard-band clip test, the
unread core_index class tables, and -- stated plainly -- that lighting
magnitudes are unverified because there is still nothing to compare
against.

Verified after the changes: 148 tests pass, clippy and fmt clean, all
5,213 extracted files match the hash table, and level 0 still loads at
26,758 triangles with the same LOD split and fully lit.
2026-10-10 03:37:03 +02:00
ReaperOfVeriod 65b5d366de Implement the PS2 float model and the LightTfrags pass
Two pieces, and the first is the reason the second can be trusted.

ps2.rs is the EE's FPU on raw u32 bit patterns. It is not f32: there are
no denormals (exponent 0 *is* zero), no NaN and no infinity (exponent 255
is an ordinary value, so MAX reads back in Rust as NaN -- a test asserts
exactly that, because it is the sharpest available statement of why this
module exists), every result truncates toward zero, the adder discards
the small operand's bits more than one below the large one's LSB and
returns the large operand unchanged across a 25-bit exponent gap, and
rsqrt is sqrt-then-divide with truncation between the steps rather than
one rounded operation. The lighting pass accumulates all of these across
every vertex of every patch, so this is a visible difference rather than
a rounding curiosity.

tfrag_light.rs is the pass itself: PEXT5 base colour, the select word
picking one light set or blending two by bits 8..15 / 256, blended
directions re-normalised where unblended ones are not, and
base + A.rgb*max(dA, dA*wA) + B.rgb*max(dB, dB*wB). Lighting indexes
positions, not vertex-info entries, since a common entry and a LOD entry
can share a position.

TfragLight gains a `select` field for the u16 at +6. It was briefly
reconstructed with an unsafe pointer cast over the struct, which is
exactly the sort of thing that breaks silently the moment a field is
reordered; it is now a plainly parsed field.

The pass does not run yet. The light bank framing the reference uses --
a u32 offset at +0x04, an i32 count there -- gives offset 0xa0d and a
count of 698810368 on RAC2 level 0, so RAC2's gameplay extent is not
laid out that way. The renderer now counts the vertices it actually lit
and prints it, because falling back silently to the stored colours
produces a plausible but wrong picture:

  lighting: 0 of 80274 vertices shaded by LightTfrags; the rest fall
  back to the tfrag's stored colours (no usable light bank)

That line is the current honest state, and the frame has not changed as
a result. Where RAC2 keeps its light bank is the open question.
2026-10-10 03:21:52 +02:00
ReaperOfVeriod 90f02890f8 Decode tfrag normals from the overlay's sine table
Normals are per-vertex, and the tfrag's light records store them as an
azimuth and elevation *index* into a 256-entry sine table rather than as
angles. This decodes that table and builds normals for the renderer.

The reference reads the table from an ELF at BOOT_NORMAL_TABLE_VADDR
(0x165500) and notes every level overlay carries a copy. The overlay has
no ELF header -- it is a raw loadable image starting with code -- so there
is no program header to map a virtual address through, and the offset is
not fixed either: level 0 has the table at 0xb650c, level 5 at 0xcca30,
level 26 at 0xb6284.

So it is located by content. Every table starts with exactly (1.0, 0.0),
angle zero, and the scan then requires all 256 entries to match
cos(i*pi/128), sin(i*pi/128). Validating the whole curve is what makes the
search safe: a lone (1.0, 0.0) pair of floats occurs by accident in a
2.7 MB executable, and a test plants one as a decoy. The values are read
from the disc rather than recomputed, since those are the numbers the game
indexes.

A normal is (cos(az)cos(el), sin(az)cos(el), sin(el)) *negated* -- the
stored normals point into the surface -- and then given the same Z-up to
Y-up swap as positions, or the lighting comes out sideways. The azimuth
and elevation fields are stored signed but index 0..=255, so entry 200
must be read as 200, not as -56; there is a test for that.

Also frames and parses the level light bank from the gameplay extent, and
the pipeline now writes overlay.bin. The per-vertex lighting evaluation
is not written yet, so the renderer stays unlit by default. GC_ENGINE_LIT=1
substitutes a host directional light for inspection -- that is a stand-in,
not the game's lighting, and it is deliberately not the default.

The hash table is regenerated for the 27 new overlay.bin files: now 5,213
files and 5,762,497,559 bytes.
2026-10-10 03:09:07 +02:00
ReaperOfVeriod 17c7e8ccc9 Draw tfrag per-vertex colour, unlit, and record why the frame is dark
The tfrag RGBA array now modulates the texture. It is indexed by
*position*, not by vertex-info: several vertex-info entries can share a
position, and the array is sized vert_count, which is a position count.

Colour follows the same rule as the vertex itself. Morphing blends the
position only -- the game keeps the high-detail colour -- while collapse
replaces the vertex outright, colour included. Getting that the other way
round would tint distant terrain.

The material is unlit, and that is a decision rather than a shortcut.
Level 0's colours really are dark (patch 0 runs (25,26,26) to
(108,144,141)) and the game's lighting is what brightens them, so what is
on screen is the true albedo.

Worth recording: the obvious move -- add a directional light, or crank
ambient brightness -- does nothing at all. With no normals the PBR path
is degenerate, so the brightness change has no effect, and a host light
would have hidden the gap rather than shown it. The colour data was
always reaching the mesh; what is missing is lighting, not a material
flag.

Written up in plan/level-assets.md, including what LightTfrags actually
needs: the light bank from the level's gameplay extent, the 256-entry
normal table at BOOT_NORMAL_TABLE_VADDR (0x165500) which every level
overlay carries, and the already-parsed azimuth/elevation light records.
2026-10-10 03:02:52 +02:00
ReaperOfVeriod 192dcde247 Render a level: first drawable frame, and the origin bug it exposed
Adds gc-engine's binary and a level.rs that joins the format layer to the
renderer: LOD selection and morphing per patch, then triangles grouped by
texture so each texture is one draw rather than one per triangle.

The PS2 world is Z-up at 1024 raw units per world unit, so the conversion is
(x, y, z) -> (x, z, -y). The sign flip on the third axis turns the PS2's
right-handed frame into Bevy's left-handed one rather than negating a
determinant, which is what stops the level rendering mirrored.

Morphing and collapse are kept distinct. Morphing blends a vertex toward the
midpoint of its two parents across its tier's band; collapse replaces the
vertex outright, position and UV, once it is past the far edge. Morphing is a
blend, collapse is a cut, and conflating them makes distant terrain
shimmer. Blending has to happen in raw units before the axis swap: mixing a
converted parent with an unconverted child scales the blend by 1024.

Level 0 renders as 26,758 textured triangles over 92 textures, correct UVs,
with LOD selection live (26 patches at LOD 0, 650 at LOD 2 from the start
camera). GC_ENGINE_SHOT writes one frame as a PPM and exits.

Rendering immediately exposed a bug no test could. Positions are i16 offsets
from a per-patch origin, and the obvious rule -- the last non-index STROW row --
is wrong on RAC2, which also uses STROW for per-vertex light records that come
*after* the origin. Taking the last one yields [186, 186, 186, 186] and
collapses the whole level onto the world origin. The entire level then fit in
a 63-unit box and every test still passed: vert_count matched, tri_count
matched, every parent and triangle index resolved, because all of those are
relative to the same broken origin. Only a screenshot showed it. The rule that
works is the STROW immediately preceding the first V3_16 position unpack; with
it, level 0 spans x 86..444, y 46..211, z -477..40, matching the patch
bounding spheres.

That is worth more than the renderer: a parser can be completely
self-consistent and still describe the wrong thing, and where a value is
absolute rather than relative it has to be checked against something
independent.

The frame is flat and dark because there are no normals and no lighting yet.
That is the next task -- the per-vertex light records -- not something to
paper over with an exposure fudge.
2026-10-10 02:58:43 +02:00
ReaperOfVeriod 71a3185fa5 Resolve tfrag LOD parent linkage: guard absent tiers by their counts
The four V4_8 parent arrays are classified by VU address, but the
addresses collide: on level 0 patch 1,
unk_indices_2_lod_01_addr == parent_indices_lod_0_addr == 291, and
unk_indices_2_lod_0_addr == indices_addr == 294. Matching on address
alone cannot separate them, and it fails silently -- the arrays still
parse, they just hold the wrong numbers.

Two corrections, and the first one is worth recording because it looked
like a data problem rather than a code one:

Each V4_8 element is 4 bytes and holds four indices, so an array of N
entries is count*4 bytes, not count. An earlier analysis compared the
unpack's element count against the position counts and concluded that
15,000 patches could not hold enough parents. That was the wrong unit;
the data was fine.

unk_06 and unk_0a suppress an empty tier. When the extra vertex-info
count is zero there is no extra array and its address is simply the next
tier's, so guarding those branches on the count lets the address fall
through to the array that exists. That one change took resolved LOD-0
entries from 28,739 to 341,306.

Result: of 886,841 vertex-info entries across all 27 levels, 516,492 are
common and have no link by design, and all 370,349 LOD entries resolve --
29,043 LOD-01 and 341,306 LOD-0 -- with zero failures. Each resolves to a
real first parent, a LOD-01 parent that is always a common entry, and
three positions inside the patch. A common entry never claims a parent.

Morphing can now be completed: a patch knows which LOD to draw, how far
to blend each vertex toward its parents, and where those parents are.
2026-10-10 02:41:18 +02:00
ReaperOfVeriod 2a5307a157 Add tfrag LOD selection, morph weights and parent linkage
The block header's float at +0x08 gives L and the switch distances are
[D0, D1, D2] = [6L, 4L, 2L] world units -- 192/128/64 on every level
measured. The distance test uses the patch's bounding sphere truncated
to integers against trunc(D * 1024), not the sphere's centre, so a
patch straddling a threshold takes the coarser mode. Six modes result:
LOD 2, LOD 1 collapse, LOD 1 morph, LOD 0 collapse, LOD 0 morph, LOD 0.

A morph weight runs 0 to 1 across its tier's band. Past the far edge an
entry is not blended but replaced by its first parent -- a collapse.

The tested property is that distance never moves back to a finer mode,
swept across the whole range, because that is what keeps terrain from
popping as the camera moves.

The four V4_8 parent arrays are classified by VU address and an
unrecognised address is now an error, which is a real check: all 18,102
patches still parse. Every recorded parent resolves to real vertex info
and real positions, and no common entry claims a parent.

That is as far as it goes, and the limit is a finding rather than a
rounding error. The addresses are not unique: on level 0 patch 1,
unk_indices_2_lod_01_addr == parent_indices_lod_0_addr == 291, with
unk_06 and unk_0a both zero, so nothing but unpacking order separates
them. Across all 27 levels 57,782 of 374,349 LOD entries resolve, and
316,548 unresolved LOD-0 entries against 28,739 resolved is far too
lopsided to be genuine -- either the arrays are misassigned where
addresses collide, or RAC2's positions_lod_0_count means something other
than RAC1's. Only 3,274 of 18,102 patches carry enough V4_8 bytes to
cover positions_lod_01_count + positions_lod_0_count + extras.

The vertex-info tiers themselves are confirmed: the addresses at +9 and
+11 match the observed V4_16 unpacks and the three tier counts sum to
vert_count on every patch, so the header layout is right and it is
specifically the parent arrays that are open. Ruled out so far:
address-only classification (collides) and one-parent-per-position (fails
on 15,000 patches). Not yet tried: ordering by VU address instead of by
declaration, and using unk_06/unk_0a to suppress an empty tier before
falling through. Written up in plan/level-assets.md.
2026-10-10 02:36:40 +02:00
ReaperOfVeriod 29fdf89f53 Decode level textures: 8-bit indices through a CSM1 palette
Adds texture.rs and makes the pipeline write gs_ram.bin, which it had
been skipping. Nothing could be decoded before this: the CLUTs live
there.

A level texture is not pixels, it is palette indices -- width*height
bytes at core_data[textures_base + data_offset], with colour coming from
a 256-entry CLUT at gs_ram[palette * 0x100]. gs_ram is a byte range in
the level's data extent header at offset 0x10, 518,656 bytes on level 0.

Two PS2 conventions have to be honoured and neither is a design choice:
within each 32-entry CLUT group the two middle 8-entry blocks are
swapped (bits 3 and 4 of the index are exchanged, an involution), and
alpha runs 0..=0x80 rather than 0..=255 with 0x80 fully opaque.

The mip chain splits across two buffers: levels 0 and 1 are in the
texture block with level 1 immediately after level 0, and levels 2 and 3
are resident in gs_ram at mipmap*0x100 and pad*0x100. Tfrag textures are
square so every level halves evenly.

Verification is two-layered, because every step here is a table lookup
and a wrong CLUT remap still produces a well-formed image of the wrong
thing. All 1,595 tfrag textures across all 27 levels decode; 96.8% are
fully opaque and 96.3% use more than eight colours. On top of that,
`gc-extract dump-texture` writes any texture or mip level as a PPM.
Level 0 texture 0 comes back as a clean bevelled metal panel with rivets
and specular highlights, and texture 74 as a vent grille whose punched
windows are fully transparent. That tool is worth keeping: it is the
cheapest way to tell a decode that is merely well-formed from one that
is right.

The hash table is regenerated for the 27 new gs_ram.bin files, now 5,186
files and 5,685,541,143 bytes.
2026-10-10 02:30:56 +02:00
ReaperOfVeriod 48d9506cf1 Decode tfrag texture bindings, vertex colours and light records
Adds level_core.rs (the uncompressed core index header and texture table)
and extends tfrag.rs with the data a draw call needs beyond geometry.

An ad-gif is five A+D quadwords holding packed fields rather than GS
registers; the level load rewrites them in place before drawing. On disc
TEX0_1.data_lo indexes the level's texture table, TEX1_1.data_lo is the
LOD bias K and data_hi the minification filter, and CLAMP_1 holds both
wrap modes.

Three things are easy to get wrong here:

* K is a signed 12-bit value in 1/16 units, sign-extended by shifting the
  field up 4 and back down. Retail values are -137..-89 mip-scaled;
  reading it unsigned gives 1911 instead of -137.
* TEX1_1.data_hi is MMIN, and 4 decodes as LinearMipmapNearest, not
  LinearMipmapLinear as the raw bit layout suggests.
* RGBA and the light/normal records are not in the VIF lists at all. They
  sit at fixed offsets in the patch data block: rgba_size*4 colours at
  rgba_ofs, vert_count light records at light_ofs+0x10. The tfrag origin
  lives there too -- if no STROW row supplied one, the quadword at
  light_ofs is it.

The texture table checks out on its own terms: 93 entries at index offset
0x2900 on level 0, and 0x2900 + 93*16 lands exactly on the next range.

Verification: an ad-gif index outside the level's table would mean the
geometry and the textures were misread independently, so every one is
range-checked against real entries. All 27 levels: 47,950 ad-gifs over
18,102 patches, all in range, referencing 1429 of 1595 level texture
entries (90%). Each patch's light array is exactly vert_count records and
its RGBA array exactly rgba_size*4.

Still open: LOD selection and morphing, and the texel data itself.
2026-10-10 02:22:51 +02:00
ReaperOfVeriod 9459f15329 Parse tfrag topology: vertex info, strips, indices and triangles
Turns tfrag positions into drawable geometry. Every unpack in a patch's
VIF lists is now classified by format *and* destination VU address, using
the VU header at the head of the common list. That table is what tells
the strips from the indices from the parent arrays: they are all V4_8
and only the address distinguishes them.

Strip indices address the vertex-info array, not the position array.
Several vertex-info entries can share a position, so an index resolves
through vertex_info[i].vertex / 2 to reach a position. Vertex-info also
carries UVs and the LOD parent link.

A strip run of n vertices is a GS triangle strip of n - 2 triangles with
alternating winding. The vertex count is biased by -128 when negative,
and the first record of a list is always biased even when positive --
real patches open with -116 or -124, never a small positive number.

tri_count lives in the patch header and cannot be derived from the lists
without walking them, so reproducing it is a strong check: all 27 levels,
18,102 patches, 809,879 triangles, every patch matching its header, every
triangle index inside its LOD's vertex-info range.

Four wrong turns got there, each of which failed quietly rather than
loudly: reading the element size from the immediate instead of the
command, 2-byte payload padding instead of 4, ignoring the inline
payload of STROW/STCOL/STMASK/MPG/DIRECT, and banking strips by arrival
order instead of by LOD. Every one produced parseable, plausible geometry.
Only the header's own triangle count caught them.

Still open: ad-gif texture bindings, per-vertex RGBA and light records,
and the LOD switch and morph rules.
2026-10-10 02:15:32 +02:00
ReaperOfVeriod 36641b4cf8 Parse tfrag terrain blocks and decode all vertex positions
Adds gc-formats::tfrag and a gated real-disc test that decodes every
terrain patch on all 27 levels.

Two rules decide whether this works at all, and both were wrong on the
first attempt:

The vector format is encoded in the VIF command byte, not the immediate.
Bits 2-3 are vn (component count minus one) and bits 0-1 are vl, giving
element_size = ((32 >> vl) * (vn + 1)) / 8. Terrain positions are vn=2,
vl=1 (command 0x69) at 6 bytes. Reading the format from the immediate's
low nibble -- the more commonly quoted layout -- yields 12 or 16 bytes
and misaligns everything after it.

Unpack payloads pad to 4 bytes, and several non-unpack commands carry
inline data: STROW/STCOL 16, STMASK 4, MPG num*8, DIRECT/DIRECTHL
imm*16. Ignoring those desynchronises the walk, and a desynchronised
walk still finds code words that look like positions.

Positions are also not all in one list. vert_count is the total across
three tiers, the LOD-2 list precedes the common one in offset order, and
the LOD-0-only list starts at shared_ofs + lod_1_size*16 rather than at
an offset of its own. Scanning only the common list matches about a
quarter of patches, each exactly -- which is what made it convincing.

The invariant is that every patch yields exactly its own vert_count. It
caught both errors, so tfrag_positions returns None on a mismatch rather
than partial geometry. All 27 levels: 18,102 patches, 821,439 vertices,
zero mismatches.

Still open: strips, triangle indices and the RGBA vertex-info arrays.
Positions alone are not drawable.
2026-10-10 02:07:02 +02:00
ReaperOfVeriod 3ec548efe8 Correct the terrain findings: region layout confirmed, record framing disproven
Two claims in the previous commit were wrong and are retracted here rather than
left standing for the next person to build on.

"382 records of 64 bytes" and "byte +3 is 0x48 in every record" were both
artefacts. The 0x48 at that offset is simply the most common byte in the region --
945 of 2000 sampled slots -- not a marker. Over 33,091 slots at that position
only 4,127 hold 0x48. The pattern holds for the first 382 records and then
breaks, which is exactly how a coincidence of a dense byte value masquerades as
structure.

"The coordinates are fixed-point s16 with a scale of 8" is also not supported.
It rested on one coincidence: 17,496 / 8 = 2,187, the planet radius. No grouping
of the record into coordinate triples produces points on a sphere of that radius.
Tested every stride from 2 to 16 bytes at every starting offset, with three and
four components; the best result is 5.0% of points on-sphere, which is chance.

What is now established, and holds on all 27 levels:

  core_data region layout, as strictly increasing offsets
    tfrag       [0, occlusion)
    occlusion   [occlusion, sky)
    sky         [sky, collision)
    collision   [collision, textures_base)

Ruled out the competing reading where 0x0c/0x10/0x14 are sizes laid end to end:
the cumulative total misses textures_base on every level and the leading bytes
of each supposed region are not plausible structures.

Also new: terrain carries its texels inline. A scan for the PS2 texel pattern
18 18 18 80 finds 6,739 occurrences in 138 clusters starting at 0xd9f8, well
inside the tfrag region, so textures_base names something other than terrain
textures.

The vertex format remains unrecovered and the record says so. The next attempt
should look for a longer run of plausible vertices rather than re-deriving a
framing from one record.
2026-10-09 16:37:19 +02:00
ReaperOfVeriod 84d94337f7 Record the terrain record layout: 382 fixed 64-byte records
Continued on the tfrag terrain region and got a firm answer on the framing,
which is what the next parser needs before it can read vertices.

The region holds exactly 382 records of 64 bytes, from 0x40 to 0x5fc0; the 383rd
breaks the pattern. Per-column analysis over 300 records separates marker bytes
from data:

  +3                 0x48 in 300/300 -- a marker, the high byte of the s16 at +2
  +19,20,21,23,43,45 0x00 in 300/300
  +52..55            ff 00 ff ff in 300/300 -- a fixed signature
  everything else    varying

Coordinates are fixed-point s16 with a scale of 8. That is not a guess: the value
17,496 appears in the first record and 17496 / 8 = 2187, the radius of a
Ratchet & Clank planet to the unit. Read as f32 the data is nonsense, which is
consistent with the earlier finding that no plausible float run exists anywhere
in the 16.9 MB.

Still open, and stated as open: which column is which of x, y and z, and whether
64 bytes is one vertex, two, or a header plus data. The ff 00 ff ff signature is
more likely a record delimiter than a GS packet, since a VIF interpretation of
the first tag does not chain.

The next step is mechanical rather than exploratory: try every grouping of one
record into 3-component vertices and keep the one whose points all fall within
a sphere of radius ~2,187. Recorded in plan/level-assets.md. No code change.
2026-10-09 16:33:49 +02:00
ReaperOfVeriod 85fa9d1432 Locate the tfrag terrain region and record what its records look like
The core header field at 0x08 is zero on all 27 levels, and the seven offsets
0x0c, 0x10, 0x14, 0x60, 0x64 and 0x68 are strictly increasing on every level.
Reading them as a sequence of region boundaries gives the layout of core_data:

  tfrag      [0, occlusion)
  occlusion  [occlusion, sky)
  sky        [sky, collision)
  collision  [collision, textures_base)

So tfrag = 0 is a real value rather than a missing field, and the terrain is the
first 2.12 MB of level 0's geometry.

The terrain begins with a short header followed by a repeating 64-byte record
from 0x40. Established about those records: byte columns 3, 7, 11 and 15 are
0x48 in every record, which are high bytes of four s16 values giving a near-fixed
Y on a planet of radius about 2,187; columns 16 and 17 increase steadily across
records like a grid walk; and columns 48-51 carry a repeating ffff00ff pattern
characteristic of GS packet headers.

The record layout itself is not yet recovered, and the write-up says so rather
than guessing. Ruled out and recorded: uncompressed f32 vertices (the longest
plausible float run in 16.9 MB is a run of zeros in the collision block, so
vertices are packed); GS packet tags at every position (0xE0..0xEF is frequent
but uniform, which is what packed data looks like); and a 48-byte record stride
(the pattern repeats at 64).

Also confirmed this session: the core header base is offset 0 with certainty,
because the compressed and decompressed sizes of core_data appear at the same
offsets (0x88 and 0x8c) in all 27 levels' core_index. And the occlusion block at
the header's 0x0c offset reads as an ascending u16 array of 65 values before
restarting, consistent with per-cell visibility records.

No code change: this is the reverse-engineering record for the next parser. 75
unit tests, clippy clean.
2026-10-09 16:32:42 +02:00
ReaperOfVeriod 16c04db559 Fix level extents read as bytes, and extract the level geometry
A defect in the Phase 1 extractor, found by reading the data extent's contents:
level extents are declared in sector counts, the generic G/ pair table uses byte
counts, and level containers were being routed through the generic path. So
LEVEL0.WAD's data extent -- 6,484 sectors -- was taken as 6,484 bytes. Every
test still passed, because a truncated file is still a file.

The extent's own header gave it away: it declares an uncompressed total of
13,277,920 bytes, and 13,277,920 / 6,484 is 2047.7. A ratio landing on 2048 is
a unit error, not a compression ratio. Across 27 levels this had been discarding
roughly 300 MB of level content.

Level containers now go through the typed LevelHeader instead. Level 0 yields:

  data.bin         13,279,232   the data extent
  core_data.wad     9,738,678   the geometry, compressed
  core_data.bin    16,969,296   the geometry, decompressed
  core_index.bin       31,600   the geometry directory
  gameplay.bin       659,456
  sound_bank.bin   1,503,232

core_data is a WAD LZ77 stream -- the format already implemented for the game --
and it decompresses cleanly. The first real level content this project has
recovered.

New: LevelDataHeader parses the data extent's own header, a table of byte ranges
naming the level overlay executable, the geometry directory, the GS RAM image,
the HUD banks and the geometry. Note this is a third meaning for a "size" field
on the same disc, after sector counts in the level header and byte counts in the
generic table; all three are documented together in level-data.md so the next
parser does not rediscover this the hard way.

The geometry directory's offsets were checked against the geometry rather than
taken on trust: all twelve land inside the 16.9 MB of decompressed core_data,
which is what confirms the header is being read correctly. The class tables
themselves do not yet decode under the documented layout, and that is recorded
as open work in plan/level-assets.md rather than papered over.

75 unit tests, 9 integration tests against the real disc. Hash table regenerated
at 5,159 files and 5,671,915,799 bytes -- hashes only, no content. Extraction
now writes to ~/Documents/gc-extracted rather than /tmp, which is 16 GB and did
not have room for a 5.3 GB output.
2026-10-09 16:28:26 +02:00
ReaperOfVeriod a9e43e39c1 Parse the level container header, naming each level's four extents
The first typed structure read from real data. Every one of the 27 level
containers begins with the same 0x60-byte header, which turns the generic pair
table into named assets: data, sound_bank, gameplay and occlusion, plus three
chunk descriptors.

Two things about this header are worth stating because both are silent failures
rather than errors.

Sizes here are sector counts, not byte counts. The generic G/ pair table on the
same disc uses byte counts, so both readings type-check and the wrong one is off
by a factor of 2048. The tiling check settles it: read as sectors, the four
extents tile every one of the 27 containers exactly; read as bytes they overlap
each other.

Level id is not container order. LEVEL21.WAD reports id 30, so a level was cut
from the disc and its number left a gap. Anything keying off file order -- a
save slot, the planet select screen -- would mislabel that planet. There is also
a second trap in the same area: the ISO filesystem sorts names alphabetically, so
LEVEL10 precedes LEVEL2, and any tool needing order has to sort numerically.

The header's field at 0x04 is zero on all 27 levels. It is not the container
size despite looking like one, so the code reads it as a runtime field and takes
the container length from the ISO entry instead.

Measured across all 27 levels, three invariants now hold in tests: extents tile
in file order; extent coverage plus chunks reaches at least the end of the
container (21 levels land exactly on its length, 6 leave 134 to 689 sectors of
trailing data); and exactly one level's id differs from its container index. The
coverage assertion is >= rather than == on purpose -- 21 levels end one sector
past the file end because the header occupies sector 0, and asserting equality
would truncate a level's tail.

gameplay is left unparsed and that is recorded rather than hidden. It begins
"WAD" plus a per-level byte and does not decode with the WAD LZ77 decoder: all
27 levels produce plausible output for the first packet and then fail on a rule
the format's own encoder never breaks, which means the input is wrong rather
than the decoder. docs/plan/level-assets.md records what is ruled out and what
to try next.

66 unit tests, plus 7 integration tests against the real disc. The unpack hash
table is unchanged, so this adds parsing without altering the pipeline.
2026-10-09 16:20:25 +02:00
ReaperOfVeriod 027373e65b Add the disc pipeline: identify, unpack and verify
Phase 1 of the roadmap. The disc is now turned into verified asset files, and
the result is checked against a committed hash table rather than against
whatever happens to be on disk.

  gc-extract identify disc.iso        SCUS_972.68 v1.01 NTSC, ELF SHA-1 matches
  gc-extract unpack   disc.iso out/   5135 files, 4,500,106,960 bytes, 10.8s
  gc-extract verify   out/ table.tsv  OK: 5135 files match

All 92 G/*.WAD containers parse. MPEG1 and MPEG2 are raw video and pass
through whole; the other 90 are indexed containers whose extents are extracted
individually.

Formats recovered and written up in docs/formats/:

- iso9660.rs. R&C2 lists its assets in the filesystem, unlike R&C1, so a
  directory walk is the primary asset index. Three details that all fail
  quietly rather than loudly: directory records store every 32-bit field twice
  (little-endian then big-endian), the CD001 signature is at sector offset 1
  rather than 0x8001, and the directory flag at record offset 25 is
  authoritative where the identifier's trailing slash is not.

- global_wad.rs. Container headers hold a table of extents. All 92 indexed
  containers use {offset in sectors, size in bytes}. Measured, not assumed: the
  sector-count reading places every entry of all 27 LEVEL<n> and all 27
  AUDIO<n> containers out of range, while the byte reading places all of them
  correctly. SCENE<n> is the one exception, a flat sector list whose extents
  are sized by the next value.

- wad.rs. Insomniac's LZ77 stream: four flag-selected packet classes, with 0-3
  trailing literal bytes per packet carried in unused low bits. This is not a
  bit stream, which is why it resists the usual LZ77 reading.

- buf.rs. A bounds-checked reader, so untrusted 2003 disc data produces typed
  errors rather than panics.

gc-extract carries the three stages and a SHA-1 implementation with FIPS 180-4
test vectors, so identification works without pulling in a crypto dependency.

Verification: 54 unit tests, plus four integration tests that run only when
GC_DISC names a disc. Those four check identification against the recorded
values, cross-check the Rust identifier against tools/identify-disc.py, parse
all 92 containers, and confirm a full unpack matches the committed table. The
committed table is 316 KB of sizes and SHA-1s for all 5,135 files: hashes only,
no game content, so it is safe to version and it turns "matches retail" into
something a test can assert.

docs/plan/disc-pipeline.md records two wrong turns, because both are easy to
repeat and both failed by producing plausible wrong output rather than an
error: the container table was read backwards, and the LZ77 stream was modelled
as a bit stream. The first survived because it happened to be correct on the
one container used for manual checking.
2026-10-09 16:08:30 +02:00