Commit Graph
74 Commits
Author SHA1 Message Date
ReaperOfVeriod cd1648571c The verified mapping is the flat memory image, not section indexing
The fix e6b3e0b called for, with the verification that six commits kept deferring.

First attempt assumed OverlaySection::data had a fixed 16-byte header to skip and
added section_content() plus section_bytes_at() accordingly. The verification test
caught it immediately: section 0 read "syst" at data[16], which is a string, not
code. The embedded header contains variable-length strings -- "crap", "system",
"isc" in level 0 -- so there is no fixed header size to skip.

What is actually true: the overlay lump is a MEMORY IMAGE. Its first word is
0x001A7C80, the load address of .lit, and overlay[addr - LIT_BASE] is the byte at
addr. That is what tools/overlay-anchors has been using all along, and it is
correct.

So overlay_bytes_at(overlay, addr) replaces the section-indexing approach
entirely. Bounds-checked, so it cannot read past the image.

THE TEST, and it pins the word that exposed everything

overlay_section_index_agrees_with_the_flat_blob asserts

    u32 at 0x2ee138            == 0x4bc00083   (what capstone reports)
    u32 at overlay[0]          == LIT_BASE      (it is a memory image)
    every section dest is addressable via the flat mapping

105 words checked, passing. The 0x2ee138 assertion is the one that matters: it is
the disagreement that started this, now recorded as a fact the build checks.

What this does not do: re-read 0x2ee138, or redo any of the sixteen probes. Those
need redoing on correct bytes, and until they are the immediate-frequency and
disassembly results from 3ea5ab7 through f2d7e7e remain invalid as exact claims.

Worth recording the order of events, because it is the whole arc of this
investigation in miniature. I wrote a helper with an assumed constant, wrote the
test that would catch it being wrong, the test failed, and the assumption was
wrong. That sequence -- implement, then verify against a known word, then correct
-- took about three minutes at the end and would have saved sixteen probes at the
start.

285 passing with the disc present (23 + 19 + 65 + 156 + 22; disc binary 34.94s),
clippy clean.
2026-10-14 06:16:58 +02:00
ReaperOfVeriod 2ff2abbd32 Two class-header words are base-relative offsets pointing past the tie packets
Chasing where the AD-GIF is, since f43a1c2 established it is not in the packet.
Two of the undecoded class-header words are offsets.

base + 0x1c and base + 0x34 land inside core_data on 1,864 of 1,864 classes, so
both are relative to the class base rather than absolute, and both are non-zero
on every class. Both also land **past** that class's own tie packet blocks on
every class, so they address per-class data held elsewhere in core_data rather
than the tie geometry.

That is consistent with f43a1c2: if the packet's own control region is not a tag
stream, the per-class draw state is referenced from somewhere else.

WHAT IS THERE, AND WHAT IS NOT CLAIMED

The bytes are structured, not random -- class 3 shows a fixed-stride table with
`80 01 00` recurring, class 2's field is a monotone byte table, class 5's has
`38 38 38` and `ff 00 ff ff`. They read like compiled shader programs with
small-integer parameters, or lookup tables.

**Not identified, and deliberately not named.** None of those three fragments has
been run against a control, and all three were picked because they looked like
something. That is precisely the finding rule 4 refuses, and having spent this
session on four retractions I would rather record an honest "structured,
unidentified" than a plausible format that has not earned it. If any of these
turns out to be a shader program the next step is a control, not a better-looking
hex dump.

ALSO SETTLED

0x24 is demonstrably not an offset: only 17 of 1,864 of its values land in range
for core_data. Recorded as packed data. 0x28, 0x2c, 0x38 remain high-entropy and
unexplained.

281 passing with the disc present (23 + 19 + 61 + 156 + 22), clippy clean.
2026-10-14 05:03:34 +02:00
ReaperOfVeriod ff9699c3c7 The tie class header's undecoded tail: two reserved words, a float, and a control that says no
TieClassHeader skips 0x1c..0x44 -- forty bytes per class, 1,864 classes, "none of
it matches RAC2 yet". Profiled each of the ten words instead of reading them.

THE PROFILE, PER WORD

    0x20   reserved -- zero on 1,864 of 1,864
    0x30   reserved -- zero on 1,864 of 1,864
    0x1c   1,390 distinct, 0x1e0..0x3cd60          looks like an offset
    0x24   1,571 distinct, high entropy
    0x28   640 distinct, 0x00100000..0x1000013d    two bytes packed
    0x2c   1,610 distinct, high entropy
    0x34   1,331 distinct, 0x190..0x31e20           looks like an offset
    0x38   441 distinct
    0x3c   SIX distinct: 0 (1,384), 16 (435), 48 (21), 32 (18), 2 (3), 4 (3)
    0x40   a float -- 0.00208 to 10.84, median 0.30, no negatives

0x40 is decidable and it is now a hard assertion: a positive range across two
orders of magnitude with no negatives and no zeros is not what an integer looks
like. Bounding sphere radius or class scale; not distinguished, and the doc says
so rather than picking one.

THE CONTROL THAT PREVENTED A WRONG CLAIM

0x3c has six values, is zero on 74% of classes, and sits next to a doc comment
listing "the texture count, ad-gifs, the bounding sphere and the class scale". The
obvious next move is to call it the ad-gif count -- especially having just spent a
commit establishing that the AD-GIF is not in the packet, so an ad-gif count
would be satisfying.

Tested it: mean bfc_distance per 0x3c bucket is 7.80, 6.56, 11.07, 8.03, 6.81,
6.11. Flat. It does not track the shader count, so it is not the ad-gif count.
The test now asserts that flatness as a control, so if 0x3c ever starts
correlating with bfc_distance the assertion fires rather than the coincidence
being noticed later and mistaken for a decoding.

That is the rule-4 pattern working: a statistic that cannot tell the hypothesis
apart from nothing must not be used to support it. Six distinct values and a
dominant zero *feel* like a count, and feel is exactly what is not evidence.

STILL UNIDENTIFIED

0x1c and 0x34 look like offsets but are unverified as such -- only 17 of 1,864
0x24 values land in range for core_data, so that one is not an offset and is
recorded as packed data. 0x24, 0x28, 0x2c, 0x38 are high-entropy and unexplained.
None of them is guessed at.

280 passing with the disc present (23 + 19 + 60 + 156 + 22; disc binary 35.38s),
clippy clean. The two unused-variable warnings in the sound_bank and overlay tests
are pre-existing and confirmed present before this change; left alone rather than
folded into an unrelated commit.
2026-10-14 05:01:40 +02:00
ReaperOfVeriod f43a1c2e6e bfc_distance is not the control region's length; the AD-GIF is not in the packet
Follows 9bb04cc. The control region is now characterised, and the second half of
the answer is a correction to a field doc that has been wrong since the record
was first parsed.

WHAT THE CONTROL REGION IS

Per-record, not shared state. 12,294 distinct blobs across 13,042 records, mixed
within 2,410 of 3,549 classes, and different between LOD 0 and LOD 1 on all 500
classes that have both. It varies per primitive.

Partitioned by its own length, it is not uniform in character, and the pooled
count hid that:

    vert_ofs  48 bytes  (3 qw)      5 distinct blobs
    vert_ofs  64 bytes  (4 qw)  3,175
    vert_ofs  80 bytes  (5 qw)  4,309
    vert_ofs  96 bytes  (6 qw)  3,281
    vert_ofs 112 bytes  (7 qw)  1,523
    vert_ofs 128 bytes  (8 qw)      1

The shortest and longest are effectively constant -- five blobs, and one -- while
everything between is per-record unique. That is a shape, not a noise floor, and
it is invisible unless the measurement is partitioned by length.

THE CORRECTION

bfc_distance == 5 * shader_count holds on every record, and the field doc has
always read "quadwords of ad-gif data" -- which read as a packet-relative length
would make it the control region's size.

It cannot be that. bfc_distance is 5..20 quadwords; vert_ofs, the control region,
is 3..8.

Pooled, they overlap on 9,315 of 15,219 records -- 61%, a number describing
nothing. Partitioned by shader_count, as AGENTS 3a requires, it is exact:

    shader_count 1: 2,772 of 8,676 exceed  (32%)
    shader_count 2: 4,649 of 4,649 exceed  (100%)
    shader_count 3: 1,729 of 1,729 exceed  (100%)
    shader_count 4:   165 of   165 exceed  (100%)

Without exception for two or more shaders. For one shader it exceeds exactly when
vert_ofs is below 5, which is that 32%.

**The AD-GIF is not stored in the tie packet.** The packet is control, vertices
and scissor, in that order. bfc_distance is a per-record quantity tied to the
shader count whose meaning is still not established, and the "quadwords of ad-gif
data" reading of it is wrong. The doc now says so and says why, rather than
carrying a plausible description of a field whose meaning nobody has established.

This is the fourth retraction today and the second today found by partitioning
rather than by a test failing. The pooled number was not merely less precise than
the partitioned ones -- it pointed the other way, because 61% looks like noise and
100% looks like a boundary. Had I accepted the pooled figure I would have written
"sometimes" and moved on.

59 disc tests, 278 total across 23 + 19 + 59 + 156 + 22 with the disc present
(disc binary 35.31s), clippy clean.
2026-10-14 04:57:41 +02:00
ReaperOfVeriod 9bb04cca7b The tie control region is not a VIF or GIF tag stream; and vert_ofs is mis-named
Went after the GS's GIF packets in the tie AD-GIF region. Got a negative result,
a new universal invariant, and a retraction of my own first probe, which reached
the right answer for the wrong reason twice.

