252 Commits
Author SHA1 Message Date
ReaperOfVeriod 462d0eda5a Correct the sky control and the image extent
The 256-byte period in the sky region stands, but the evidence I gave for it
was badly chosen and one figure in it was wrong.

The control was uniform random bytes, which read flat at 0.0000 and made the
0.772 match rate look like an enormous signal. It is not: 91.8% of the
region's bytes are the same value, 0x07, so a stride-256 comparison matches
trivially wherever both positions are 0x07.

The correct control is the same bytes shuffled, which keeps the value
distribution and destroys only the ordering. It reads 0.512 at stride 256,
not 0.0000. The real excess is +0.26 -- a genuine signal, and much smaller
than reported.

The extent was wrong for the same reason. Measured against the matched
control, the excess over the value distribution is:

  +0 .. +49152    +0.38 .. +0.45
  +53248          +0.26
  +57344          +0.07
  +61440          -0.13

so the periodic data ends between +53248 and +57344, rows 205 to 224, not at
+60160. The earlier number came from a raw threshold with no distribution
control, and a byte value covering 91.8% of the data drags such a threshold
to wherever the dominant run happens to stop.

So: the period is real and cross-checked, the image dimensions are not. Any
"256x236" from the previous commit should be discarded.

The general lesson is the one AGENTS.md asks for and this missed -- a control
has to match the thing being controlled for. Uniform random was the wrong
reference for data with a dominant byte value, and it made a weak result look
like a strong one.
2026-10-11 03:33:54 +02:00
ReaperOfVeriod 10f797dcdc Find a 256-byte-row image at the start of the sky region
Range measured: level 0, 2192576..2615232, the 422,656-byte sky region.
Periodicity start and end located by sliding an 8 KB window in 256-byte steps
through the region and measuring the 256-stride match rate.

Method: autocorrelation, then a periodicity scan. A random control of the
same size reads flat at 0.0000 for every stride tested.

The peak stride in the first 64 KB is 256 at a 0.772 match rate. Strides 255,
257 and 4 score nearly as high, which is what a genuine period looks like when
sampled rather than assumed -- a useful contrast with the 148-byte "stride"
that held for eight values and then broke.

Across levels the peak is 256 on 21, 512 on 5 and 128 on 1. 512 and 128 are a
harmonic and a sub-multiple of 256, so the fundamental period is 256 bytes.

Within level 0 the periodicity holds from +0 to about +60160 and then stops:
236 rows of 256 bytes, i.e. a 256-wide by 236-tall image at one byte per
pixel. The remaining ~362,240 bytes -- 86% of the region -- are not
256-periodic and stay unidentified. That is presumably what the layer offset
table at +0x20 points into.

The sky region is therefore not one thing. Its first ~60 KB is an image and
is the tractable half; the rest is still open, as is the non-uniform header
where level 2 differs from every other level.
2026-10-11 03:32:37 +02:00
ReaperOfVeriod d397a1ee34 Differential analysis finds no constant substructure at stride 368
Range measured: level 0, 16047040..16737344. 1875 whole 368-byte records
from the region start, variation checked across the first 60.

Method: the differential analysis that identified the shrub body -- compare
records byte by byte, record which positions differ.

Result: all 368 positions vary. No constant field, no padding, no header
inside a record. The shrub body separated immediately into a varying payload
and a constant two-byte tail; this format has nothing constant to separate.

That is consistent with the autocorrelation peak rather than in tension with
it. Checking which positions carry the 368-stride matches over 20000
positions gives 168 exact 4-byte repeats, distributed evenly at 0.84% across
all four byte offsets. Whole 4-byte sequences recur at exactly 368 while no
individual position is constant -- consistent with records containing entries
that repeat at a fixed relative index, not with a fixed-layout record.

So the 368 stride stands and the records stay unidentified. Worth recording
that the shrub method failed here for a specific and predictable reason: it
needs something constant to latch onto. Applying a method that worked
elsewhere is not the same as it applying here, and saying so is cheaper than
the next person assuming the technique transfers.
2026-10-11 03:31:31 +02:00
ReaperOfVeriod 21791e8f5f Find a repeating structure at stride 368 in the region after shrub
Range measured: level 0, 16047040..16737344, sampled as the first 65536
bytes. Cross-checked on every level that has the region, each sampled the
same way from its own region start.

