122 Commits
Author SHA1 Message Date
ReaperOfVeriod e389d6d082 UpdateCamera_6 verified at 0x4284a8 -- three independent checks
The anchor 20655c7 said had to be verified before following. It is.

docs/AUG6-RETAIL-ANCHORS.md recovers UpdateCamera_6 and InitCamera_24 as class/role
identifiers in the two dispatch tables, so role 6 of lvl.camvtbl is the camera
update function. The decomp's addresses are in the same space as ours -- its
func_L00_002A3B80 is this overlay's .text dest exactly -- so the claim is checkable
rather than taken on faith.

Three checks, because PROVENANCE.md records that the decomp's function addresses
have failed once here:

1. Role 6 of level 0's lvl.camvtbl names handler 0x004284a8.
2. overlay_bytes_at reads real code there: 0x3c014094, a lui.
3. tools/overlay-anchors decodes the first six words with zero failures:

       lui  $at, 0x4094
       ori  $at, $at, 0x7ae1    ; $at = 0x40947ae1
       mtc1 $at, $f1             ; a float into an FP register
       lw   $v0, 0x2290($s4)
       lui  $at, 0x3f00
       mtc1 $at, $f3             ; 0x3f000000 = 0.5f

Two mtc1 into FP registers and a struct field at +0x2290: a function doing
floating-point work on a camera struct. Three agreeing checks are a different kind
of evidence from any one of them, which is the lesson of the moby_anim chase.

Caveat recorded: level 0's lvl.camvtbl is 0xb4 bytes, 13 records and 13 distinct
ids, but several ids read as implausible large values (4,350,904 and similar). The
record layout is not uniformly id/handler, so role 6 being clean may be luck. Worth
remembering when the other roles are read.

Next, unblocked: read all of 0x4284a8 and find the store that writes 0x18aa70.
That confirms m4 by locating the write site rather than inferring it from input
response, and the frustum should be whatever the same function writes nearby.

295 passing, clippy clean.
2026-10-15 03:36:08 +02:00
ReaperOfVeriod e7eec01b50 m4 is the live camera: a 20x outlier against eight controls
d4e81ba left one question: the camera's rotation is one of the nine, but which.
Answered by turning the camera in the live emulator and watching which matrix
responds. Ten captures at ~0.6s intervals; angle through which each matrix's first
row rotated, from capture 1:

    idx     1->4      1->7     1->10
     m0     0.37     0.27      0.53
     m1     1.31     1.07      1.34
     m2     3.89     1.38      1.43
     m3     2.25     1.18      1.40
     m4    30.60    18.49     37.21     <-- the camera
     m5     1.70     1.45      1.82
     m6     1.24     0.98      1.29
     m7     1.88     1.34      1.73
     m8     1.70     1.45      1.82

The eight others are the control and they sit under 4 degrees across the whole
sweep while m4 goes to 30-37. Its motion is non-monotonic, tracking the input
being turned one way then back.

The translations gave the complementary half: all nine move together by the same
few thousandths. So the nine share one camera position and carry nine different
orientations, one of which is live.

guard_band step 1 now has both operands:

    R      rows 0-2 of the matrix at 0x18ab70
    camera its fourth row, (247.1, 193.4, 50.4) on Aranos

NOT established: why there are nine. They are not nine cached frames of one camera
-- if they were, all nine would have swung by 30 degrees together. They are nine
distinct orientations about a shared point with one selected, and a table selected
by an index fits. The nine-legal-orientations-on-a-spherical-planet reading stays
plausible and unproven; what is proven is which entry is live. No reindexing was
observed, so if the index moves it does not on camera rotation alone.

294 passing with the disc and savestate present, clippy clean.
2026-10-15 03:29:14 +02:00
ReaperOfVeriod d4e81ba977 Correction: the camera region is nine matrices, not three
dcd4dd6 said three 4x4 matrices at 64-byte stride and read them as a
view/modelview/lighting family. There are nine. Contiguous from 0x18aa70, breaking
at 0x18acb0 where row norms are 0, 0, 0.97 and w = 0.

All nine are orthonormal to 1e-6, all affine, all translating inside a 0.4-unit
box, all moving smoothly between live frames. Nine different rotations about one
position is not a view/projection/lighting family -- it is a table indexed by
orientation, and the index is what is unknown.

So dcd4dd6 was right that the region holds the camera position and wrong that
three was the extent. It stopped at three because three read as a sensible story.
That is rule 4's failure and I have now committed it twice in this investigation.

What survives unchanged: the translation is the camera position, (247.3, 193.1,
50.5), verified by orthonormality plus the smooth-motion control. The rotation
changes far faster than the translation between frames, which is what identifies
which part is which.

What is now open and larger than it looked: which of the nine is the R that
guard_band step 1 wants. Not "pick m0". Nine orientations about one point is a
table with an index. A plausible reading -- that these are the nine legal camera
orientations on a spherical planet -- would be a real find and is NOT established
by anything measured here.

Two cheap checks, both needing only the live emulator: move the camera and see
whether all nine translations move together or only one; and change pitch to see
whether the nine rotations reindex.

The test now checks all nine rather than three, so a tenth would be noticed.
294 passing with the disc and savestate present, clippy clean.
2026-10-15 03:27:01 +02:00
ReaperOfVeriod dcd4dd6246 Camera transform found at 0x18aa70: three orthonormal matrices, one position
With PCSX2 on Aranos the savestate problem went away. PINE reads EE RAM live, so
two dumps seconds apart are consecutive frames by construction -- exactly what the
offline savestates could not provide (1aa94ee).

Of 1,654 changed 16-byte float triples between two live dumps, most singletons are
mob positions moving 4-5 units. One region is three 4x4 matrices at 64-byte
stride, each with an affine fourth row:

    0x18aa70   0.0338 -0.1983  0.9796  0.0000
              -0.0275 -0.9799 -0.1974  0.0000
               0.9990 -0.0203 -0.0386  0.0000
              247.2990 193.1330 50.4526  1.0000
    0x18aab0   t = 247.239, 193.102,  50.497
    0x18aaf0   t = 246.940, 193.155,  50.151

The control that makes this an identification rather than a coincidence: all three
upper 3x3 blocks are orthonormal to within 1e-6 -- row norms 0.999995..0.999998,
pairwise dots at or below 1e-6. Three consecutive float triples in a random 32 MiB
region being unit length AND mutually perpendicular does not happen twice. The
residual is f32 rounding, expected on an FPU that is not IEEE-754.

Camera position on Aranos: (247.3, 193.1, 50.5).

camera_transform_is_orthonormal_with_a_moving_translation asserts orthonormality,
w == 1, and that the translation moves smoothly but not wildly. That last half is
the check 1aa94ee needed and the savestates could not supply: consecutive frames
give 0.002-0.005 of movement, against ~230 between unrelated savestates.

What this unblocks: guard_band step 1 is R * (bsphere - camera * 1024). Both
operands now exist. R is rows 0-2 of 0x18aa70 and the camera is its fourth row, so
the clip test can run against a real frame for the first time.