THE NEGATIVE RESULT

Tried the leading `5 * shader_count` quadwords of each packet (the
`bfc_distance` field's stated meaning) as VIF tags -- command in bits 0-6, size
in 7-14, mark in 15 -- and as GIF tags -- type in the top byte of word 1, length
in bits 0-2 of word 0. Both uniform. Over 13,042 records: 10,443 distinct values
of word 0, no GIF tag type above a count of 2. That is the control result rule 4
asks for, and it says stop.

THE RETRACTION

The first probe scanned `bfc_distance` quadwords, which for most records
overruns `vert_ofs` and runs into the vertex data. Pooling a control region with
vertex positions -- two populations with different structure -- gives a uniform
answer, so "not a tag stream" came out right from a measurement that was
measuring the wrong bytes. That is exactly the rule 3a failure, and I only caught
it because the hex dump I took next looked structured while the histogram said
random.

Re-measuring quadword 0 alone, on its own population, gives the same uniformity.
The conclusion survives; the reasoning did not, and both are recorded.

THE PARTITION, AND A NEW INVARIANT

The hex dump showed the packet layout directly:

    q[0 .. vert_ofs)            control        3..8 quadwords, peak at 5
    q[vert_ofs .. +vert_size)   vertices
    q[vert_ofs+vert_size ..]    scissor

and `scissor_ofs == vert_ofs + vert_size` holds on **15,219 of 15,219** records
across all 27 levels, including every one whose strip_count is non-zero. That is a
derived quantity stored explicitly, and it holds everywhere -- which is the check
that confirms the three regions really are laid out the way the existing packet
length formula assumed. It is now a hard check in invariants_hold(), which is
something the previous four could not be.

The fixture in tie.rs failed the moment I added it: it had vert_ofs 4 and
vert_size 0x20 with scissor_ofs 0x30, which is 4 + 0x24. The synthetic data was
never constrained by a relation that did not exist yet. Fixed to 0x24.

vert_ofs IS MIS-NAMED

It is not a vertex offset. It is the *length* of the leading control region, and
the vertex data begins at that many quadwords into the packet. The field name
stays because the on-disc order is fixed and everything already written about this
record says vert_ofs, but the doc now states what the value is and gives the
distribution.

STILL UNKNOWN

What the control region contains. Not standard DMA tags; possibly pre-merged GS
register writes in another framing, or per-class state rather than per-primitive
draw data. Recorded as an open question rather than guessed at.

23 + 19 + 58 + 156 + 22 = 278 passing with the disc present (disc binary 35.67s),
clippy clean.
2026-10-14 04:51:43 +02:00
ReaperOfVeriod f0bc1fbdf4 State which GS rules are wired and which are not
A small doc change with a specific reason.

gc-engine's sampler sat behind a comment claiming it clamped while the code was
`..default()`, which is Repeat. That was caught only because I went looking for
where clamp01 should be used and found the answer was "somewhere that already
claims to do it". The generalisable failure is a reader seeing a rule defined in a
module called gs.rs and assuming the renderer applies it.

So the module now says which rules are wired and which are not:

    CLAMP wrap           yes    gc-engine image_of sets address_mode_u/v
    alpha 0x80 opaque    yes    texture::scale_alpha delegates here
    blend / alpha test    no    bevy AlphaMode cannot express both at once
    mip selection         no    only level 0 is decoded, mip_level is always 0

The last row is the one worth having written down. mip_level is a correct,
tested function that returns 0 for every input the renderer can currently give it,
and nothing in the code says so. It will start meaning something the day multi-level
textures are decoded, and until then it is indistinguishable from a mip selector
that works. That is the same shape as the sampler: a specific, plausible, untested
claim about behaviour sitting where nobody will re-check it.

passes_alpha_test and BlendMode are reference -- correct and tested, but not on the
draw path, and per 43e508a not expressible there in bevy's current enum. Saying so
is better than leaving them looking like pending integrations.

No behaviour change. 278 tests, clippy clean.
2026-10-14 04:46:16 +02:00
ReaperOfVeriod 4bf552dab0 The alpha bug had a test named after the bug
Fixes a broken build left by 777a6a8, and the test it breaks is the interesting
part.

    texture::tests::alpha_is_scaled_from_eight_bit_halves
        assert_eq!(scale_alpha(0x7f), 0xfe);

That is not a test that happened to pin the wrong value. The test's NAME asserts
the wrong rule -- "scaled from eight bit halves" is the `a * 2` implementation
written down as intent, with 0xFE as its proof. Someone looked at this, thought
"alpha is a half-scale byte, double it", and then locked the conclusion into both
the identifier and the assertion.

So the previous commit's claim that `a * 2` was an implementation detail was wrong
in a way that matters: it had a name, a doc comment and a test defending it. That
is not an accident that escaped review, it is a convention that was adopted.

Two things follow.

The rule is now /128 * 255, and the test says so in its name. It also pins 0x7E ->
0xFB, a value the doubling cannot produce and which the disc does contain.

And 777a6a8 was pushed with a failing test in it. I only caught it because the
count came back as 100 rather than 278 and the discrepancy was worth one more
command -- `cargo test -q` plus a grep for `^test result: ok` sums only the
binaries that passed, so a failure is invisible in the total unless you go and
look at the raw lines. I had been using that filtered sum to report "278 tests"
for several commits without knowing it could hide a red build.

That is worth more than the bug it found. The measurement I had been reporting as
evidence of health was itself an instance of the defect this whole stretch has been
about: a quantity that should have had one definition, expressed as a filtered sum,
agreeing with the wrong answer and nobody comparing it against anything.

278 passing with the disc present, clippy clean.
2026-10-14 04:41:19 +02:00
ReaperOfVeriod 82d7c4f8a7 Three implementations of the 0x80 alpha rule; the one decoding pixels was wrong
Found the PS2 alpha convention expressed three times in three files, and the three
disagree:

    value    texture::scale_alpha    gs::alpha_to_8    gc-engine (float)
    0x40           128                  128              0.5
    0x7E           252                  251              250.9
    0x7F           254                  253              253.0
    0x80           255                  255              1.0

scale_alpha was `if a < 0x80 { a * 2 } else { 0xff }`. That is a/128 * 256, not
a/128 * 255 -- one too high at the top of the range, so 0x7F decoded to 254 where
the renderer's own alpha path gives 253. It is the only one of the three that was
wrong, and it was the one feeding decoded texture pixels.

scale_alpha now delegates to gs::alpha_to_8. The renderer keeps its float form
because it wants a float, and gc-formats has no business importing an engine
concern; two of the three are now one function.

The visual cost of this bug was at most 1/255 on a single palette entry. Nothing
would ever have shown it in a screenshot. That is the point rather than the
excuse: this class of error is not "big wrong", it is "right to within a rounding
step", in the code path nobody instruments. Every one of them is invisible by
construction, which is why the only defence is noticing that one rule has three
homes and making it have one.

Worth counting what this has turned into over three commits. The vtbl terminator:
two loops reading the same table with opposite answers. The sampler: a comment
asserting CLAMP over code doing Repeat. The alpha rule: three implementations,
one wrong. None of these is a subtle algorithm and none is in an exotic format.

All three are the same defect -- a quantity that should have one definition had
several, and nothing in the build made them compare.
2026-10-14 04:36:25 +02:00
ReaperOfVeriod 0b660cb0ce The extracted alpha rule disagreed with the renderer; the renderer was right
Pulling the 0x80 convention out of the renderer and into gc_formats::gs -- the
side benefit I claimed last commit rather than took -- immediately produced a
contradiction, which is the argument for having done it.

crates/gc-engine/src/level.rs already read it inline:

    let a = if c.a < 0x80 { c.a as f32 / 128.0 } else { 1.0 };

My first alpha_to_8 scaled by 127 instead of 128, which made 0x7F fully opaque
where the renderer had it at 253/255. One of them was wrong and it was not the new
one: the disc convention is a 0..0x80 range with 0x80 as the top, so 0x7F is
127/128, not 127/127.

Corrected to match, and the doc now records why -- including that the renderer was
already right, so this is a correction to code I wrote two commits ago rather than
to anyone else's.

One genuine ambiguity remains and it is stated rather than papered over: 0x40 is
exactly the midpoint, and the renderer works in floats where it is exactly 0.5.
Widening to an integer has to round, and this rounds half away from zero giving
128. There is no way to check that against the disc -- it needs a frame, which is
the tfrag morph item's unblock condition -- so it is documented as a choice.

The engine's call site keeps its inline form and now says why: it wants a float,
and the rule has one live site. Converting it would have meant changing working
code for no benefit, which is the opposite of what extracting the rule was for.

Worth noting what actually happened here. Extracting a rule to remove a magic
constant found that two expressions of it disagreed. Neither was obviously wrong
from its own code -- mine looked cleaner, and was not. That is a better argument
for centralising a rule than any amount of saying so.
2026-10-14 04:15:11 +02:00
ReaperOfVeriod 31f4ba3f7c GS alpha test and blend: all four rules the item names are now present
Finishes the rule layer. Two traps, both of which produce a picture that looks
slightly wrong rather than obviously broken:

AFAIL READS BACKWARDS. The PS2's alpha-test register holds an enable, a 7-bit
reference, and a bit that looks like "fail if alpha is above the reference". On
the hardware it means the opposite -- AFAIL = 0 discards where alpha is BELOW the
reference. passes_alpha_test takes the side explicitly rather than encoding the
register, and both directions are tested so the convention is pinned in code
rather than in a comment.

THE OPAQUE BIT MUST STAY OUT OF THE COMPARISON. 0x80 is fully opaque, but it is
also numerically 128, so a test that compares the raw byte against a 0x40
reference would have every fully-opaque fragment pass every threshold, and nothing
would ever be discarded. passes_alpha_test clamps the opaque bit off first, and
there is a test that asserts exactly that -- the failure mode here is a scene that
renders correctly with the wrong shading, which is not something anyone notices
by looking.

Blend is the four GS primitive types, decoded from the primitive-type byte and
named by what they do rather than by the documentation's IIIB/IIIA/IIIC labels,
which name two different modes identically. Out-of-range types decode to Off
rather than panicking, because the byte comes from asset data. Addition clamps at
255 rather than rescaling, matching the GS's 7-bit clamp; a caller wanting a true
average blends before widening, and the doc says so.

That completes the four rules the roadmap names for this item:
  alpha 0x80 == 1.0      alpha_to_8
  CLAMP wrap             clamp01
  mip selection          mip_level
  blend + alpha test     BlendMode, passes_alpha_test

Nine unit tests, no wgpu work, and the roadmap item stays [~] rather than [x] --
the rules are the input to the renderer, not the renderer.

Worth noting the pattern in both traps: each one produces output that is plausible
and slightly-off, with no error anywhere. That is the same shape as the sign
inversion in mip_level and the byte-order bug in the anchor search. The tests are
the only thing that catches this class, which is the argument for having them even
though nothing here needs a GPU.
2026-10-14 04:13:19 +02:00
ReaperOfVeriod 81ec17857a GC starts: the GS's own rules, as a module with tests
Picked Phase 3's GS item and did the part that is checkable without a GPU -- the
rule layer the renderer will have to feed wgpu. New gc_formats::gs with three of
the four rules the roadmap names, each unit-tested.

alpha_to_8: 0x80 IS OPAQUE. The obvious implementation of this is
  let v = value & 0x7F;
and it is wrong, because 0x80 & 0x7F is 0 -- which is precisely the mechanism
that makes a 7-bit alpha read as "half" to anything treating the byte as 8-bit
unsigned. The first version of this function did exactly that and the test caught
it. Below 0x80 the value is genuinely 7-bit and scales onto 1..255, so 0x7F is
opaque too and no non-zero alpha vanishes.

clamp01: CLAMP wrap, with 1.0 kept AT the far edge rather than wrapping to 0.
Under repeat sampling an exact 1.0 is a discontinuity; under clamp it is the
edge texel, and the game loads its textures that way.

mip_level: round(log2(image / on_screen)) clamped to the chain. The ratio is
image-over-screen and not the reverse -- a SMALLER on-screen size selects a
HIGHER mip level. I had this inverted in the first version, which made
mip_level(1.0, 64.0, 8) return 0 where it must return 6, and the test caught
that too. Both bugs were the same kind: a plausible implementation of a rule
whose sign convention is not obvious, and the only thing standing between them
and a commit was the test.

Not started: wgpu itself, and blend/alpha-test behaviour. This is the rule layer
only, and the roadmap item stays [~] rather than being ticked, because three of
four rules and zero of the GPU work is not "done".

Two of the four rules the item names were already half-present in the renderer --
moby alpha and the 1/4096 UVs -- so making them explicit here rather than scattered
is worth something on its own: 0x80's meaning was previously implicit in however
each call site happened to read the byte.
2026-10-14 04:12:10 +02:00
ReaperOfVeriod f182a4a0ac Transcribe the 256-byte MobyInstance; oClass closes the instance-to-routine loop
The decomp carries an authentic MobyInstance layout of 0x100 bytes with every
field named. It is now in gc_formats::moby with a 67-entry offset map, attributed
as a transcription rather than presented as our own finding.

Two fields matter:

    0x40  animSeq      animation sequence pointer
    0x44  animSeqT     animation time parameter
    0x48  animSpeed    playback speed
    0x50  animLayers   animation layer list
    0x57  jointCnt     joint count -- non-zero means the moby is skinned
    0x58  jointCache   joint matrix cache
    0xBC  oClass       object class id -- the dispatch key

OCLASS CLOSES A LOOP THAT HAS BEEN OPEN FOR SEVERAL COMMITS. It is the field the
runtime uses to reach the lvl.vtbl handler tables, so an instance and its routine
are now linked by a named offset, rather than by an assumption about which axis
the two tables share. That assumption -- the positional correspondence -- is still
refuted, and still does not need to be true.

jointCnt and jointCache settle something from earlier in this log BY CONTRADICTION
rather than confirmation. A tie vertex's i16[3] was tested as a skin/joint index
and disproved: vertices sharing it were 0.95x FARTHER apart than vertices differing
in it. That stands and is unaffected -- this is the moby struct, and a moby is a
different object from a tie. Recording the distinction rather than letting "joints"
appear in two places look like a confirmation of the earlier hypothesis.

WHAT THIS DOES NOT DO IS FIND moby_anim. It names the instance-side state, so the
asset to locate is whatever those pointers reference, and we still do not know
where that is. The item's unblock condition is now sharper -- it needs the data
behind those pointers, not the struct.

PROVENANCE, AND WHAT THE TEST CAN ACTUALLY CHECK

This is not verified against disc data and cannot be: moby instances are runtime
objects and nothing of that shape is in the container. The test asserts what can be
asserted without the bytes -- offsets ascend, do not overlap, the last field lands
exactly on 0x100, and o_class sits at 0xBC immediately before triggers.

Worth keeping distinct from the collision finding. That project's FUNCTION ADDRESSES
did not transfer: they landed in NOBITS memory. A struct's field order is a
different kind of claim from an address in a program's layout, so one failing does
not impeach the other -- but the distinction is real, and PROVENANCE.md now records
which is which rather than citing the project as a single authority.

268 tests.
2026-10-14 03:47:15 +02:00
ReaperOfVeriod 4ca0308499 SBlk body head: the DAW size, stored twice
Picked sound_bank rather than tie, where three discriminators have now come back
negative and a fourth would be guesswork.

The body after the 24-byte head opens with twelve words. Six are settled across
all 27 levels:

    word   value                 note
    0      varies per level      not identified; 27 distinct
    1      60                    constant
    2      varies per level      not identified; 23 distinct
    3      0                     constant
    4      daw_size              == the header's daw_size, exactly
    5      daw_size              the same value again
    6,7,8  0                     constant
    9      127                   constant
    10     1                     constant
    11     0                     constant

Level 0's words 4 and 5 are both 1,482,000, and daw_size is 1,482,000.

THE POINTER THAT WAS NOT ONE

Words 4 and 5 were briefly recorded as pointers into the DAW region. They are not.
The test that promoted them was "does this value fall inside [daw_offset,
daw_offset + daw_size)" -- and the DAW region is ~1.5 MB starting near the front of
a ~1.5 MB extent, so almost any plausible 32-bit word lands inside it. 1,482,000 is
merely just under the top of exactly that range.