Method: autocorrelation. For each candidate stride, count how often a 4-byte
sequence at offset i repeats at i + stride, as a fraction of comparable
positions. A random control of the same size measured identically reads flat
at 0.0000 for every stride, so the statistic does discriminate.

  stride 368        0.1110
  stride 340        0.0935
  strides 337-339   0.026-0.029   background
  random control    0.0000

Stride 368 is the strongest peak. On level 0, 340 is a secondary peak well
clear of the background; the two differ by 28, unexplained.

The cross-check is the part that matters: the peak stride was recomputed
independently for every level with the region, and all 24 peak at exactly
368. One level's peak could be a property of that level's data; 24 of 24
agreeing is a format invariant.

What a 368-byte record is remains unestablished. This is the same position
the shrub body was in before differential analysis identified its records,
and the same method is the obvious next step -- apply it to the 368 stride
rather than continuing to look for headers.

Chosen deliberately over the header search: autocorrelation found this in one
pass with a control, where four header guesses found nothing. The shrub body
only yielded to differential analysis too, and pattern-matching is what
produced this session's four retractions.
2026-10-11 03:30:50 +02:00
ReaperOfVeriod 690092dfb6 Disconfirm the ten-entry table lead from the region after shrub
Range measured: level 0, 16047040..16737344.

Treating the ten entries' low halves as offsets from the region start lands
on blocks exactly 148 bytes apart, the first nine of which begin 08 00
followed by a 16-bit id stepping by 0x40. That reads as a pointer table and
it is a coincidence.

Walking the same 148-byte stride across the rest of the region, only 14 of
4657 blocks start with 08 00. The first nine agree, then the pattern breaks
completely -- id deltas go from a steady 0x40 to 0x801, 0xf510 and
negative values.

Eight consecutive values at a constant stride is much more than chance
should produce and much less than a structure needs. The lead committed an
entry ago is therefore disconfirmed, and the region remains unidentified:
real, non-zero data, bounded, and unopened beyond its first ~150 bytes.

The open question is unchanged and is where the next attempt should start:
the 690,304 bytes on level 0 between the last shrub class and the constant
trailer.
2026-10-11 03:28:12 +02:00
ReaperOfVeriod 69b81af3b2 Expose the shrub body as 8-byte records, bounded per class
Turns the differential finding into code. Each shrub body record is 6
varying bytes plus 2 constant ones; body_records() hands them back raw and
body_is_well_formed() checks the trailing pair.

ShrubHeader::region now takes an explicit span_end. It has to: without it
the data slice runs to the end of the lump, so the last class in each level
absorbs whatever follows shrub and a per-class check ends up measuring its
neighbours. That is the same unbounded-span mistake as before, caught this
time by a failing assertion rather than by reading the docs afterwards.

The disc test asserts well-formedness only for the 334 of 358 classes whose
span ends at the next class. The last class in each level has no successor,
so its span contains the 690 KB region after shrub and its body is not
wholly records -- that is the open question, and asserting either way would
be wrong. Shrub::span_end_is_a_successor records which case a span is.

Records are summed only across bounded bodies. Summing the last class too
reproduces the "3.5 million records" figure retracted two commits ago, to
within rounding -- which is a useful check on how easy that mistake is to
repeat by accident.

8,938 records across the 334 bounded bodies; 16 denormal reserved words;
358 headers naming their own class.
2026-10-11 03:26:06 +02:00
ReaperOfVeriod ed7e568231 Find a ten-entry table on a 148-byte stride after shrub
Range measured: level 0, 16047040..16737344, the 690,304 bytes between the
last shrub class's body and the constant trailer. Nothing outside that range
is read.

Method: reading the bytes at +0x20 as u32 shows ten consecutive entries
whose low halves increase by a constant 0x94 (148), followed by a zero. The
high half is 0xf000 for the first nine and 0x0a40 for the tenth, so the
table is ten entries and ends at +0x48, absolute 16047112.