Not assumed, and recorded as open: which of the three matrices step 1 wants; whether
the float translation needs converting to the disc's 12.4 fixed point first (the
algorithm multiplies by 1024, which implies it does); and the frustum planes, which
the guard band needs and which are not in this structure.
2026-10-15 03:23:02 +02:00
ReaperOfVeriod b4d605a2f4 Docs audit: 15 stale figures left behind by two fixes, including a wrong numerator
Checked the docs for claims this session retracted. Found more than the first pass.

    1,163 -> 1,162   9 in the format spec, 3 in the roadmap, 5 test doc-comments
    5,878 -> 5,822   2 in the spec, 3 in test doc-comments
    234 -> 233       1 in the spec, 1 test doc-comment -- level 0's contribution

The last one is the one a blanket search would have missed. "5,878 records, and
level 0 contributes 234" was stale in both numbers, and only one of them was in
the string I was looking for. Verified against the tests rather than against the
other stale prose:

    lvl.vtbl: 1162 distinct class ids from 5822 records; max 0x13ba (5050);
    moby_classes.count is 238, and 1135 of 1162 ids (97.7%) are at or above it
    vtbl oClass: 1162 ids, largest 5050; ... 1133/1162 ... level 0 alone
    contributes 233

The reason all of it survived is the same shape as the bug it belongs to. The
tests were fixed and the new numbers asserted, and the prose was not touched -- and
the prose is what a reader takes away. A commit that corrects a measurement without
correcting the document it was measured in has only half-landed. Two corrections in
this session, 15 places.

Also left alone deliberately: the explanatory sections in core-data-regions.md that
discuss 5,878 and 1,163 as the *wrong* figures, and the test comments that record
what the loop used to print. Those are correct as written -- removing them would
erase the reasoning. A final sweep confirms nothing stale remains outside those.

293 passing with the disc and savestate present, clippy clean. Documentation and
comments only; no behaviour changed.
2026-10-15 03:17:23 +02:00
ReaperOfVeriod 3dbd384b59 The guard-band clip test: implemented and unit-tested ahead of the camera
1aa94ee moved the camera hunt behind a PCSX2 session, because no two savestates on
hand are consecutive frames. But the camera is only part of this item -- the test
itself is a pure function over a camera and a frustum, so it can be written and
tested now, and then drop straight in when the camera is found rather than being
written under time pressure later.

New gc_formats::guard_band implements docs/plan/tfrag_rac1.md 3b.2 steps 1-7,
returning Cull, Normal or ClipAtLod0. Seven unit tests: directly ahead, behind
camera, beyond far, straddling the side planes, all-corners-outside-one-plane, the
integer sqrt, and a rotation.

This is not speculative plumbing. The inputs are already parsed and verified:
TfragGeometry::bsphere, clip_corners (8 corners, median 1.000x the bounding radius
from their centroid) and draw_mode (matching the reference table).

WHAT THE FIRST VERSION GOT WRONG

The reference culls when ONE plane has every corner outside it. I first wrote
"every corner is outside some plane", which culls patches that merely straddle
*different* planes. The test caught it at once: a patch whose corners were beyond
far in one axis and the side plane in another returned Cull where it should be
ClipAtLod0. Fixed by carrying a per-corner 4-bit mask of which planes it is
outside and testing each plane across all corners.

That is what the guard band is for. A patch outside the frustum but inside the
guard band must still draw, via the clipping program, so objects partly off-screen
do not pop. Collapsing the two turns "4x guard band" into "immediate cull", and
the symptom is objects vanishing at the screen edge rather than any error.

NOT VERIFIED AGAINST THE GAME

Steps 1-4 are implemented from the reference and have not been compared to a real
frame, for the reason above. The unit tests check the algorithm implements the
documented rule, not that the rule is what the game does. Two scales are my
reading of the fixed-point convention and are NOT established: tan = 1024 for 1.0,
and the clip-cube scale of 64. Both are flagged in the module doc so they are not
mistaken for measured facts.

293 passing with the disc and savestate present, clippy clean.
2026-10-15 03:09:57 +02:00
ReaperOfVeriod a991088103 Camera state: 2,005 per-frame float triples, one singleton candidate
The guard-band clip test's blocker is named in core-data-regions.md: the algorithm
needs the camera and the frustum, both per-frame, so it needs a savestate. Two
savestates give that as a differential -- anything that moved is per-frame state --
without needing to know where the camera lives.

Frames .01 and .04, 33,554,432 bytes each. Of every 16-byte-aligned triple of
finite floats with magnitude 0.001..100,000, 2,005 differ.

Two shapes, clearly different things: the 0x1886xx block repeats one triple four
times at 16-byte stride, which is a per-object array, and 0x18aa70 holds a
near-unit vector that reads as a direction. 0x152c70 is a singleton,
[247.000, 194.000, 49.890] -> [300.170, 227.480, 128.000], in the range Aranos
occupies.

Not claimed as the camera. 2,005 candidates is not a shortlist and a plausible shape
is not evidence -- this stretch has produced enough of those.

Three confirmation steps recorded, cheapest first: smooth motion across .01-.04, a
singleton rather than an array element, and perturbing the camera in an emulator to
watch that one address change. The first two need only the savestates already on
disk; the third is what the lighting verification used.

286 passing with the disc present and GC_SSTATE set, clippy clean.
2026-10-15 02:15:05 +02:00
ReaperOfVeriod e3e0a30030 The GS TEX0 register set, and why TBP0 is region-relative
Picking up "the GS register set the level load builds from the texture entry"
from level-assets.md, for a renderer that emulates the GS rather than resolving
texture addresses on the host.

gc_formats::gs gains tex0_of (TBP0, TBW, TW, TH, PSM), texture_slot_bytes and
clut_base. Three measurements back them, over all 27 levels and 1,595 textures.

Every dimension is a power of two: 1,595 textures, zero exceptions, in four
classes -- 128x128 (1,249), 64x64 (303), 256x256 (22), 32x32 (21). The GS
addresses textures by exponent, so TW and TH are log2 of the dimensions, and a
texture that was not a power of two could not be addressed at all. That the
whole population is one is the measurand for the decode.

TBP0 is region-relative. 19 of 27 levels have a textures_base only 64-byte
aligned -- mod 256 reads 0, 64, 128 or 192 -- while every entry's own
data_offset is 256-aligned on all 1,595. A base pointer computed from core_data
would therefore not be a whole number of 256-byte blocks on those levels. The
first version of tex0_of took textures_base + data_offset and rejected 1,168 of
1,595 textures for exactly this reason, which is how the finding surfaced.

The slot is level 0 plus one mip level: the stride between consecutive entries
is w*h + (w/2)*(h/2) on 1,560 of the 1,564 ascending gaps -- 32x32 gives 1,280,
64x64 gives 5,120, 128x128 gives 20,480, 256x256 gives 81,920. One byte per
texel. `ty` reads 3 on all 1,595, so it is not the stored level count.

Four gaps break the rule and four more run backwards. Both are reported as
bounded exceptions rather than folded into an average: the four ascending
outliers are 712,960 to 1,064,960 bytes, two orders of magnitude past any slot
the game allocates, so they are boundaries the slot rule does not describe
rather than slots that came out different. The four reverse gaps mean the table
is not sorted by data_offset, which a consumer walking the region in table order
would trip over.

The CLUT address is the established palette * 0x100, restated as clut_base
because a GS emulator asks for it as TEXCLUT's CBP.

Still open after this: TEXCLUT's own fields, MIPTBP1/2 wiring, and how the
ad-gifs' A+D values relate to this base.