A range test is not an identification. What survives is EQUALITY with daw_size,
exact on all 27 levels and asserted, which is a claim that can fail rather than one
that is almost always true.

That is the fourth time this session a plausible reading of a single number has had
to be withdrawn for something falsifiable: the 305x class-ID slot, the UV-corner
texcoords, the VIF code word, and now this. The pattern is consistent enough to be
worth stating as a rule -- a test that passes almost always is measuring the shape
of the data, not a property of it.

Word 0 gets one note: levels 1 and 4 hold 13,042,052 and 13,042,054, two apart,
while their daw_sizes differ by 63,504. Whatever it is, it is not a size.

The remainder of the SBlk body -- the per-sound record table -- is still undecoded,
so sound_bank stays open. A SBlkBodyHead struct carries the twelve words with the
constants asserted in its docs.

259 tests.
2026-10-14 03:06:36 +02:00
ReaperOfVeriod ec433e3341 Correct my own "VIF packets" reading: the split is real, the identification is not
Last commit read bit 31 of a tie vertex region's first word as "VIF code word"
and told a two-encoding story on it. The split is real; the identification is
wrong, and I checked it before building anything on top.

Walking those regions as VIF packets, levels 0-2, 914 records:

    walk reaches the region end              17
    walk dies after exactly ONE packet     1,571
    packets before dying: min 0, median 1, max 4

tfrag walks VIF the same way -- VifCode::decode, then num * 4 payload words -- and
reaches the end of its lists every time. So the walk is not the problem; these
regions are not VIF packet streams. Bit 31 is set on whatever this word actually
is, and that is still unknown.

The specific thing that made me overreach: I quoted 0xf5c1a3c5 as "a VIF code
word, cmd 0x75 num 193" and treated that as establishing the format. Decoding one
word as a packet header says nothing about whether the next 193 words are its
payload. Here they are not -- the walk dies immediately after.

This is the third time in this area that one plausible word produced a confident
story: the 305x class-ID slot, the UV-corner texcoords, and now this. All three
shared a shape -- a single readable item at the head of a population, treated as
evidence about the whole. The bit is the only thing the two kinds reliably
differ on, so it stays; the name goes.

The test now asserts BOTH the split and the walk failure, which is the part worth
keeping. If these ever do turn out to be VIF, the test fails and says so, rather
than two findings drifting apart silently -- which is how "half VIF, half flat"
would have sat in the docs indefinitely.

Docs corrected in both roadmap and format file, including the quoted example.