What the entries mean, what 148 refers to, and why there are ten are all
unestablished. Ten matches no count already known for this level -- 238
moby classes, 74 tie, 7 shrub, 93 tfrag textures -- so it is not yet tied
to anything else in the lump.

Recording it as a lead rather than a finding. The stride is a real
measurement on a bounded range, but a ten-entry table with an unexplained
stride is exactly the shape that looks like a structure while being
something else, and the rule in AGENTS.md is that a pattern gets looked at
before it gets named.
2026-10-11 03:15:55 +02:00
ReaperOfVeriod 352a4e5ad7 Locate the region after shrub, 690 KB on level 0
Range measured: each level's last shrub class data_offset field (class +
0x2c) through to the start of the constant trailer. Nothing outside that
range is read.

Method: the last shrub class's body is an array of 8-byte records whose
final two bytes are 00 00. Scanning forward in 8-byte steps for the first
record whose trailing pair differs locates the body's end. That is a
search for a stated pattern rather than a statistic, so it either finds
the boundary or says it did not.

Result: on 24 of 24 levels the boundary lands exactly on an 8-byte record
boundary, with body lengths of 192, 208, 224 or 240 bytes -- the same sizes
seen when shrub classes were grouped and diffed.

Level 0:

  last shrub class data   16046800 .. 16047040       240 bytes
  next region             16047040 .. 16737344   690,304 bytes
  constant trailer        16737344 .. 16969296   231,952 bytes

So 690,304 bytes between the last shrub class and the trailer are a
distinct region rather than shrub data. What is in them is not established.
They are the leading candidate for moby_anim, now that collision..moby has
been ruled out as texture.

Worth recording how a wrong conclusion nearly happened here. An earlier
probe searched for PS2 NaN filler at the start of this range, found none,
and was about to report the body as having zero length. That would have
been wrong: the last shrub class is a real class with real data, and NaN
filler only appears in degenerate ones. Checking the actual byte pattern
instead of concluding from a failed heuristic is what caught it.
2026-10-11 03:14:28 +02:00
ReaperOfVeriod 6afcfa8608 Require bounds and method on every reported number
Four numbers in one session looked load-bearing and turned out to be
artifacts: a format identified as VIF-packed on the strength of recurring
byte patterns where a control put it on the random baseline, a body size
computed over wrongly bounded spans, a "6.2 MB of unidentified region" that
was texture data, and "3 unexplained blocks" that were a sampling artifact.

All four shared one cause -- measuring before bounding, naming bytes before
looking at them -- and none errored, which is why they survived long enough
to be believed. Two of them needed follow-up commits to undo.

The rule: any size, count or distribution comes with the byte range it
describes and how it was measured. Ranges come from core_layout rather than
from running to core_data.len(). A statistic that cannot separate your format
from uniform random cannot tell you anything. And counting distinct byte
values only tells you which blocks to look at, not what they are.

Worth having in the working agreements rather than in anyone's memory.
2026-10-11 03:12:27 +02:00
ReaperOfVeriod 8d57a09b76 Bound the last region by core_data's constant trailer
Comparing all 27 levels byte-for-byte from the end: the last 231,952 bytes
(0x38A10) of every level's core_data are identical. Level-specific content
therefore ends at len - 0x38A10, which is a real bound for the final region
and is not core_data.len().

This closes shrub. Across the 24 levels that have a shrub region the bound
gives 24,442,928 bytes total, and the disc test asserts on every level that
the region ends strictly inside the lump rather than at its end.

What the trailer is remains unestablished -- fixed data, or padding from a
fixed-size decompression buffer -- but it is constant, which is all a bound
needs.

The data between the last shrub class and the trailer is high entropy: 183 to
256 distinct byte values per 4 KB block, mean 254, where texture data sits
under 200. It is real content of unknown type and is the most likely home for
moby_anim, now that the range between collision and moby has been ruled out
as texture.