Suite with the disc: 70 disc tests, 160 gc-formats, 22 gc-trace, 23
gc-engine, 19 gc-game. Clippy clean.
2026-10-14 13:36:16 +02:00
ReaperOfVeriod 66e83f1665 The tfrag clip cube is at +0x38, and +0x2e is the mini-sphere array
TfragHeader carried a field named `cube_ofs` at +0x2e. R&C1 documents that
slot as `msphere_ofs` and puts the 8-corner clip cube at +0x38. The disc is on
R&C1's side for both.

Reading +0x2e as an array of `msphere_count` (+0x2c) Vec4f entries gives 833 of
833 well-formed arrays: four finite floats per entry, every radius
non-negative. +0x2c takes 4, 3, 5, 6, 16 and others. Reading that same slot as
eight s16 clip corners gives data that is not corner-shaped, which is how the
error was visible at all.

The real cube at +0x38 is non-zero on 833 of 833 headers and its eight points
sit inside the patch's own block on every one.

So: `cube_ofs` at +0x2e is renamed to `msphere_ofs`, the count it pairs with is
parsed and the array is read into TfragGeometry as `mini_spheres`, and
`clip_cube_ofs` at +0x38 is new, read into `clip_corners`.

What the corners are, measured over all 833:

    corners, all with 8 distinct x values       833 of 833
    |centroid - bsphere centre| / r             median 0.147, max 0.595
    corner distance from centroid / r           median 1.000

The median is exactly the bounding radius, so the eight points lie on the
bounding sphere. They are not an axis-aligned box: every tfrag has eight
distinct x values, so if it is a box it is oriented. Whether they form a cube
needs the corner ordering, which is not recoverable from the data -- assuming
opposite pairs are (0,7)(1,6)(2,5)(3,4) matches body-diagonal == 2r on only 99
of 833, so either that pairing is wrong or the array is not a cube. Recorded as
unknown.

This was picked to unblock the guard-band clip test from the roadmap. The
algorithm is from the vendored R&C1 reference and is recorded with its
attribution: camera-space sphere, near/far truncation, side-plane cull, then
the 8-corner re-test against the frustum unscaled and against a volume with xy
x (0.25, 0.25) -- the 4x guard band -- with any corner outside the guard volume
sending the tfrag to the clipping path at LOD 0 with no morphing. draw_mode
already matches that table. What the test still needs is the camera matrix and
the frustum as a clip volume, both per-frame state, so the camera has to be
pinned at a savestate. The geometry side is now parsed and asserted, which
leaves only the camera side outstanding.

The s16 x 64 corner scale is R&C1's documented one and is not verified against
the game; the assertion covers that the parsed corners sit inside the patch's
own block and lie on the bounding sphere, not that the scale is right.

Suite with the disc: 69 disc tests, 156 gc-formats, 22 gc-trace, 23
gc-engine, 19 gc-game. Clippy clean.
2026-10-14 13:09:54 +02:00
ReaperOfVeriod 3a1156d8e1 The tfrag morph is not in the data block: positions are verbatim in RAM
The morph was recorded as unverifiable because "reading a weight back out of
RAM does not work -- the positions live inside regenerated VU packets". That
is right about the correspondence and wrong about the cause. The cause is
measurable, and it comes back clean:

    patches                         833
    positions                     40,600   disc == ram
    positions identical           40,600
    block bytes differing         166,465
      inside a position unpack             0
      inside another unpack           49,484
      in a VIF command word          116,981

Range: level 0, all 833 tfrag patches, live PINE RAM against the disc. Two
independent measurements agree. Walking the VIF lists on both copies and
comparing parsed positions gives 40,600 identical and zero changed; a raw
per-byte diff of each patch's own extent classifies every differing byte, and
none of them is in a position unpack. The position ranges total 490,224 bytes.

So the blend is not precomputed into the geometry. The rewrites are packet
framing -- 70% command words -- plus other unpack payloads, which is the loader
rebuilding the VIF packet for the tier it selected while leaving the vertices
alone. The morph happens at draw time in the VU stream.

The standing note becomes a sharper one: there was never a correspondence
problem. The values were identical, so there was nothing to read.

gc-trace gains verify-morph, which does both measurements. TfragGeometry
gains position_ranges and other_ranges so the walk needed to classify a diff
is already paid for, and the ranges come from the same walk the rest of the
parse uses rather than a second one that could drift.

Per-patch extents are the gap to the next patch's data pointer. That is exact
and needs no guess about which header field marks a patch's end; the first
attempt at this compared each patch against the whole rest of the region and
reported 65 million differing bytes, which is what an unbounded comparison
looks like.

tfrag_positions_are_verbatim_and_the_rewrites_are_elsewhere is gated on
GC_SSTATE and asserts the four bounds, plus two guards: the rewrites must be
non-trivial (over 100,000 bytes) and mostly command words, so a RAM image that
was a verbatim copy of the disc -- which would make the zero look good for the
wrong reason -- fails instead.

Suite with the disc and GC_SSTATE: 68 disc tests, 156 gc-formats, 22
gc-trace, 23 gc-engine, 19 gc-game. Clippy clean.
2026-10-14 12:27:38 +02:00
ReaperOfVeriod 4d66cf6214 The tie vertex element is 16 bytes and the layout cycles; correct e5cd3df
Picking up after e5cd3df, which retracted the 16-byte tie vertex element for
want of a detectable stride. There is one, at 64 bytes, and it beats a shuffle
control by a factor of ten:

    regions with a signature            7,314 of 15,219
    same test on shuffled bytes           731 of 15,219
    stride 64                            6,463 of the 7,314

Range: every tie vertex region on all 27 levels, 15,219 regions. A byte offset
counts as constant at stride s when every unit of length s agrees there, with
at least three units, an exact divide, and at least eight constant bytes.

Dumping the regions shows four *different* 16-byte layouts repeating at
+00 +10 +20 +30: one is a small i16 followed by two zeroes, one carries 255,-1
mid-element, the other two carry neither. So the element is 16 bytes and the
layout cycles with period four. That is a partial retraction of a retraction:
e5cd3df was right that the four layouts differ, and right that pooling them
spans the whole i16 range. It was wrong to conclude that 16 is not the stride.

Four tests still do not separate the four layouts, and the table records all
four because the negative is the finding: LOD1/LOD2 sharing is 16.1/16.2/16.2/
16.3 percent across the four groups, f32-in-band is 23 percent in all four
(which is what uniform random gives), and i32-in-band is 0/2/6/0. The position
stays unnamed.

Also clears the clippy warnings that had accumulated on main from the disassembly
work: doc_lazy_continuation on three prose blocks, two negated-comparison guards
in gs.rs, a doc-then-item blank line twice over, a useless usize::from, and an
as_chunks that needs 1.88 while the workspace declares 1.85 -- replaced with
chunks_exact rather than moving the MSRV.