258 tests.
2026-10-14 03:04:48 +02:00
ReaperOfVeriodandreaper a94704de18 port Phase 3: overlay masking + code-identity + relocation
Ported ReRAC's (vendor/rerac/crates/rc-formats/src/level_overlay.rs) mask /
Relocation machinery into gc-formats:

- parse_overlay_sections, read_overlay: section table {dest,size,kind,entry},
  ends on entry mismatch; kind==8 = BSS; plus LevelOverlay with .text words,
  text_start and function starts.
- mask: hides relocated fields (j/jal targets, lui of main-RAM 0x0010..0x0040,
  %lo/move/$gp completions tracked per-register).
- walk/HiState/address_refs/key: PS2 code walk with branch-delay-slot register
  state.
- Relocation::new/same_code/copies/func/data: masked-code index, one-copy
  lookup, data-address relocations.

A real-disc test asserts that a level-01 engine function's relocated copy is
found exactly once on all 26 other overlays (gate: every level-01 function is
reachable by masked-code identity on all 27).

Co-authored-by: reaper <reaper@users.noreply.github.com>
2026-10-14 02:37:34 +02:00
ReaperOfVeriodandreaper 8030dbf006 close: tie LOD distance ordering exceptions - 80/1864 classes have mid=8192 far=8190
Tie class headers ordered near < mid < far on most classes. Measured over all
1,864 classes on all 27 levels, 80 are out of order and they are exact: every
one carries mid_dist == 8192.0 and far_dist == 8190.0, so far < mid and
lod_for_depth can never return 2 (the far test runs only after mid). Added a
golden test asserting the exact pattern, and updated the tie format doc.

Co-authored-by: reaper <reaper@users.noreply.github.com>
2026-10-14 02:19:07 +02:00
ReaperOfVeriod 572cd1898d Record a defect packet slicing found: the tie block terminator is early
Range measured: every tie LOD block on the disc, 2,177 blocks.

The last packet's end is known two ways. Its length comes from its record's
packet_quadwords, and every other record's length is confirmed by the next
record's pointer -- so only the last packet of each table is unconfirmed.
Comparing its computed end against where parse_tie_block stopped: the last
packet's computed end lands 2 to 16 bytes past the located 0xf6, on all 2,150
blocks measured.

Two readings fit. Either the terminator was located too early -- inside the
last packet's data -- or the last packet's length over-predicts by a small
amount. The first is likelier: a length error on the last record of every table
would have to be a coincidence. Neither is settled here, and the test asserts
the measurement rather than the reading.

Why the existing test missed it: "2,177 of 2,177 blocks end on 0xf6" is
satisfied by a false terminator exactly as well as a true one -- it checks
that the byte found IS a 0xf6, not that it is the block's end. A test that
confirms the shape it walked to will pass on the wrong byte.

The cost: the block extent is short by that much, so payload and padding
under-report and anything computed from them is correspondingly short. The
block starts are unaffected and RAM-confirmed.

Recorded as a limitation with the measurement attached, not fixed. A terminator
packet data cannot imitate is its own task, and guessing one would be the
plausible-sounding alternative this project has been bitten by. A packets()
accessor was tried and removed for the same reason: with the extent short it
silently yields the wrong answer, and an API that is quietly wrong is worse
than no API.
2026-10-13 17:40:31 +02:00
ReaperOfVeriod 1cfe744501 Settle the tie packet length, and correct the retraction that hid it
Range measured: every tie LOD block on the disc -- 2,177 blocks, 13,042
consecutive record pairs.

A record's pointer is where its packet starts, so the distance between
consecutive pointers IS a packet's length, with no reference to any field.
Comparing that against the record's own fields:

  packet_quadwords = vert_ofs + vert_size + scissor_size

  12,927 of 12,927 record pairs whose strip_count is zero
       0 of 115 record pairs whose it is not

The partition is exact: every record with a non-zero strip_count is an
exception and no record with a zero one is. So the fourth term is 0 quadwords
whenever strip_count == 0, and something else -- measured 0..19 quadwords, and
NOT equal to strip_count -- whenever it is not. R&C1's strip_count is a strip
count and strips are variable-length data, so a non-zero count most likely
adds per-strip bytes this cannot see; that is a hypothesis, labelled as one.

TieRecord gains packet_quadwords and packet_quadwords_are_known. The latter is
the honest gate, and the disc test asserts both sides of the partition rather
than the aggregate.

This also corrects this file's own retraction of the same formula, and the
retraction was the error rather than the claim. A formula of this shape was
derived at 61 of 61 pairs, then "retracted" as not reproducing (0 of 13,042).
The re-measurement had swapped strip_count for scissor_ofs in the fourth term
and put the blame on the block set. Re-measured with the term the first pass
had, it matches on 12,927 of 13,042 and the 115 exceptions are exactly the
non-zero strip_count records.

Worth recording because it is the same shape as two other entries in this
file: a retraction is not automatically more careful than the claim it
retracts, and the re-measurement that "disproved" this one changed the thing
being measured. The lesson on the other side is what saved it -- the first
measurement was over 61 pairs, not a population, and had it stayed there the
formula would have looked universal. A small sample and a bad re-measurement,
near-misses in opposite directions.

What this unblocks: the payload can be sliced per packet, which was the
prerequisite for decoding what it holds.
2026-10-13 16:48:49 +02:00
ReaperOfVeriod 4abce518e5 Decode the overlay's load base, from its own profiler table
Range measured: all 27 levels' overlays.

The overlay carries the game's profiler menu definition -- a run of 8-byte
padded names (normal, camera, cam+chr, volume, bars, render, mobys, ties,
shrubs, cpu/gpu, occl, ...) followed by runs of absolute RAM addresses that
point back at those same names. On every level the four addresses after
normal/camera/cam+chr/volume, each minus its target name's overlay offset, give
the same number: 1,735,792.

That is field_00 - 16, where field_00 is the header word that read 1,735,808 on
all 27 levels with nothing attached to it. The overlay is loaded with its
16-byte header stripped, so overlay offset X sits at RAM X + 1,735,792.
OverlayHeader::ram_address carries it, and the disc test asserts the derivation
holds on all 27 levels.

Confirmed, not only derived: level 0's RAM 0x1acf80 holds
"normal\0camera\0cam+chr\0volume\0", byte-identical to overlay 0x5310, and
0x5310 + 1735792 == 0x1acf80. All four names land where the overlay's own table
says. `gc-trace verify-overlay-base` runs that check, savestate-gated the same
way as the tie and tfrag ones.

The overlay is relocated, not copied: only 37.67% of level 0's is
byte-identical at the base, and the 256-entry sine table -- at overlay 0xb650c
-- is not at 0xb650c + base. So the base locates the overlay's image, but
individual tables inside it move. That is the shape of a loader applying
relocations, and it means the base is necessary but not sufficient for reading
the game's tables.

The value of the base is that any overlay offset can now be carried to a RAM
address, which is the prerequisite for reading the game's live level state --
the thing the tie relocation and tfrag lighting checks had to work around.

Two things get deleted rather than finished. The collision u16 table was probed
four ways -- magnitude distribution, level-independence, run starts, run
lengths -- and every one came back level-dependent: the run-start pattern is
identical on 1 of 19 levels. So the first 1024 bytes of the collision payload
are data, not a fixed table, and nothing was decoded. Recorded as the negative
it is rather than left as four half-findings.
2026-10-13 13:22:50 +02:00
ReaperOfVeriod 55ea619b4c Decode the tie class header's +0x0c word: three LOD record counts
Range measured: every tie class on the disc, 1,864 classes over all 27 levels.

Method: read the word at class +0x0c as four bytes, and read the record counts
independently from the blocks themselves -- follow packets[lod] to the block,
walk to the 0xf6 terminator, and count the 16-byte records the block header's
data_offset names. Two paths to the same number, so the check is not circular.

Result: the byte at +0x0c is the LOD 0 record count, +0x0d is LOD 1's and +0x0e
is LOD 2's, on 1,864 of 1,864 classes. Classes with an absent LOD agree too:
a zero packets[lod] gives a zero count and the byte is zero.

This closes the file's oldest lie about the tie header. The comment in
TieClassHeader::parse read "0x0c: the 64 light-slot normals offset. Read past,
not decoded" -- inherited from R&C1's layout, where the word genuinely is an
offset. On R&C2 it is not an offset at all; it is three counts, and R&C1 carries
the same quadruple at +0x20. Skipping the field was the right action on the
wrong information: the field was unknown only because R&C1's map was applied to
R&C2's bytes.

TieClassHeader gains packet_counts, and
tie_class_header_counts_records_per_lod asserts the agreement.

The fourth byte, +0x0f, is left unnamed. It ranges 1..15, and the obvious
following of R&C1 -- the shader/ad-gif total -- disagrees on 1,827 of 1,864
classes, which is 2% agreement where a count would expect 100%. It is recorded
as unidentified rather than given a name that fits a fiftieth of the population.
2026-10-13 08:59:40 +02:00
ReaperOfVeriod 8ccc3ddda4 Retract the uniformity claim everywhere it still stood, including the API docs
The record-length retraction had been applied to the tests and to the section
that discovered the problem, but the same claim was still standing in four other
places -- including the rustdoc for has_uniform_record_length(), which is what a
caller would actually read:

  crates/gc-formats/src/chunks.rs:206   "Measured: true for 1,509 of 1,509 chunks"
  docs/roadmap.md:448                    "uniform record length (1,509 of 1,509)"
  docs/formats/core-data-regions.md      a whole section titled
                                         "Every chunk has a uniform record length"