A lump shorter than the trailer now yields end: None rather than a backwards
bound, covered by a test.
2026-10-11 03:09:21 +02:00
ReaperOfVeriod 8882d8fc26 Re-scan at finer granularity: collision..moby is entirely texture
The "3 of 21 blocks not texture-like" figure from the last commit was an
artifact of sampling every 256 KB and missing everything between. Re-scanned
at 4 KB granularity there are about 100 such blocks, and looking at them
rather than only counting them, they are smooth gradient ramps -- sky and
fog texture. A noisy gradient has a high distinct byte count without being
code, which is exactly the mistake the byte-count heuristic invites.

So collision..moby is entirely texture data. There is no separate moby_anim
region there, and two claims about this range have now been wrong: the
"6.2 MB of unknown data" and the "3 unexplained blocks". Both came from
sizes and counts taken without looking at what they described.

moby_anim is therefore still unlocated, but it is not here. The remaining
candidate is the unresolved tail after the last shrub class -- ~925 KB of
real, non-zero data on level 0 that nothing accounts for.

Recording the two corrections together rather than one at a time, because
the mechanism is the same in both: a heuristic count is not an
identification.
2026-10-11 03:06:07 +02:00
ReaperOfVeriod db414b3d5f Correct: the gap between collision and moby is texture data
The previous commit claimed ~6.2 MB of unidentified data sat between the
collision header and the first moby class, and speculated it might be
moby_anim. That was wrong, and it came from quoting a size before bounding
the thing it measured.

Looking at the bytes rather than only the offsets: the range is dominated by
8-bit indexed texture data. Byte-value concentration distinguishes them
cleanly -- a texture block draws on a small palette, code and collision data
does not. 18 of 21 sampled 4 KB blocks fall under 200 distinct byte values,
against 249 for tfrags and 209 for collision.

The real layout on level 0:

  collision  2615232 .. 3518544   (header plus its declared 903,248 payload)
  textures   3518848 .. ~8832192
  moby       8832192 ..

The texture table's data_offset values stop at 5326208, which is what made
the gap look genuine. Mip chains extend well past the last entry's offset,
so sizing a region from a table of first-level offsets understates the data
it points at.

Three of the 21 blocks are not texture-like, so something is mixed in that
is still unidentified. It is not 6.2 MB of moby_anim.

This is the third number this session that looked load-bearing and turned
out to rest on an unbounded measurement. The pattern is consistent enough
to be worth stating plainly: sizes derived from tables or offsets are not
evidence until the bytes underneath have been looked at.
2026-10-11 03:05:07 +02:00
ReaperOfVeriod 8b98d388f7 Show region bounds in probe, which exposed an unnamed 6.2 MB region
Printing the region table next to the header fields makes the gap visible
without having to remember to consult core_layout, and it paid for itself
immediately.

On level 0 the collision region comes out 6216960 bytes, from 2615232 to
the first moby class at 8832192. Collision's own header declares a payload
of 903,248. So roughly 6.2 MB sits between the collision header and the
first moby class, and almost none of it is collision.

The successor rule is correct -- a region does end where the next one
begins -- but "the next" is not yet known there. collision..moby is
really collision + <something unnamed> .. moby. The name is wrong, not
the bound.

Candidates for the space, rough order of likelihood: moby and tie model
animation data, which is what moby_anim would need, then volumes, then
per-class runtime structures. Tfrag texture texels are already placed and
sit earlier in the lump.

Worth resolving before starting moby_anim, since that format is the most
likely tenant. Until then, any probe reading between the collision header
and the first moby class is reading something the region table does not
describe.
2026-10-11 03:03:46 +02:00
ReaperOfVeriod cb38b8c279 Compute core_data region bounds once, from successor boundaries
Region spans were being re-derived per format while probing, usually
running from one known offset to the next with the last span running to
core_data.len(). On level 0 that gave the final shrub class a 925,008-byte
span absorbing most of the lump's tail, and every aggregate computed over
it -- record counts, value distributions, "this looks random" -- was
silently wrong. Nothing errored, which is what made it survive two
commits and a correction.