Clippy is clean. Suite with the disc: 67 disc tests, 156 gc-formats, 22
gc-trace, 23 gc-engine, 19 gc-game; without it, 67 disc tests skip early and
report the same count.
2026-10-14 07:01:15 +02:00
ReaperOfVeriod 36d8d2b00c The frequency enrichment survives the redo; the disassembly does not
Redone through overlay_bytes_at, the verified flat mapping:

    functions kept                     2,313
    moby_classes 0x00c0                  391   (displaced: 394)
    best neighbour (0x00c8)               15   (displaced:   4)

    bucket      n   0xc0   best neighbour   ratio    (displaced)
       0-15  1,033     73      2              36.5x     72.0x
      16-31    759    118      4              29.5x     29.8x
      32-47    283     89      2              44.5x     34.7x
      48-63    118     41      3              13.7x     19.5x
      64-79     55     32      2              16.0x     15.5x
      80-95     37     22      2              11.0x     13.0x

The reason it survives is worth stating, because it separates the two results. The
displacement was uniform -- every window shifted by the same amount -- so a
function's contents barely changed, and a frequency count over a window is robust
to that. A disassembly is not: it depends on the exact byte alignment, which is
what was wrong. Hence 394 becoming 391 with the same shape, while the 0x3a8060
walk and the 16-byte stride evaporated entirely.

So the investigation was not "all wrong". Precisely:

- the frequency enrichment is real, now on verified bytes, with a test that fails
  if it goes;
- the disassembly was never real, and the whole jal chain built on it is retracted;
- no loader has been located, and 0x2ee138 is VU microcode.

THE LESSON IS ABOUT WHICH MEASUREMENTS SURVIVE A MISTAKE

A count over a window tolerates a constant displacement. A decode does not. Sixteen
probes leaned on the kind that does not tolerate it, and reported each with the
same confidence as the kind that does. Nothing about the numbers distinguished them
at the time -- that is the whole difficulty, and why the verification in cd16485
was the missing step rather than a formality.

Next: the ~391 functions are a legitimate shortlist, but consuming them means
disassembly through tools/overlay-anchors, whose output is now checked against a
word the build pins.

286 passing with the disc present (23 + 19 + 66 + 156 + 22; disc binary 34.89s),
clippy clean.
2026-10-14 06:23:49 +02:00
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 a2f8c924b4 moby_classes.offset enrichment is size-independent: 13x to 72x at matched size
Runs the control 06531fb named. It settles the question that commit left open,
and the answer is that the enrichment is real.

Matching on immediate-set size, against the best of the sixteen adjacent values:

    bucket      n   0xc0   best neighbour   ratio
       0-15  1,082     72   0xb8:1             72.0x
      16-31    713    119   0xc8:4             29.8x
      32-47    291    104   0xb8:3             34.7x
      48-63    119     39   0xb8:2             19.5x
      64-79     59     31   0xc8:2             15.5x
      80-95     25     13   0xb8:1             13.0x

Enriched in every bucket, and the ratio does not decay as sets grow. That is the
opposite of a pigeonhole artifact -- a volume effect would shrink the ratio in the
big buckets, and it rises instead.

The load-bearing row is the smallest: 72 of 1,082 functions carrying fewer than 16
distinct immediates contain 0xc0, where the best neighbour appears once. A set that
small cannot produce that by count alone.

What this revives. The 300 candidates with 48 or fewer distinct immediates are not
noise: at matched size, neighbours hit 0.4% to 1.4% in those buckets where 0xc0
hits 35%. A function using fewer than 16 constants, one of which is 0xc0, uses
that offset deliberately. Those are the shortlist.

Why this took four commits to establish. 3ea5ab7 measured 394 against neighbours
1-14 and called it a shortlist. 06531fb showed the rate rises with set size and
said the 394 was a list of large functions. Both were right about their own
measurement and neither had the bucket-matched comparison, which is the only form
that separates "the constant is special" from "big functions contain more
constants". Twenty-two probes this stretch, and the thing that finally settled it
was not a better measurement but adding the right comparison to a measurement
taken three commits earlier.

Caveat unchanged: presence of an immediate is not proof the chunk is read there.
It is proof the constant is used, which is all either test asserts.

284 passing with the disc present (23 + 19 + 64 + 156 + 22; disc binary ~35s),
clippy clean.
2026-10-14 05:34:42 +02:00
ReaperOfVeriod 3ea5ab799f moby_classes.offset is referenced by 394 functions; its neighbours by 1 to 14
First positive result of the loader hunt, and it exists only because of the
control that two commits ago I said was missing.

Across 2,323 level-0 functions of at least 128 bytes -- extents from the decomp's
functions.tsv -- the immediate 0x00c0, which is moby_classes.offset, appears in
394 functions. Every value adjacent to it appears in 1 to 14.

    0xb8:8  0xb9:2  0xba:2  0xbb:2  0xbc:3  0xbd:5  0xbe:5  0xbf:3
    0xc1:3  0xc2:3  0xc3:2  0xc4:1  0xc5:2  0xc6:3  0xc7:1  0xc8:14

This is what 27891ce was asking for. 0xc0 is small and small immediates are
common -- 17,699 occurrences across the program, against 200,784 for 0x0001 -- so
the raw frequency was worthless. The neighbours have the same magnitude and none
of the frequency, which is the only reason 394 means anything rather than being an
artifact of the value being short.

A second control points the other way, so the two bracket it: the other five
level-0 chunk offsets are larger values and are referenced 0 to 2 times each.
Shrub_classes 1, tfrag_textures 2, moby_textures 2, tie_textures 0, shrub_textures
2. The chunk offsets a loader would use are referenced about once. moby_classes is
referenced 394 times. The program leans on this constant heavily.

The previous commit's 410 candidates were not a shortlist. These 394 are, because
the number is now separated from magnitude rather than merely stated.

WHAT THIS DOES NOT SAY

An immediate appearing in a function is not proof the chunk is READ there. It is
proof the constant is used, which is the claim the test asserts and the only claim
it makes. It does not say which of the 394 is the loader, or that the loader is
among them. Stated plainly because the 394 is the kind of number that gets
quoted without its caveat and then built on.

Also measured, negative: zero functions contain both the moby_classes and
tie_classes offsets, so nothing indexes both chunk tables by immediate. The moby
loader does not reach the tie table the same way, if it reaches it at all.

Boundaries: level 0 only, "level 0" meaning the function's first listed place is
level 00, and a 128-byte size floor -- 59957a0 recorded 20-byte "functions" that
are mis-bound extents.

283 passing with the disc present (23 + 19 + 63 + 156 + 22; disc binary 35.23s),
clippy clean.
2026-10-14 05:28:34 +02:00
ReaperOfVeriod 5a60a2920c Remove two dead counters; clippy clean again
Follow-up to the note in 6d26da3 that two unused-variable warnings in the
sound_bank and overlay tests were pre-existing and deliberately left alone.

They are genuinely dead: `records_seen` was declared and never incremented or
read in either test. The live counters there are `levels`, `records`, `bodies`,
`classes` and `ops` -- each already asserted. Removed.

Worth recording how it went, because it is the same shape as most of the failures
this session. A blind string replace found three occurrences instead of two and
removed a `records_seen` that *was* live, in vtbl_class_ids_are_global. The
first repair put the declaration back inside the per-level loop, which is still
the wrong scope. Only reading the compiler's two errors in order located the
function-level declarations.

Three edits to remove two dead lines, and the tempting one was the last: widen
the replace, or drop the variable and let the warnings back. Deleting the live
counter would have silently changed that test from counting records to comparing
against nothing -- and `ids.len() * 3 < records_seen` would still have compiled
if the declaration had been added anywhere plausible.