All three now say 257 of 1,509, and the method's rustdoc explains WHY the old
number was an artefact -- that resolve sliced at the first offset delta, so every
record came out the same length by construction and the predicate was confirming
the parser back to itself.

That section also contained a withdrawal, and the withdrawal was wrong:

  "This withdraws the previous commit's variable-length type. That came from
   taking minlen/maxlen over all of a type's records at once, pooling across
   chunks; rule 3a's pooled claim, third time this session."

The pooling rule fired correctly and was overruled by a measurement with no
independent content. The per-chunk check that should have caught the pooled
signal was itself reporting the parser's own slicing, so it agreed with the wrong
answer for the same reason the original claim did. Two errors stacked: a real
signal dismissed by a rule that had been right twice already, and a predicate
that could not have told the difference.

This is the third time this session that a test was asserting the parser's
behaviour rather than the disc's, and the third time it was the only thing
standing between a claim and a retraction. has_uniform_record_length is the
clearest instance: a predicate named after a property of the data, returning a
confident answer about the code.

245 tests, clippy clean.
2026-10-13 08:18:20 +02:00
ReaperOfVeriod 92aa0fe49f Audit what the record-boundary fix contaminated: one retraction, one survivor
The fix changed record lengths in 1,252 of 1,509 chunks, so every committed claim
computed over record bytes needed re-checking rather than assuming. All of them:

  claim                                        reads            verdict
  1,509 chunks / 34,491 records                offsets          unaffected
  1,262 chunks with a leading-byte bucket rule record byte 0    unaffected
  1,235 chunks with an index rule              record bytes 1-2 unaffected
  34,432 of 34,491 records 8-byte aligned      record byte 0    unaffected
  55 strides, all multiples of 4               offsets          unaffected
  moby class IDs in one slot per chunk type    record bytes      RETRACTED
  17-21% byte positions constant               record bytes      SURVIVES

Only claims that scanned across a record's BODY were at risk. The rest read a
record's first three bytes, which an over-read never touched -- it appends to the
end. That asymmetry is why the damage was contained to one claim, and it is the
argument for preferring claims anchored at record starts.

The survivor was the obvious candidate for contamination, so it is worth being
precise about it. Re-measured with each record bounded by its successor, and
comparing only the span every record in the chunk has (they are no longer all the
same length):

    lowest   level 8   16.7%
    highest             20.0%

Still inside the documented band. Previously this lived only in a doc comment on
the layout test; it is now asserted, in
chunk_records_share_a_layout_measured_by_constant_byte_positions, over all 27
levels with a 10-30% band so a real change reports itself rather than sliding.

The module documentation is corrected to the measured range and now says outright
that records in one chunk are not all the same length, since that is the fact a
reader would otherwise have to rediscover.

244 tests, clippy clean.
2026-10-13 08:02:31 +02:00
ReaperOfVeriod 4fab4d0359 Fix the record boundaries after all, and retract a claim built on the bug
I reverted the record-boundary fix last commit on the grounds that "fixed-length
records plus inter-record padding" and "variable-length records" produce the same
offset table. That reasoning was wrong, and cheaply so.

stride is the FIRST offset delta. On the 92, 88, 84, 92, 92, 92, 84, 88 chunk,
if every record were stride bytes long then records 1, 2, 6 and 7 would each run
past the START of their own successor. Records are addressed by an explicit
offset table; they cannot overlap. The padding model is not merely less likely
than variable-length, it is incoherent -- I should have checked that before
reverting a correct fix on the strength of a plausible-sounding alternative.

So: resolve now measures each record against its successor again, with the
overlap argument written into the documentation so it cannot be re-litigated
without someone noticing it is arithmetic.

THE RETRACTION, which is the substantive part:

Three tests broke. Two were straightforward, but the third was not.

  * constant_positions indexed records as if they were all the same length. Now
    that they are not, it compared past the short ones. It takes the minimum
    length instead.

  * "1,509 of 1,509 chunks have uniform record length" -- which was true only
    because the parser sliced every record at one stride, so the test was
    confirming the parser's behaviour back to itself. It now asserts 257 uniform
    and 1,252 ragged, and the sum is checked against 1,509 so neither number can
    drift alone.

  * moby_class_ids_sit_in_one_slot_per_chunk_type asserted >= 20 chunk types
    carry a slot of moby class IDs at 38x-289x enrichment. It now finds 2, of
    which 1 clears 100x. RETRACTED. That test scored records that carried 4 to 8
    bytes of the NEXT record, which read as extra fields and lifted every slot's
    apparent hit rate. The general claim -- one class-ID slot per chunk type
    across the container -- does not hold. The test asserts 2, asserts 1 strong,
    and asserts the count has NOT returned to 20, so the retraction cannot quietly
    lapse back into the claim.

That is a committed finding withdrawn on the strength of a parser bug. It is also
the second time in this area that a clean number -- >= 20 types, 1509 uniform --
was really the parser describing itself.

243 tests, clippy clean.
2026-10-13 07:59:23 +02:00
ReaperOfVeriod 5f0396e3d7 Measure every chunk's record offsets; the cause is ambiguous, so no fix
The previous commit fixed on one signature. Measuring all of them shows the
pattern is the norm. Taking delta = offset[i+1] - offset[i] against the chunk's
own first delta, levels 0-2, 2,953 deltas:

      0  1,723      -4    549      +4    426
     -8    146      +8     32
    -12     71     +12     21
    -16     34     +16      4
    -20     27     +20      1
    -24     13

Every deviation is a multiple of 4 and most are exactly +/-4; worst observed is 24
bytes in a 7-record chunk. 1,252 of 1,509 chunks have at least one, across 66 of
74 signatures.

WHY THIS DOES NOT BECOME A FIX

Two models produce this offset table and they are observationally identical:

  1. records are genuinely variable-length, padded to 4 bytes, stride = largest
  2. records are fixed at stride and the offset table carries inter-record
     padding, so the gap to the next record is record + padding

Nothing in the offsets distinguishes them. Both say where records start. Both
produce 92, 88, 84, 92, 92, 92, 84, 88 for the case already discussed.

I wrote the change that slices each record against its successor -- model 1 -- and
then reverted it. Two things came out of writing it:

First, it broke three tests, which is the informative part. Those tests assert
"1,509 of 1,509 chunks have uniform record length" and they passed BECAUSE the
parser forced uniformity by slicing every record at a single stride. They were
asserting the parser's behaviour back at itself, and would have kept passing
forever regardless of what the disc contains.

Second, shipping it would have replaced one unverified model with another and
silently changed the record boundaries of 1,252 chunks on the strength of a
guess. The repo's own rule applies: a heuristic count is not an identification,
and here it is not even a count -- it is two equally good explanations of one
table.

WHAT WOULD SETTLE IT: a field inside the record whose end is independently
observable -- a terminator, a count implying a length, or a known value pinning
its position. Under model 2 the disputed bytes are padding and will be uniform or
zero; under model 1 they are data and will not be. For [75 42 ef] those 8 bytes
read a0 00 27 20 at both the tail of one slice and the head of the next, which is
arithmetic rather than evidence, since it is the same eight bytes seen twice.

The uniform model stays, the ambiguity is recorded in resolve()'s documentation
and here, and the 1,509-of-1,509 assertion stands as a statement about what the
parser does rather than about what the disc contains. 243 tests, clippy clean.
2026-10-13 07:50:50 +02:00
ReaperOfVeriod 28ab7e094b SBlk head characterised: four constants and one word that is not a count
sound_bank's head was parsed but never characterised. Across all 27 levels the
SBlk region opens identically for its first twelve bytes:

    +0x00 "SBlk"   +0x04 1   +0x08 4   +0x0c "DAW\0"   +0x10 0

and +0x14 is the only word that varies. It varies with its low sixteen bits zero
on all 27, leaving 0x0092..0x0103 -- 146 to 259.

That is the shape of a record count, and it tracks the body length at close to
100 bytes per unit. It is not one:

    body / field_14     91.2 .. 110.8 across 27 levels
    any stride 0..80 with body = stride*word + constant exactly:  no

So the test asserts the negative too. If a stride ever makes the relation exact
on all 27, sound_bank_sblk_head_is_constant_apart_from_one_word fails and asks
for the record size to be revisited. A near-miss at 100 bytes/record is exactly
what hardens into a name the next time somebody glances at it; the constants are
also checked individually so a real deviation names which one moved.

The DAW region begins with sixteen zero bytes then block-structured data with
16-byte periodicity and slowly varying nibbles -- consistent with block
compressed audio, not decoded. Recorded as such.

Two mistakes, both mine, both the same shape as earlier ones:

1. The constructor bound `format_tag` with a `let` BEFORE the two words that
   precede it in the file, consuming bytes 4..8 and shifting every field after
   it. The struct literal listed the names in the right order; the statements did
   not. A scratch script reading the same bytes gave the opposite answer, which
   is what exposed it. Fixed, with a comment, because "read the lets in file
   order" gets undone otherwise.

2. Inserting the new test ahead of an existing one stranded that test's #[test]
   on my doc comment -- silently unregistering
   tie_vertex_element_is_16_bytes_with_fixed_texcoords. This is the SAME mistake
   as two commits ago and I made it again. It again surfaced only as clippy's
   "never used" rather than as a missing test. Restored and verified by listing
   the registered tests rather than trusting the count.

240 tests, clippy clean, both tie vertex tests confirmed present.
2026-10-13 07:29:07 +02:00
ReaperOfVeriod 6d0c1b6fd1 Close out tfrag on the disc side, and fix two stale "not recovered" claims
Picked tfrag morph targets from the open list and found the premise was wrong.