core_layout computes the layout once: each region ends where the next one
begins, class regions start at the lowest non-zero geometry offset in their
table, and a region whose successor is unknown gets end: None rather than a
clamped guess. Zero class entries mean absent, not position zero -- the
same trap occlusion and collision each fell into separately.

The disc test asserts what the module is actually for: that the unknown is
visible. Shrub's end is unresolved on the 24 levels that have a shrub
region, and that is asserted rather than hidden.

It also found that not every level has every region -- level 10 has neither
occlusion nor shrub -- so the test checks ordering against the canonical
sequence instead of demanding an identical set.

Still open: where the shrub region ends. Texture texels cannot bound it;
they sit before moby, not after shrub. See docs/formats/core-data-regions.md.
2026-10-11 03:02:02 +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 517f0b63fa Read the shrub header floats, including a denormal the PS2 flushes
Extended the shrub header past the two self-identifying words. The first
four words are floats -- finite on all 358 spans -- though they do not
behave like a min/max bounding box, which orders correctly on only 146 of
358, so they are recorded as bounds rather than named. The word at +0x10
is positive everywhere, 3.0 to 20.0, exactly 5.0 on 281 of them.

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

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

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

The shrub body past +0x2c is still not decoded.
2026-10-11 02:41:34 +02:00
ReaperOfVeriod 39c364e874 Note in the roadmap that shrub header fields are checked, not just reported 2026-10-11 02:38:44 +02:00
ReaperOfVeriod 7107f395d0 Parse the shrub class header
Shrub is the third class-geometry family and is not the moby one: all 358
spans on the disc have a leading word of 0 or one too large to index a
table into, so none contain a moby-style part table. The rule that holds
for 43,467 of 43,467 moby parts holds for zero of them.

Two header fields do self-identify, exactly, on all 358 spans: +0x24 equals
the owning class entry's own o_class, and +0x2c is a data offset landing
inside the span. Both are checked rather than merely reported -- a span
naming the wrong class is a different thing, and quietly returning the
mismatched value would hide that.

The body past +0x2c is not decoded. It tiles at no stride consistent with
the count at +0x28 (tails of 240, 240, 192 and 224 bytes against counts of
5, 6, 5 and 3), and the header's leading bytes are packed rather than
float even though +0x10 lands exactly on 5.0f on 281 of 358 spans.

Shrub class entries are 0x30 bytes rather than 0x20, because they carry
billboard texture fields the other two tables lack; the parser records that
so the stride is not re-derived.
2026-10-11 02:38:35 +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 fc4c8b427d Mark shrub partial in the roadmap 2026-10-11 02:33:36 +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 dc941a8eec Parse the collision container framing
Collision sits inside core_data at the offset the core index gives, behind
a 0x40-byte header that self-describes: it carries its own size and the
payload size. That pairing is the invariant -- the declared header size is
exactly 0x40 on all 27 levels and header + payload stays inside the lump.

41,481,520 payload bytes across the 19 levels that have any; 8 declare
zero length, which is present-but-empty rather than absent, so the two are
counted separately.

The cells inside the payload are not decoded. They do not walk as
fixed-stride cells, and treating faces as variable-length records stops
partway into the first cell on level 0 rather than tiling the payload. The
first cell reads as 11 verts and 33 faces under every hypothesis tried,
which is exactly the kind of agreement that looks like a result and is
not one -- three different strides all produce the same first cell and all
of them are wrong. Left open rather than committed as a guess.
2026-10-11 02:21:10 +02:00
ReaperOfVeriod 2dcc6e9710 Parse the sound bank container framing
A level's sound extent is two regions: an `SBlk` descriptor table and the
sample data that follows it. Four u32 at the head describe the layout, and
the invariant that establishes it is sblk_offset + sblk_size == daw_offset,
which holds on all 27 levels. That is what pins the field meanings; the
numbers alone are ambiguous about which of them is the offset.

DAW is a format tag inside the SBlk header, not a region signature. It
occurs exactly once in the entire extent, at sblk_offset + 12, naming the
sample format -- the sample data itself starts at daw_offset with no
signature at all, roughly 19 KB away on level 0. Reading it as a region
magic is the obvious reading and it is wrong.