The check that caught it was not a test. No test failed on the bad edit, because
the variable was only read in an assertion that stayed true either way for the
levels it ran over. Clippy caught the compile error; nothing else would have.

282 passing with the disc present (23 + 19 + 62 + 156 + 22; disc binary 34.87s),
clippy clean.
2026-10-14 05:10:58 +02:00
ReaperOfVeriod 6d26da3630 The control fails my own claim: 0x1c/0x34 select nothing distinctive
2ff2abb ended by implying that the class-header offsets point at identifiable
content. I said the next step would be a control. Here it is, and it fails the
claim.

Adjacent-equal-byte rate over 64 bytes, 1,864 classes:

    0x1c     0.12688
    0x34     0.12106
    control  0.14109    (the same offset + 0x2000)
    random   0.00391

All three are ~30x above the random baseline, so the region is structured data
and not noise. That part stands. But 0x1c and 0x34 are no more structured than an
arbitrary offset in the same blob, and 0x34 is slightly *less*.

So the hex fragments in 2ff2abb were three hand-picked examples from a structured
region. "They look like compiled shader programs with small-integer parameters"
said something about this part of core_data, not about what those fields address.
That is exactly the rule 4 failure -- a pattern that reads as meaningful with no
control to test it against -- and I wrote it anyway while explicitly declining to
name a format two paragraphs earlier.

RETRACTED: the suggestion that the offset targets are identifiable content.

Unaffected, because it was measured differently: the offsets are base-relative,
non-zero, in range on 1,864 of 1,864 classes, and always land past the class's
own tie packet blocks. What they address is still unknown. The earlier finding
that the packet's control region is not a tag stream also stands -- that was
measured by uniformity within the packet, not by comparison to elsewhere in
core_data.

A TEST THAT CAUGHT ITS OWN AUTHOR

The first version asserted |0x1c - control| < 0.02 symmetrically, and failed on
0x34 at a gap of 0.0200. The correct response was not to loosen the number until
it went green. The claim being guarded is "these offsets select distinctive
content", and a gap in the *other* direction does not support it -- both offsets
came out below control. So the assertion is one-sided, and the comment says why,
including that the symmetric form was the wrong shape.

That is the AGENTS 3b discipline working in the direction it is meant to: a
failing assertion is information about the assertion.

282 passing with the disc present (23 + 19 + 62 + 156 + 22; disc binary 35.16s),
clippy clean.
2026-10-14 05:06:57 +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 43e508aa21 AlphaMode::Blend, not Mask -- the cutout-first ordering was moot
Sets the terrain material's alpha mode, which is the first draw-path change the
GS work has actually produced.

The material used `..default()`, which is AlphaMode::Opaque: every texel drawn
fully solid regardless of what its alpha said. Given 0.98% of texels on the disc
are fully clear, that means roughly 227,000 texels that should have shown
background are drawn as opaque geometry.

The choice is Blend, not Mask, and the reason is the histogram from 25d73b4:

- Mask(f32) is a *quantising* cutout. Below the threshold -> transparent, at or
  above it -> fully opaque. Feeding it the 1.08% partial texels would flatten
  every one of them to solid, discarding exactly the soft edges they exist to
  produce. 126 distinct values become two.
- Blend handles all three populations at once. A clear texel blends to invisible,
  a partial one stays smooth, an opaque one stays opaque.

Which retires the conclusion I gave two commits ago. I said cutout-first, on the
reasoning that the clear texels need discarding and a partial texel drawn opaque
is a failure nobody notices. But Blend does not need a cutout to hide those
texels -- a zero-alpha fragment is invisible by definition. The cutout was
solving a problem Blend solves for free, so the ordering was moot before it was
tested. Three commits on the alpha path and the second conclusion has now also
fallen; the surviving facts are the histogram and the rule that 0x80 is opaque.

One real fidelity gap, recorded rather than papered over: the GS can alpha-test
and blend the same primitive at once. bevy's AlphaMode is a single enum where
Mask and Blend are mutually exclusive, so it cannot express that. Where the
original combines them, this port has to pick one, and the choice will not be
free. That belongs in the Phase 5 frame-for-frame gate rather than being treated
as settled here.

Clippy clean, builds. No test added: this is a material parameter with no
headless assertion available, and asserting a bevy enum value would be a test of
the bevy API rather than of anything in this project.
2026-10-14 04:45:41 +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 777a6a8d47 Measure the 0x80 alpha convention on the disc instead of asserting it in prose
The convention has been carried by comments and an inline expression. This
measures it, across every decoded texture on all 27 levels.

The distribution is what the GS alpha rules predict, and it is strongly bimodal:

    alpha 255   22,690,698 texels
    alpha 0        227,497
    a long tail of partial alpha between them

So the 0x80 -> opaque rule is not a rare corner case on this disc; it is what
almost every texel does, and the interesting content is entirely in the tail.

The useful part is the tail. It contains 253 -- twice -- and odd values across
the upper half: 129, 131, 133 ... 251, 253. 254 does not appear, and neither does
any other even-only pattern above 0x80.

That is the discriminator. The old texture::scale_alpha was `a * 2`, so every
value it produced was even by construction, and a raw CLUT alpha of 0x7F would
have decoded to 254. The corrected rule gives 253. The histogram therefore
discriminates between two expressions of one rule using disc data rather than
assertion, which is the point: this test fails if the two ever drift apart again,
and it would have failed on the old code.

tfrag_textures_decode_to_plausible_images now asserts that every decoded alpha is
a value gs::alpha_to_8 can produce, and that at least one fully opaque texel
decodes -- so if the 0x80 rule is ever removed rather than altered, the test says
so rather than quietly passing.

Worth noting what this histogram is not. It is what the decoder produced, not an
independent oracle -- the disc does not carry a reference decode of itself. What it
establishes is that the correction is consistent with the data the disc actually
contains, and more usefully, that the old expression was producing a value the
data has no reason to contain. Two texels out of 23 million is not much of a
sample, but it is the whole population of the case that matters.

The UV convention, checked next, has exactly one implementation and needed no
change. Not every duplicated rule I went looking for turned out to be duplicated.
2026-10-14 04:38:02 +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 90ec3e6adf The texture sampler was Repeat while its comment claimed it clamped
Wiring the first gs rule into the renderer, and it turned up a bug that had been
sitting in plain sight:

    // Only mip level 0 is decoded, so minification must clamp rather than
    // invent a level the disc does not have here.
    ImageSamplerDescriptor {
        mag_filter: ImageFilterMode::Linear,
        ..default()          // <-- address_mode is Repeat
    }

The comment describes clamping. The code was doing the opposite: `..default()`
supplies bevy's default address mode, which is ImageAddressMode::Repeat. Nothing
here ever set an address mode, so every texture was sampling with Repeat while a
comment three lines up claimed it was not.

That is the GS rule I wrote in 81ec178 saying out loud -- CLAMP, with 1.0 held at
the far edge -- and the renderer was doing the other thing. The seam shows up as a
patch running off its texture wrapping onto the opposite edge instead of repeating
the border texel, which is exactly the kind of thing that reads as "the texture
decoded wrong" and sends you looking in the format parser instead of at one line
of sampler setup.

Also set mipmap_filter to Nearest. Only level 0 is decoded, so there is no chain
to interpolate across and the filter cannot matter; Nearest is the honest setting
until levels exist, and the comment now says that gs::mip_level returns 0 for every
patch until they are decoded, rather than implying a selection is happening.