There are no morph targets in this format. Morphing is a blend toward the midpoint
of the vertex's two PARENT vertices -- g[parent1_position] and
g[parent2_position] -- which are other vertices in the same tfrag. The engine
implements exactly that in resolve_vertex, and it is covered by
morphing_blends_toward_the_parent_midpoint plus
morph_weight_runs_from_zero_to_one_across_its_band and
collapse_distances_are_the_far_edge_of_each_band. So "morph targets unrecovered"
was never a gap; it named a thing that does not exist in the format.

Strip bookkeeping is likewise already there: TfragStrip carries
vertex_count_and_flag, and tfrag_triangles walks the strip command lists and index
arrays to build triangles. That was the other half of the same roadmap entry.

Coverage, all passing: 36 tfrag tests, 9 texture tests, 23 gc-engine tests. The
real-disc texture test decodes all 1595 level textures, with pixels and mip levels,
so the module doc's "not yet recovered: the texel data the texture table points
into" is also stale.

Two stale claims corrected:

1. crates/gc-formats/src/tfrag.rs said "Not yet recovered: the LOD switch and morph
   rules in TfragProc, and the texel data the texture table points into. See
   docs/plan/tfrag.md." The rules are recovered and tested, the texels are
   decoded, and docs/plan/tfrag.md DOES NOT EXIST -- a dangling reference to a
   file that was never written.

2. docs/roadmap.md marked tfrag partial with "Still open: morph targets and the
   strip/chain bookkeeping". Neither is open.

The honest replacement for both is what is genuinely not claimed: the LOD switch
distances, morph weight and collapse edge are DERIVED from the data -- tiers are
classified from the four V4_8 parent arrays, the weight runs zero to one across
each tier's band -- but have not been checked against PCSX2 the way the lighting
pass was. That is a verification gap, not a parsing gap, and it needs a frame
captured at a known view distance rather than more disc-side measurement.
2026-10-13 04:50:28 +02:00
ReaperOfVeriod 54862b64ee Identify the tie record as RAC1's TiePacketHeader, and retract a formula
The 16-byte tie record is R&C1's TiePacketHeader offset for offset: a data
pointer at +0, then R&C1's byte fields at +4 through +0x10. The identification
is not a resemblance but R&C1's own rule -- bfc_distance == 5 * shader_count --
holding on all 15,219 records of all 2,177 blocks on all 27 levels.

Measured over the same set, and reported rather than asserted where it is not
universal:

  bfc_distance == 5 * shader_count      15219 / 15219
  vert_ofs <= vert_size                 all
  scissor_size <= scissor_ofs           all
  control_size <= control_count         all
  color_ofs == vert_size                12640 / 15219
  color_ofs == vert_ofs + vert_size    0 / 15219
  color_count, strip_count, strip_vertex_count zero  15089 / 15077 / 15078

R&C1's rule for the colour offset holding on none of them is the interesting
line: the two games use that field differently, so it is named color_ofs for
what this game's majority reads as and the 2,579 exceptions are counted rather
than averaged away. TieRecord parses all twelve fields, invariants_hold asserts
only the universal four, and the disc test counts the rest.

A packet-length formula is withdrawn rather than softened. (vert_ofs +
vert_size + scissor_ofs + scissor_size) * 16 was measured at 61 of 61 pairs and
is not reproduced on the correct blocks -- 0 of 13,042. The block set was the
error, not the arithmetic: the script that produced the 61 read packets[2]
where packets[1] was meant, so it measured 461 blocks instead of the 2,177 the
class tables define, and the formula agreed perfectly with a population that
was not the tie blocks. TieRecord::packet_bytes goes with it, and the
retraction is recorded in the format doc and in the struct.

That misread also explains why the earlier RAM verification saw different
numbers for the two inputs -- same lesson as rule 3, arriving through a wrong
subscript instead of a wrong span.
2026-10-13 04:22:38 +02:00
ReaperOfVeriod 4e90c282ad Make the tie record relocation a command, and verify it on two levels
The relocation rule from the previous commit was established by a script that
lived in a shell history, which means the next person would have had to
rediscover it. It is now `gc-trace verify-tie-records <unpacked-dir> <level>
<class> <lod> <src>`: resolve the class, parse the block, search the RAM image
for the loaded record table, report what it found, and exit 0 only when every
record's pointer has become table + word and every field byte is verbatim.

Swept over every (class, lod) slot of two levels, from PCSX2 savestates:

  level 0: 87 slots with a block, 87 verified, 0 mismatched, 61 absent
  level 1: 104 slots with a block, 104 verified, 0 mismatched, 78 absent

So the rule holds on every LOD block of two levels the game had loaded, which
is what makes the record layout a finding rather than a reading of one block.

Writing the command also caught an off-by-one in the parser it exercises:
TieBlockHeader::records started its slice after the 16-byte header, but the
header IS the first record -- the table runs from the block start to
data_offset, and a block with data_offset 0x50 holds five records, the first
being the header itself and its pointer naming where the payload begins. Found
because the tool's first run relocated nothing while the same check in a script
had passed; the unit test fixture was wrong in the same way and is fixed.

Two operational notes now in the format doc: the live emulator is not a stable
input (verifying level 1 over PINE matched 104 of 104 while the user was on
that planet, and the same command minutes later found no matching copy because
they had returned to level 0 -- a miss there is not a counterexample, so the
report says "no copy matched" rather than "failed"), and a class index past the
table count is an error rather than a mismatch, so a sweep should stop at the
level's class count rather than a round number.
2026-10-12 15:24:48 +02:00
ReaperOfVeriod e6eecb871f Decode the tie LOD block: it is a table of 16-byte records
The tie block's word at +0x00 was read as an element count, and the reading
failed in a way worth recording: the payload length divided by it is a whole
number on 0 of 922 blocks, at any record size and any header up to 512 bytes.
That is the correct measurement of the wrong hypothesis -- the word never
described the payload.

Range measured: every LOD block of level 1 whose word is a non-zero multiple
of 16, plus the two read by hand in full.

Method: locate each block's record table in PCSX2's RAM by its twelve field
bytes, which are the only distinctive 12-byte run in a block, then compare
every record against its disc copy. Each block has several copies in RAM, and
the one that matches is the loaded one -- checking the first hit is what made
the first attempt report 0 of 16.

Result: on 16 of 16 blocks, every record's leading word has become
table_address + word, and the other twelve bytes are byte-identical to the
disc. So a block is [16-byte header][record table][vertex payload], the records
are 16 bytes -- a leading u32 pointer plus twelve bytes of fields -- and the
header's word at +0x00 is the byte offset from the block start to the payload,
that is, the length of the table.

That is what the multiple-of-16 was: the table holds 16-byte records. It is
never zero because there is always at least the first record, and its 42
distinct values are table lengths. Each record's pointer is relative to the
table start, relocated at load to table + word.

TieBlockHeader::data_offset and TieBlockHeader::records carry it, bound by the
offset, with TieRecord for the twelve field bytes, and the disc test asserts
the table is bounded on every block. What the records' fields name and what the
payload holds are still open -- the payload is known to be variable-length,
float-shaped, not LZ77 and not VIF1.
2026-10-12 07:17:41 +02:00
ReaperOfVeriod c2af0528e3 Correct the chunk loader finding and decode a record field
The loader note said every record gains a 02 00 prefix in RAM. It does not:
the word is the record's own leading field, already on the disc at every
record. What the loader changes is the pointer -- it addresses the word, two
bytes earlier than the disc's table -- and that pointer shift is what made the
bytes look inserted.

Measured, in order:

- the two bytes before a payload are identical in core_data and in RAM, record
  for record: 02 00 on level 0's 90-record chunk at 0x100b302, 08 00 on the
  level's first chunk. Nothing is inserted;
- the RAM table's low 24 bits are the payload's minus 2, consistently, so the
  record is [leading u16][two index bytes][payload] and the loaded table
  addresses the leading word. That is also why the index bytes read as +2 from
  the pointer -- the same +2 the earlier RAM result quotes;
- the word is a field, not a pad: uniform across a chunk's records on 1,439 of
  1,509 chunks (None on the 70 that vary, reported rather than averaged),
  fixed within all 71 chunk types, and only ever 1, 2, 4, 6, 8 or 10 -- 554
  chunks carry 2, 520 carry 4.

Two readings of the value were measured and disproved, so they are recorded as
disproved rather than left as candidates: it is not an entry count (the payload
divided by it leaves 57 distinct fractional "sizes", and 162 chunks do not
divide at all), and it is not the number of distinct leading bytes (agrees on 0
of 1,506 chunks).

Chunk gains `prefix: Option<u16>`, and the disc test asserts the uniformity,
the per-type fixity and the value set. The scratch example that produced the
per-type class-ID table is dropped now that the same scan is a committed test,
which also lands here: moby class IDs sit in one body slot per chunk type, with
the slot varying by type and the distinct >= 4 filter load-bearing -- without it
the top slots are constants that read as 305x because any value below the moby
count counts as a hit.
2026-10-12 06:00:29 +02:00
ReaperOfVeriod 28680b4be3 Show the first two chunk-record bytes encode the record's own index
Continuing from the phase counter. Within a chunk:

  i    :  0  1  2  3  4  5  6  7  8  9 10 11 ... 15 16 17 ...
  byte0:  0 16 32 48 64 80 96 112 128 144 160 176 ... 240  0 16 32
  byte1:  0  0  0  0  0  0  0  0  0  0  0  0 ...   0  1  1  1