daw_size never reaches the end of the file. The tail is positive on all 27
levels, 232 to 1,848 bytes, so the field describes less than the region
it names; the test asserts that direction rather than inventing a size
that fits.

523,720 SBlk and 37,649,008 DAW bytes across the 27 levels.

The sample format inside DAW is not decoded. It looks like VAG/ADPCM from
the nibble packing, and it has no bearing on drawing anything, so it stays
open rather than getting a half-answer.
2026-10-11 02:15:33 +02:00
ReaperOfVeriod cfabe9a0ae Fix mask bit lookup to match its documented encoding
visible() documented its argument as `byte << 8 | 1 << bit` -- high byte
selects the mask byte, low byte is already the shifted bit -- then shifted
the low byte a second time as though it were a bit index.

That is wrong twice over. It inverts the meaning of every bit argument,
and for any low byte above 8 it panics in debug, because `1u8 << 32` is
an overflow. Real data reaches it: the folded bits for the upper bytes of
a mask are exactly the values that trip it.

Treat the low byte as the bit mask, which is what the doc says and what
the renderer needs. Added an assertion for a mask index that does not
exist, since that was the case that exposed the mismatch.
2026-10-11 02:15:33 +02:00
ReaperOfVeriod 632505a6bd Parse the occlusion PVS grid
A z -> y -> x tree of u16 node offsets over 4x4x4-unit cells, each
populated cell naming one 128-byte mask of 1024 visibility bits that the
renderer fills from the camera's cell each frame.

Two sentinels carry meaning and one of them is easy to miss: a z or y
slot of 0 means "no subtree", and an x slot of 0xffff means "no cell".
Reading the latter as a mask index gives a grid whose highest mask is
65535 on every single level, which is the tell.

The check that makes this real is the mask array. It starts at
masks_offset and runs to (highest_mask + 1) * 0x80, and on every level
with a grid that ends at the block boundary exactly -- widest
unexplained tail across the disc is 0 bytes. A wrong tree shape or cell
size would leave the highest mask index elsewhere entirely.

412,521 populated cells over 140,939 masks, across the 24 levels that
have one.

An offset of 0 means the level has no occlusion data at all, which is
not the same as "at the start of the file" -- that is where the tfrags
block lives, and parsing it as a grid produces a mask array that fits
anyway, with 1600 bytes to spare. That is what made level 10 look
plausible on the first pass; three levels (10, 24, 25) carry none.
2026-10-11 02:11:30 +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 bcf0d1f0cc Decompress the gameplay extent: the level light bank now parses
gameplay is a WAD stream, exactly like core_data. The extractor was
writing it compressed, so the light bank framing read the WAD header's
stream length as a light count: level 0's offset at +0x04 was 0xa0d
and the "count" there was 698810368.

The file says so in its first six bytes: 57 41 44 79 0d 0a -- "WADy".

Decompressed, level 0's gameplay extent is 1,872,256 bytes, the offset at
+0x04 is 0xc2b20, and the count there is 5. Five directional light sets.

All 80,274 vertices of level 0 now go through LightTfrags, and the
renderer's fallback line no longer appears. The frame's colour
distribution goes from 295 to 737 distinct sampled colours and takes on
the level's warm cast.

This corrects a recorded negative result that was simply wrong.
docs/roadmap.md carried "gameplay does not decode with the WAD LZ77
decoder, all 27 levels fail identically, so the input is wrong rather
than the decoder", with bit order and header length also ruled out.
That was true of the data being parsed and wrong about the cause: the
decoder was never the problem, it was being handed a file that had not
been through it.

Same lesson as the origin bug, from the other direction. A recorded
failure without a positive control is easy to record and easy to leave;
the control here is trivial and should have been run first -- decompress
and look.

The renderer keeps counting and reporting how many vertices it lit,
because a silent fallback to stored colours looks plausible and is wrong.
Lighting magnitudes remain unverified: that needs comparison against
reference frames via the PCSX2 loop, which is still to come.
2026-10-10 03:27:52 +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