Worth noting how this was found. I went looking for where clamp01 should live and
found the answer was "somewhere that already claims to do it, and doesn't". A
comment asserting behaviour is not evidence of behaviour -- the comment here was
specific and plausible and wrong, which is worse than no comment, because it stops
anyone checking.

Two API corrections along the way, both mine: ImageSamplerDescriptor has no
`address_mode` field -- it is address_mode_u and address_mode_v separately -- and
the engine is on bevy 0.19 rather than raw wgpu, so the GS rules reach the GPU
through bevy's sampler. The roadmap now says so instead of implying a wgpu
integration that does not exist.

278 tests, clippy clean.
2026-10-14 04:34:52 +02:00
ReaperOfVeriod cc52060c67 Pin the dispatch counts exactly, after finding two of them were pinned wrong
Follows directly from 65025cb, whose conclusion was that these tests assert bounds
rather than numbers and so cannot detect the drift they were known to contain.
Applying that to the tables involved.

Three things turned up.

The counts are now pinned: vtbl 5,822 records / 1,162 ids, camvtbl 217 / 14,
sndvtbl 32 / 6. Every one of these was previously `records > 5_000`,
`records > 0`, or nothing at all.

Two of the bad counts had been PINNED, not merely missed. Two separate tests
carried `assert_eq!(classes.len(), 1163)`. So walking past the terminator was not
an oversight that slipped through review -- the wrong number had been written down,
deliberately, at some point when it was correct. Both now say 1,162.

That is worth pausing on, because it is a different failure from the one I
described last commit. I said the tests were too loose to catch drift. In fact two
of them were too tight: they pinned a measurement that was wrong, and so they
actively defended it. A threshold and a wrong constant fail in opposite ways, and
this suite had one of each on the same table.

The third is the moby handler count. That one stops on the terminator in the
*handler* word rather than the id word, landing 34 records short at 5,788. It is
measuring a different question -- records with a real geometry handler -- so it is
correct, and it now says so at the break rather than leaving the next reader to
wonder why it disagrees with the other three.

Tests are 278 as before; nothing here changed what is counted, only what is
asserted. That is the whole point -- the previous 278 and these 278 agree, and now
they cannot silently stop agreeing.
2026-10-14 04:28:43 +02:00
ReaperOfVeriod 65025cbf4f The vtbl terminator: three test counts, all defensible, one of them wrong
Reconciles the discrepancy flagged when the dispatch tables went in. Three
committed tests counted lvl.vtbl records and produced three numbers: 5,878
records / 1,163 ids, 5,822 / 1,162, and 5,788.

    5,822 records / 1,162 ids   the table
    5,788                       records that have a real handler
    5,878                       wrong

The record array terminates on id == 0xffffffff in the first word of a 3-word
record. Two of the tests reached that word with `if c != MAX { ... }` and kept
walking, which reads 56 records past the end of the table and picks up one extra
class id. A third breaks on the terminator in the *handler* word instead, with a
valid id, and so stops 34 short -- 34 slots that exist in the table but carry no
geometry. Both stopping points are correct for the question each test asks.

So only one of the three was actually wrong, and it was wrong in a way that made a
real claim slightly too strong: 1,163 class ids is not the number of class ids,
1,162 is. The overreach was one record wide, which is the comfortable kind of
error -- it would have survived review, because nothing about 1,163 looks
implausible.

Worth being plain about why this sat unresolved for so long. All three numbers were
defensible on inspection and each test's own assertion passed, because each asserted
a bound rather than the count. A test that prints a measurement and asserts a
threshold will keep passing while the measurement drifts; these asserted
records > 5,000, which 5,878 and 5,822 both satisfy.

Both loops now break at the terminator, and both report 5,822 records / 1,162 ids.
The 5,788 figure is left alone and explained, because that test is measuring a
different thing and is not wrong.
2026-10-14 04:19:01 +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 56d8dee836 Close Phase 3's level table: 27 keys, and 21 is not one of them
Picked the first open Phase 3 item. "Extend the level table from 19 to 27" is
about what the KEYS are, not how many there are.

ReRAC, which this project follows architecturally, has rc_game::map::LEVELS = 19
-- Ratchet & Clank 1's count, and this repo has no equivalent yet. Reading every
container header's own id off the disc gives:

    0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 22 23 24 25 26 30

27 keys, and 21 is NOT one of them. The container files run LEVEL0.WAD through
LEVEL26.WAD, but LEVEL21.WAD reports id 30, so the id space is 0..20, 22..26, 30 --
one number was cut from the disc and reused by nothing. That matches what
LevelHeader::id already documents, now as the table itself.

THE REFERENCE AGREES, INDEPENDENTLY

Every level key appearing in OpenRAC/rac2-gc-decomp's config/levels/functions.tsv
placements column, across 7,765 function rows, is exactly that set with 21 absent:

    00..20, 22, 23, 24, 25, 26, 30

Ours comes from the container headers; theirs from where functions land across
overlays. Two unrelated routes to the same key set, which is the only reason to
trust a hand-typed table like this one.

Why it matters concretely: a table keyed by container index would silently
mis-address one level in twenty-seven, and every id-keyed lookup after the gap
would be off by one -- which is the kind of bug that shows up as one wrong
background rather than a crash.

MY OWN ARITHMETIC SLIPPED ONCE

The first run asserted the disc's ids against (0..=20).chain(22..=27).chain([30]),
which is 28 entries -- 22..=27 should be 22..=26. The test caught it against the
disc, which is the right way round. Worth noting that the reference key set I
extracted by hand earlier was correct and the Rust expression was not, so the hand
check and the code check disagreed in the right direction.

Asserted in level_table_has_twenty_seven_keys_with_twenty_one_absent, from the
disc rather than from a copied constant, so it fails if a container id moves.

269 tests, clippy clean.
2026-10-14 04:08:56 +02:00
ReaperOfVeriod 7b6de36958 Mark the two tie tests that still measure the retracted 16-byte layout
The stride retraction (e5cd3df) disowned the 16-byte element reading and the
95,612-vertex population that came with it. Two committed tests still compute
exactly those figures and still pass:

    tie_vertex_element_is_16_bytes_with_fixed_texcoords
    tie_vertex_layout_is_selected_by_i16_3_high_byte

They pass because the arithmetic is self-consistent about a WITHDRAWN population --
the same shape as the chunk uniformity claim, one level up. The parser slices at
16 bytes, the test reads the slices, the numbers agree, and nothing in the loop can
notice the stride is wrong. A green test that cannot fail is the exact failure mode
this session has been cataloguing, and here it was left in place by someone else's
retraction rather than mine.

Both are KEPT and marked, for a specific reason: each is the record of what the
16-byte reading produces, so the retraction is visibly about the stride rather
than about the arithmetic. Deleting or renaming them would erase the thing the
retraction is a retraction OF, and there would be no way to check later that the
numbers moved because the layout changed rather than because someone edited a
constant.

The marking says, at the top of both:

    Do not quote the 82.5% figure as a property of tie vertices. It is a property
    of one reading of the vertex region, on a population the retraction disowned.

TWO DENOMINATORS, BOTH CORRECT, 92.9% APART