byte0 is step*(j mod g) and byte1 is (j / g), where g = 256/step and
j = index + phase. The pair is the record's index in mixed radix: byte0 carries
the low digits scaled by step, byte1 the quotient.

Measured over all 27 levels: 1,235 of 1,509 chunks fit this on both bytes. Byte 0
alone fits 1,262, so the 27-chunk difference are chunks whose first byte follows
the rule and whose second does not. On 1,235 chunks a record knows its own
position and the two leading bytes hold no per-record information.

ResolvedChunk::index_rule returns the (step, phase) only when the whole chunk fits
on BOTH bytes, so the 274 that do not stay distinguishable and nothing skips a
leading byte on a rule that does not apply to it.

A number that moved when the implementation changed: a first probe reported
1,181; the committed method reports 1,235. The method is right. The probe
accumulated ok0/ok1 flags across candidate phases with an early-exit that could
skip a phase which would have matched both bytes, and it also disagreed with
bucket_rule on the byte-0 count (1,208 against 1,262), which is what gave it away.

Recorded because both numbers came from the same run and only the discrepancy
exposed the probe as wrong. The lesson is narrow: when a one-off probe and a
committed helper disagree, check the probe first.
2026-10-12 04:25:44 +02:00
ReaperOfVeriod 79914c420e Show the chunk record's leading byte is a per-chunk phase counter; every chunk
has a uniform record length

Follows the alignment result and settles what the leading byte is.

Within one chunk the leading byte steps by a constant and wraps:

  offsets: 1420 1748 2072 2392 2716 3044 3372 3700 4028 4352 ...
  byte0:      0  16   32   48   64   80   96  112  128  144 ...

Sweeping every (step, phase) over all 27 levels:

  fully explained: 1,262 of 1,509 chunks
  by step: 8 -> 51 chunks, 16 -> 554, 32 -> 520, 64 -> 137
  unexplained:  247 chunks across 11 chunk types

So on 1,262 chunks the leading byte carries no per-record information: it is a
deterministic function of the record's index and the chunk's phase, which is a
position marker rather than data. That is why the earlier "16 discrete values,
uniform counts" pattern appeared at all.

ResolvedChunk::bucket_rule returns the (step, phase) when the whole chunk fits and
None otherwise, so the 247 unexplained chunks stay distinguishable and nothing
skips a leading byte on the strength of a rule that does not apply to it.

Every chunk has a uniform record length: 1,509 of 1,509. Length varies BETWEEN
chunks of a type -- one type runs 76 to 104 bytes -- but never within one. This
withdraws the previous commit's "variable-length type", which came from pooling
minlen/maxlen across all of a type's records at once. That is the pooled-claim
error rule 3a exists to prevent, and it is the third time this session a pooled
measurement produced a claim that partitioning reversed. The type is not
variable-length; the chunk is fixed-length and different chunks differ.
2026-10-12 04:21:32 +02:00
ReaperOfVeriod a9bf74fc10 Characterise all 77 chunk types: record byte 0 is a bucket index, not data
Characterised every chunk type -- 77 of them, 1,509 chunks, 34,491 records, all 27
levels.

Byte 0 of every record is a small aligned index, not record content. It takes 16
discrete values per chunk type (0x00, 0x10, ... 0xf0) with near-uniform counts
WITHIN each type. Uniform weighting is the tell: a running byte offset would
weight the buckets unevenly, because records differ in size.

  type_id      recs  len  byte0
  0x007d7ec438 2430  328  0x00:162 0x10:162 0x20:162 ... 0xf0:135
  0x00429ec43d 2079  368  0x00:162 0x10:135 0x20:135 ... 0xf0:108
  0x00d76cc453  810  108  0x00:27  0x08:27  0x10:27  ... 0xe8:27   <- steps by 8

The alignment step is per type, not global. The leading byte is a multiple of 8 on
34,432 of 34,491 records but a multiple of 16 on only 33,349 -- the 1,142-record
difference falls in seven types, four of which step by 8. 59 records in three types
break even the 8-byte rule, and that is where the variable-length type lives:
0x00f2ef4275, records 76 to 104 bytes, no single stride to align to.

What the bucket indexes is NOT named. It is reported as CHUNK_RECORD_ALIGNMENT and
record_is_aligned rather than as a parsed field, because naming it would be a guess
while the invariant is solid. The test pins the exact exception set -- 59 records
across 3 types -- so a change there fails loudly.

A measurement I initially got wrong: the first version of the constant claimed a
16-byte multiple on 34,432 records. 34,432 is the multiple-of-8 count; the
multiple-of-16 count is 33,349. Right number, wrong label, and the test caught it.
That is the second time this session a correct number carried a wrong label, after
the tie four-value retraction. Both times the assertion did, which is the argument
for putting these numbers in tests rather than only in prose.

Still open: what the bucket indexes, and the remaining record fields.
2026-10-12 04:18:55 +02:00
ReaperOfVeriod 291153a0d9 Find the tie LOD block terminator; packets == 0 means no block at that LOD
parse_tie_block walks to a 0xf6 terminator that every real block carries, on
2177 of 2177 across all 27 levels, followed by zero padding. This is what makes
the blocks parseable: packets[1] bounds one on only 461 of 1864 classes, so the
terminator is the only general way to find one.

The terminator was worth a control, because 0xf6 could have been byte-frequency
coincidence. It occurs in payloads at 0.83x the uniform rate -- slightly LESS
often than chance -- while the last non-zero byte is 0xf6 on 909 of 922 blocks in
the first subset, against 17 of 922 for the same blocks cut at a random point. So
it is a terminator, not a common byte.

packets == 0 means the class has NO block at that LOD: of 3,728 (class, LOD)
slots, 2177 point at a block and 1551 are exactly zero.

Corrected header, over all 2177:

  +0x00  count    multiple of 16 on all; 42 distinct; never zero
  +0x02           zero on all
  +0x04           exactly four values: 0x501 (1069), 0xa02 (720), 0xf03 (350),
                  0x1404 (38)
  +0x06           16 distinct, never zero
  +0x08           180 distinct, never zero
  +0x0a           61 distinct, never zero
  +0x0c           125 distinct, never zero, clustered 0x043f..0x0443
  +0x0e           11 distinct, zero on 2162 -- the only non-universal field

+0x0c clusters around 1087-1091, close enough to a block length to be worth
stating, but it is NOT the length: the difference against the true payload length
scatters from -12 to -172.

A correction to the previous commit. It reported 3,728 blocks all terminating and
packed_texture taking 842 distinct values, and on that basis retracted the
four-value claim. Both numbers were wrong the same way: the scan did not skip
packets == 0, so it computed class_base + 0 and walked forward from the CLASS
BODY, finding a terminator there by accident. That contaminated 1551 of the 3728.

With the zeros excluded the slot takes exactly four values on all 2177 blocks.
The four-value claim was right; my retraction of it was wrong; and the retraction
was made on a number from the same contaminated set. A retraction is not
automatically better than the claim it retracts -- it needs its own bounds.

Still open: the payload's records. Established this round: blocks are
self-delimiting, absent LODs are marked by a zero packets word, and the header is
fully characterised. Not established: any record boundary inside the payload,
which stays variable-length.
2026-10-12 04:13:52 +02:00
ReaperOfVeriod 02342de31b Decode the tie LOD block header; establish the payload is variable-length
TieBlockHeader parses the block's own 16 bytes, measured over the 922 blocks
where "the next packets offset ends this block" holds, across all 27 levels. A
real-disc test asserts every figure.

  +0x00  count        multiple of 16 on 922/922; 16..2096; 72 distinct disc-wide
  +0x02               zero on 922/922
  +0x04               ONE OF FOUR VALUES DISC-WIDE: 0x501, 0xa02, 0xf03, 0x1404
  +0x06               15 distinct, 0x0206..0x0512
  +0x08               135 distinct, 0x0b05..0x4ee4
  +0x0a               45 distinct
  +0x0c               94 distinct, 0x010e..0x0553
  +0x0e               zero on 914/922

+0x04 is packed rather than a magnitude: four values across the whole disc with
the two bytes moving independently, low 1-4 and high 5/10/15/20 in lockstep. That
it is a texture index paired with a scale is a guess; that it is two fields is
not.

The count is not a record count -- block_size minus the header is divisible by it
on 0 of 922, for any record size and any header up to 512 bytes.

On the payload, a control was needed and changed the answer. Reading blocks as
3 x f32 triples, 53-64% of reads land within +/-5000 of origin, against 16% for
a uniform-random control of identical length. So the content is float-shaped and
genuinely not random.

But the rate is flat across every start offset and every stride tried, 53-65%
throughout. A statistic that cannot separate the right stride from the wrong one
cannot identify a stride -- rule 4's exact case. Flatness plus a count that does
not tile means the records are variable length.

That killed two readings. A VIF1 command walk consumes 0 of 88 blocks exactly,
so it is not a VIF1 CPU-format stream. wad::decompress rejects the block at every
start offset, so it is not WAD LZ77.

Scanning every (start, stride) pair from 0x10 to 0x7f for byte positions with a
small distinct-value vocabulary finds none: every position takes all 256 values
at every alignment. For float payload that is expected, so the scan found nothing
rather than confirming anything -- worth saying, because "no fields found" here is
uninformative, not evidence against fields.