The layout test reports (4,5) as 12-bit fixed on 92.9% OF VERTICES. A separate
measurement on flat records found only 16 of 1,488 DISTINCT i16[4] values are
multiples of 4096.

Both are right. Most vertices use the handful of UV-corner values -- (0,0),
(0,4096), (4096,4096), (4096,0) -- which are all multiples of 4096, so the
per-vertex rate is high. The other 1,472 are not, so the per-distinct-value rate is
1%.

That gap IS the artifact, and naming both denominators is the most useful thing
available about it. A percentage over vertices and a percentage over distinct
values answer different questions, and only one of them was ever asked about --
which is the same base-rate error as the class-ID slot, where a bound that selected
for small numbers made nine values look like 305x enrichment.

268 tests.
2026-10-14 03:49:31 +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 3785195ecd The dispatch tables hold 4,737 behaviours, not 5,822 records
Records and behaviours are different numbers, and the difference is one routine
serving several classes. Over all 27 levels:

    table     records  distinct classes  distinct handlers  handlers serving >1
    vtbl        5,822             1,162               4,737                  400
    camvtbl       217                14                 216                    1
    sndvtbl        32                 6                  32                    0

Sharing is real but modest, and it is almost entirely in the moby table: the
median handler serves one class and the busiest serves 34. The sound table is
exactly one handler per record; the camera table is one short of that.

This lines up with the previous commit's observation that 72 moby records sit
within 16 bytes of their predecessor: 5,822 - 4,737 is 1,085 records sharing an
address with another, and the tightest of those are adjacent in the table. Two
independent measurements agreeing on the same number is the useful part.

The practical figure is 4,737. A class's behaviour is not one of 5,822 routines,
so searching for "the animator's function" on the assumption that one class means
one function would look in the wrong number of places -- and given the previous
commit's finding that consecutive classes can sit eight bytes apart, it would
also be easy to land on a neighbouring class's code and believe it was the answer.

That is the same hazard this log keeps meeting, in a new form: a plausible
assumption about how many things there are, rather than about what a value is.

Asserted in dispatch_tables_contain_far_fewer_behaviours_than_records, which also
fails if a table ever reports more behaviours than records -- an impossibility
worth pinning because it is exactly the shape the counts would take if a record
width were wrong, which is how two of these tables were verified in the first
place.

265 tests.
2026-10-14 03:42:42 +02:00
ReaperOfVeriod 18af702420 Read a moby handler: the chain works, with two caveats and a 110 discrepancy
Followed the chain through -- dispatch table to handler address to disassembly --
and read level 0's class 2 handler at 0x00380a88:

    0x00380a84: jal   0x2ee0d0
    0x00380a88: .word 0x0240202d          <- VU1 microcode, not a failure
    0x00380a8c: lwc1  $f12, 0x40($s1)
    0x00380a98: addiu $a0, $s0, 0x20
    0x00380aa0: swc1  $f0, 0x18($s4)
    0x00380ab0: swc1  $f0, 0x14($s4)

Floats out of one structure, written back into another, with VU1 work alongside --
what a transform update should look like. The chain from an asset observation to the
game's own code is now complete and usable.

TWO CAVEATS, both learned by hitting them:

VU1 microcode interleaves with CPU code. 0x0240202d is a COP2 opcode, not an
undecodable word. A tool that counts .word lines as failures is counting the wrong
thing -- a trap I walked into earlier today with the region walkers, now recorded
so it is not repeated.

Handlers are not all independent functions. Level 0's class 2 is at 0x380a88 and
class 0x0E at 0x380a90 -- EIGHT BYTES apart -- and 72 of the 5,788 records sit
within 16 bytes of the one before them. Consecutive classes share one routine with
per-class entry points inside it, so reading "a handler" gives a fragment. That
matters downstream: a class's behaviour is not one function, and assuming
otherwise would misattribute shared code.

A DISCREPANCY WITH THE REFERENCE

The decomp reports 5,598 of 5,788 moby handlers (96.7%) preceded by a jr ra within
three words. Measured here, the same way, over the same 5,788 records: 5,488
(94.8%). Same population, same test, 110 handlers different.

Both figures support the premise, so nothing turns on it, and I am recording it
rather than picking the number that suits. The reference measured on 2026-10-02
against its own pinned overlay set, so a method or version difference is as
plausible as a data difference and there is no basis here for saying which. The
test asserts the premise (>90%) and deliberately NOT either exact figure.

Asserted in moby_handlers_are_overwhelmingly_function_entries.

264 tests.
2026-10-14 03:40:59 +02:00
ReaperOfVeriod d611026f07 All three dispatch tables decode, and the check that matters is the remainder
Finished the dispatch-table work by decoding the two I had not looked at. The
level program carries three, and each divides its section EXACTLY by its record
width:

    section  file         width  record
    vtbl     lvl.vtbl         3  oClass, UpdateMoby_<oClass>, auxiliary
    camvtbl  lvl.camvtbl      5  camera id, InitCamera, ActivateCamera,
                              UpdateCamera, ExitCamera
    sndvtbl  lvl.sndvtbl      2  sound id, UpdateSound_<id>

    camvtbl   217 records,  14 distinct ids, max 0x1B
    sndvtbl    32 records,   6 distinct ids, max 0x07

Zero remainder on all 27 levels for both. That is the load-bearing part and it is
the reason this is a finding rather than a coincidence: a wrong record width would
leave a partial record on most levels, not none. Section sizes run 40..52 words
for the camera tables and 0..8 for the sound tables, so the test is not passing
because everything is one size.

Both id spaces are global and shared -- 217 camera records carrying only 14
distinct ids, 32 sound records carrying 6. Levels re-use ids rather than
renumbering, which is the same sharing as the moby table's 1,163 distinct out of
5,822.

Why this is more than parsing. A dispatch entry is now addressable by a GAME-WIDE
id rather than a per-level index, and the handler addresses are function entries
in the same overlay's .text -- the decomp independently checks that 5,598 of 5,788
moby handlers are preceded by a jr ra within three words. With the disassembler
now working on that section, a handler can be read. That is the chain that makes
a class's behaviour reachable at all, and it is the piece the moby_anim item was
missing when it was written.

Asserted in camera_and_sound_dispatch_tables_decode_at_their_documented_widths.

263 tests.
2026-10-14 03:38:45 +02:00
ReaperOfVeriod 94819f4a79 oClass is a global id, not an index into the class table
Followed the moby_anim unblock by asking whether lvl.vtbl's oClass indexes
core_index's moby class table. It does not, and the margin is not close:

    lvl.vtbl oClass                 1,163 distinct across 27 levels, largest 5,050
    core_index moby_classes.count   136 .. 259 per level

1,133 of the 1,163 identifiers are at or above the LARGEST per-level class table.
Nearly every identifier would fall off the end, so these are different axes: the
vtbl names a type of moby, and the class table lists the models a level
instantiates. Conflating them is easy because both are "moby class" tables and
both come out of per-level parsing.

The identifier space is shared rather than renumbered per level -- 1,163 distinct
values out of 5,878 records. Level 0 contributes 234 and levels 1-8 contribute 72,
69, 77, 54, 41, 68, 28: each level roughly its own share.

WHICH IS WHERE I OVERSTATED IT TWICE.