Still open: what the payload holds. Established: variable-length, float-shaped,
non-random, not LZ77, not VIF1. Not established: any record boundary.
2026-10-12 04:09:08 +02:00
ReaperOfVeriod 855e640f39 Establish the 16-byte tie record on measurable classes; packets[0] is a constant
The previous commit reported "3580 non-empty LOD blocks compute as divisible by
16" and then said the figure should not be trusted, because it came from the
"next packets offset ends this block" rule. That rule is unsound: packets is
strictly increasing on only 461 of 1864 classes. On the other 1403 the two
offsets are unordered and cannot both be block starts into one ascending region.

Restricting to the 461 classes where the rule does hold, 922 of 922 consecutive
block sizes are a multiple of 16. That is the evidence for a 16-byte record, and
it is now scoped to the subset where it can be checked rather than claimed over
all 1864. The other 1403 are unmeasured, not measured-and-different.

packets[0] is a constant, not an offset: it reads 256 on 1864 of 1864 classes,
while packets[1] and packets[2] vary widely and land at different addresses in
core_data on every class. Read as a block start, 256 points just past the
0x48-byte header -- which is how it was being used, and exactly how a field gets
mistaken for a pointer. It is now TIE_PACKETS0 and nothing depends on it.

Still not decoded: what the 16 bytes hold. i16[0] at +4 takes 8969 distinct
values on level 0 and is not the two-valued field a 12-unit sample suggested; the
word at +0 is not a run offset.

The test pins all four numbers -- packets[0] constant, the 922/922 divisibility,
the 461/1864 ordering, and the 0/1864 monotonic disproof -- and asserts that the
ordering is *not* universal, so a future change there fails loudly instead of
quietly invalidating the record size.
2026-10-12 03:50:42 +02:00
ReaperOfVeriod 60e3f129d8 Resolve both tie indirections on all 1,864 classes, and disprove a sample artifact
resolve_tie_class walks the chain, which is two steps and both are easy to get
wrong:

  1. The tie class table lives in core_index.bin, not core_data.bin. On level 0
     the tables chain as moby at 192, 238 x 32 bytes later tie at 7808, 74 x 32
     later shrub at 10176, which closes. Reading the same table out of
     core_data.bin produces plausible garbage rather than an error.
  2. ClassEntry::offset_in_asset_wad addresses core_data.bin, despite the field
     name and despite core_data.wad sitting alongside it.

Verified: all 1864 tie classes resolve, o_class agreeing on every one, and all
5592 LOD block offsets land inside core_data.

Disproved, and this is the part worth reading: the word at +0 of a 16-byte unit
inside an LOD block. On the first twelve units of level 0's class 0 it rises
strictly -- 288, 1520, 2848, 3968, 5088, 6096, 7312, 8528, 9792, 10976, 12016,
13072 -- which reads exactly like a linked list, and the accompanying i16s looked
quantised, two distinct values in i16[0] and three in i16[1].

Across the population it is strictly increasing on 0 of 1864 blocks, and i16[0]
takes 8969 distinct values on level 0 alone rather than two. The sample was
twelve units out of 1,724,334. Both the monotonic reading and the quantisation
were artifacts of that sample.

That is the pooled-claim error AGENTS.md rule 3a exists to prevent, committed
almost verbatim: the first twelve units looked clean, and I was one step from
writing "u32[0] is a linked list of runs" into a format spec. The test now
asserts the disproof, so it cannot creep back.

Not established: the block's record size. 3580 non-empty LOD blocks compute as
divisible by 16, but some packets offsets run backwards, so the "next offset
delimits this block" end is wrong for them and that figure should not be trusted.
The tie layout stays partial: indirections and header done, records not.
2026-10-12 03:47:49 +02:00
ReaperOfVeriod 02ced5ee83 Parse the per-level overlay: header, constant block, level data
level_overlay was the least-characterised file in the level set -- no parser, no
located structure. It now has gc_formats::overlay and a real-disc test over all 27
levels:

  overlay: 27 levels; field_00 1735808, field_08 1;
  field_04 37540..=45308, field_0c 2763912..=3027336
  (17 below the file length, 10 above);
  +0x10..0x1220 identical on all

The header is four words. +0x0c is not the file length: it was recorded as one on
the strength of the first levels measured, where it sat near the file end, but
across all 27 it falls on both sides -- below on 17, above on 10 -- so it is an
ordinary level-specific word. The test asserts this, being the claim most worth
pinning.

The common prefix is 4 bytes, not the whole header: +0x00 is constant at 1735808
while +0x04 and +0x0c vary. Everything from +0x10 to +0x1220 -- 4624 bytes -- is
byte-identical on all 27 levels.

Still not named: what any of the four header words means, and how the
level-specific tail is structured. The module reports them as fields.

Also records in docs/plan/pcsx2-verification.md that the 64-byte RAM stride from
the previous commit is disproved. A 415 KB dump of level 1 finds the longest such
run at 27 records, not 384, and those are identity matrices -- 27 of them, one per
level. It is a per-level transform table, not the collision grid, and the 20.0
reported there was a coincidence.
2026-10-12 03:32:02 +02:00
ReaperOfVeriod 2569cc06ab Decode the collision payload's leading two counts
Range measured: the first four bytes of the payload on all 19 levels that carry
a non-empty one.

Result: two u16 at payload +0 and +2, both non-zero on all 19, ranging 1..74 and
13..112, products 53 to 3256.

  level  0  11 x 33  =   363   payload   903248
  level  1  24 x 16  =   384   payload  2800496
  level  2   1 x 53  =    53   payload  2315296
  level  7  40 x 35  =  1400   payload  4049232
  level  9  74 x 44  =  3256   payload  2518352
  level 13   2 x 64  =   128   payload  3333520
  level 26  27 x 112 =  3024   payload   652176

Reported as two counts, not as a named grid. They have the shape of collision
grid dimensions -- bounded, both non-zero, products in the hundreds to thousands
-- but nothing measured ties the product to the payload size or to any per-cell
stride. Level 2's 1 x 53 giving a 2.3 MB payload and level 26's 27 x 112 giving
652 KB shows the product does not size the payload, so either cells vary wildly
in size or these are not grid dimensions. Naming them width and depth would be a
reading, not a measurement.

After the two counts comes a run of u16 that stays below 8192 for 30 to 58
entries depending on level, always an even count, and is not ascending. That is
where the per-cell table most likely starts, and it is the next thing to
resolve.

Exposed as CollisionHeader::first and ::second. They live in the payload rather
than the header, so CollisionHeader::region fills them in and the bare
CollisionHeader::parse leaves them zero -- documented at the call site so the
difference is not mistaken for an absence.
2026-10-12 02:58:46 +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 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 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 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 10188416e2 Decode the chunk count field: header byte 14 is the record count
Range measured: all 27 levels, 1123 chunks, over the 900000 bytes before the
constant trailer.

Method: compare header byte 14 against the number of offsets actually resolved
for that chunk.

Result: byte 14 equals the record count on 1123 of 1123 chunks, zero
mismatches. This is the first genuinely decoded field in this container rather
than a measured correlation.

The parser now truncates the offset table to header byte 14, so a stray
tagged word in a chunk's record data cannot silently extend the table and put
the count at odds with its own header.

Two more header bytes are near-constant and read as ASCII: byte 13 takes only
0x45 (1076 chunks) and 0x46 (47); byte 9 takes 0x44 (26), 0x45 (1084) and
0x46 (13). Those are D, E and F -- a three-letter tag at two byte positions
with different distributions. Recorded as suggestive, not established: the
other 14 header bytes are high-entropy and read as ASCII at no shift tried, so
the D/E/F may be incidental. Worth testing against a known type table.

Still open: the 14 opaque header bytes, whether bytes 9 and 13 are type tags,
which record fields are index/length/offset, and whether a chunk is moby_anim.
2026-10-11 04:26:44 +02:00
ReaperOfVeriod 681333535a Add a bounds-checked parser for the post-shrub chunk container
gc_formats::chunks parses the container that fills the region after the last
shrub class body and before the constant trailer:

  +0x00  16 bytes, last byte 0xff     (opaque, differs per chunk)
  +0x10  10 zero bytes
  +0x1a  N tagged offsets, top byte 0xf0

Each tagged offset is 0xF0000000 | low24, and a record is read at
chunk_start + low. Resolving that way gives records sharing a field layout,
which is what distinguishes the container from a coincidental byte pattern.

Fields are not named. The 16-byte header is opaque, the 0xf0 tag is
unexplained, and it is not established that a chunk is moby_anim -- a
49 x 368-byte table inside this region is byte-identical on all 27 levels,
which is evidence against that identification. What the parser provides is
chunk and record extraction, which is what the analysis established.

The gated test measures, over all 27 levels: 1123 chunks, 19546 records, 50
distinct strides, 12.4% of byte positions constant across records (per-chunk
range 2.8% to 75%). Chance gives a constant position probability of 1/256^n.

Three things the test got wrong first, recorded because they are the traps:

- A per-chunk threshold of 20% constant positions. Individual chunks run from
  2.8% to 75%; the aggregate is the invariant, not the chunk. Asserting 20%
  per chunk was asserting a property the format does not have.
- Requiring every offset to resolve. 27 chunks truncate at the window edge,
  because the 900000-byte window does not align to chunk boundaries. Counted
  now rather than asserted away.
- read_u32 in the chunk signature check was big-endian while Buf::u32 is
  little-endian. The tag lives in the high byte of a little-endian word, so
  the mismatch made every level report zero chunks while parse_chunk -- which
  goes through Buf -- was correct. Worth remembering that the two paths can
  disagree on a format this young.

Tests: 190 -> 191.
2026-10-11 04:25:18 +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 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