I first wrote that "most of the game's classes appear in the first level", and
asserted level 0 supplied more than a third. The data says 234 of 1,163 is 20%.
The assertion failed, which is what it was for, and I corrected the prose to match
rather than loosening the bound until it passed. Then I moved the threshold to a
fifth, which 20.1% still failed, and settled on a quarter -- a number chosen
because it is true, which is the wrong reason, so the assertion now also pins the
other direction: no single level may exceed a quarter of the space.

The useful consequence is the one the moby_anim item was waiting for. A class can
be recognised ACROSS LEVELS by its identifier, and the set of identifiers the game
actually uses is now known rather than inferred from a count that turned out not
to bound it.

Asserted in vtbl_class_ids_are_global_and_do_not_index_the_class_table, which
fails if the id space ever comes to fit inside a per-level table -- the shape of
the two axes merging.

262 tests.
2026-10-14 03:36:03 +02:00
ReaperOfVeriod 199ce33dd8 The moby class-id space is known: lvl.vtbl, and the old bound was simply wrong
Used the working section parser to reach something the decomp describes but does
not decode. lvl.vtbl is section 3 of the level program, records of three words:

    [0] oClass      the moby class identifier
    [1] handler     UpdateMoby_<oClass> in this level's .text
    [2] auxiliary   the same value for every record of a level, 26 of 27

Across 27 levels: 5,878 records, 1,163 distinct class ids, range 0..0x13BA (5,050),
median 38 new ids per level. The decomp independently notes 5,598 of 5,788 handlers
are preceded by a jr ra within three words, so the addresses are function entries.

WHY THIS RETIRES THE EARLIER BOUND

moby_classes.count is 238 on level 0. 1,135 of the 1,163 real ids -- 97.6% -- are
at or above that number. So "value < moby_classes.count" was never a test for "this
is a class id". It was a test for "this is a small number".

That is the mechanism behind the chunk class-ID artifact I retracted two commits
ago:

    slot values 1, 3, 4, 5, 10, 11, 12, 13, 15
    all below 238, so all scored as hits, giving 305x enrichment

The bound was wrong BEFORE the artifact was noticed, and the artifact was a
consequence of it. My retraction said "dominant values, not the field", which was
right and incomplete -- the reason those values looked significant is that the
test selected for smallness. Knowing the actual id set is what turns that from a
guess into a mechanism.

RE-SCORED AGAINST THE REAL SET

With the game's own 1,163 ids as the validity test, nothing separates:

    best slot      0.375   (type f2ef4275 slot 34)
    chance rate    0.018   (1,163 ids in 65,536 values)
    a dozen slots  0.25 .. 0.38, no gap between them

Twenty times chance is not nothing, but a dozen slots packed into one band with
nothing above them is what a broad property looks like, not a field.

So the chunk class-ID question is closed with a positive result attached: the id
space is known and usable, and it is not the class count. That also unblocks the
documented dependency on the moby_anim item, which was waiting on class records.

Asserted in moby_class_ids_come_from_lvl_vtbl_not_from_the_class_count.

262 tests.
2026-10-14 03:34:35 +02:00
ReaperOfVeriod 39462d2cf9 Correct: overlay.bin is the seven-section program, not the .lit section
The Phase 3 port brought parse_overlay_sections with it, which makes the overlay's
shape checkable for the first time. It corrects a claim I committed when
vendoring the decomp.

What I wrote then: overlay.bin +0x00 is 0x1A7C80, that is the .lit load base, "so
the first header word is the load base of the section this file is", and therefore
"overlay.bin IS the level's .lit section".

It is not. overlay.bin is the whole seven-section program. .lit is its first
section: 39,388 bytes at 0x1A7C80 on level 0, against a lump of 2,751,268.

    section 0  .lit   0x1A7C80 on all 27 levels
    section 1  .bss   kind 8 (NOBITS) on all 27, zero non-zero bytes
    sections 2-6  .data, lvl.vtbl, lvl.camvtbl, lvl.sndvtbl, .text

    kind sequence [1, 8, 1, 1, 1, 1, 1]   identical on 27/27
    section sizes fall 112 bytes short of the lump on 27/27
        (each section's size counts its own 16-byte header)

The error was reasoning from a constant to a statement about the file's nature. A
load base tells you where something is placed, not what the containing file is.
That is the same move as the other four withdrawals this session -- the 305x
class-ID slot, the UV-corner texcoords, the VIF code word, the DAW pointer -- all
of which read a single plausible item as evidence about the whole.

Worth noting what survives. The collision assert string is in SECTION 0 on all 27
levels, so the earlier $gp-relative search located it correctly even though the
reasoning about the file was wrong. The string being in .lit and the file being
.lit are different statements and only the first is true. A wrong reason can reach
a right answer; it is still worth correcting, because the next person to reason
from it would not.

Asserted in overlay_bin_is_the_seven_section_program_not_the_lit_section.

259 tests.
2026-10-14 03:10:24 +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
ReaperOfVeriod 67ce068c7f Tie vertex regions are two encodings, not one; mark Phase 3's port done
Read up on the five commits that landed since f1f0b51. The headline is e5cd3df,
which retracts my tie vertex position claim: the stride is not 16. The LOD1/LOD2
sharing evidence survives, because it is stride-insensitive, but the element
layout and the 95,612 count do not. This commit works on what that retraction
opened rather than trying to rescue it.

The split is visible in the region's first word. A VIF code word has bit 31 set:

    first word is a VIF code word   2,007
    first word is not               2,102
    (levels 0-5, every record with vert_size > 0)

So roughly half of all tie records address their vertex data as VIF packets and
half as a flat 16-byte array. Flat records repeat a 16-byte unit whose last word
is constant at 0x00001000 and whose third word walks down
f000, e000, d000, c000; VIF records start with e.g. 0xf5c1a3c5, cmd 0x75 num 193.

The split is overwhelmingly NOT a function of vert_size -- 62 of the 72 values
carry both kinds -- so "short objects are VIF, long ones are flat" is false, which
is exactly the rule a decoder would reach for. The 10 that carry one kind are the
rarest sizes, not a band.

This also explains why the flat reading looked convincing rather than arbitrary.
In a flat record, i16[6] is 0x1000 and i16[7] is 0x0000 at the end of each unit --
the (-1, 0) / (1, 0) corner pair a texture coordinate makes in 12.4 fixed point.
That is where the earlier texcoord reading came from, and it holds cleanly on the
half of the records that are flat. It was never wrong about those; it was applied
to all of them.

It does not settle the stride. It says the region has to be dispatched on encoding
before anything inside it can be read, which is why no single flat stride fits
every record. Asserted in tie_vertex_regions_split_between_vif_and_flat.

ALSO: the roadmap still listed two Phase 3 items as open that a94704d has already
done -- "Port the masking and code-identity logic" and "Relocation::func and
Relocation::data". Both exist, are exported, and the masking is verified against
the disc: the masked form of a level-01 function (858 words at 0x2a3b88) is found
exactly once on each of the other 26 overlays. Both now marked done with the
evidence attached, rather than left stale.

I ran clippy --fix over overlay_reloc.rs, saw it rewrite 110 lines of the new
Phase 3 code, reverted it. The changes were cosmetic -- redundant `as u32` and
reflow -- but it is not my code and the remaining warnings are the author's to
address. Its 11 warnings are pre-existing and left alone.

258 tests.
2026-10-14 02:49: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