227 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 20655c7744 Stop pattern-matching RAM: follow UpdateCamera_6 from lvl.camvtbl
Two more frustum searches, both unproductive, then the lookup that should have come
first.

Non-orthonormal 4x4 matrices within 4 KB of the camera, reasoning that the
projection must live nearby: 79 candidates, every one a sliding window over data
rather than a matrix. The filter does not discriminate and the region has no
obvious projection.

Then: the decomp names the camera. include/engine.h and
docs/AUG6-RETAIL-ANCHORS.md reference UpdateCamera_6, InitCamera_24 and
Camera_CollPrimTest, and docs/COMMUNITY-ENGINE-REFERENCE.md places lvl.camvtbl at
0x0026E900. The camera dispatch table is already characterised -- 217 records, 14
distinct ids on level 0, handlers in the overlay .text.

That is the right way in. The camera writes its matrices at 0x18aa70; the function
that does so is reachable from camvtbl, and the frustum is whatever that same
function writes nearby. Reading it would confirm m4 by finding the write site, and
pick up the frustum constants beside it, without guessing at float patterns.

The caveat that applies first, and why this was not done first: the decomp's
function addresses have already failed once here. PROVENANCE.md records that they
do not resolve for the moby class loader, while struct layouts transcribed from the
decomp were usable. So UpdateCamera_6's address must be verified against the
overlay before following it, not trusted from the name.

Resuming here is a different kind of task from the three searches above: follow a
known call site instead of pattern-matching a 32 MiB heap.

294 passing, clippy clean.
2026-10-15 03:32:18 +02:00
ReaperOfVeriod ca42793e48 Frustum search: bounded with a proper filter, not found
e7eec01 located R and the camera; the remaining operand is the frustum. Searched
with the same shape of filter that found the camera -- structural invariant first,
differential second.

First attempt discarded: scanning for a perspective-matrix signature returned
11,846 hits, which is noise and means the filter was worthless. Recorded because
the number looks like a result.

Second attempt, the right filter: unit-length 3-vectors at 16-byte alignment, the
invariant that identified the camera matrices. 9,514 found, 188 forming clusters of
four or more. The six-vector cluster at 0x189e20 is a frustum's exact shape -- six
planes, 96 bytes -- and it is not one. It reads as three orthonormal basis vectors
plus a zero affine row at 0x30 stride, with a translation at 0x189ea0 near the
camera: a table of positioned things.

So the frustum is not located, and I am not picking the six-vector cluster because
six is the number a frustum has. That is rule 4's failure with an exact number
attached to make it feel earned, which is what I have already committed twice in
this investigation.

What the search did establish: the structured regions near the camera are the nine
matrices at 0x18aa70, orientation records at 0x189e20, and larger clusters at
0x1793f0 and 0x18b260. The frustum is not among them as planes.

Which points at the next search, and it is a different one: guard_band's signature
is Frustum { near, far, tan }, not six planes. If the frustum is stored as
near/far/tan scalars, the search should be for a stable float pair with a
plausible ratio, not for normals at all.

294 passing, clippy clean.
2026-10-15 03:31:19 +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 6e5486de88 Docs audit: nine stale class-ID counts left behind by the terminator fix
Checked the docs for claims this session retracted. Found a real one: the vtbl
terminator fix (cc52060) corrected the distinct class-id count from 1,163 to 1,162,
and nine places in the format spec and two in the roadmap still said 1,163.

    docs/formats/core-data-regions.md   9 occurrences
    docs/roadmap.md                     2 occurrences

The reason they survived is the same shape as the vtbl bug itself. The test was
fixed and the new number asserted, but the prose kept the old one, and the prose is
what a reader takes away. A commit that corrects a measurement and does not correct
the document it was measured in has only half-landed.

Checked the numerators before touching them rather than blanket-replacing: 1,135 of
the ids are at or above level 0's moby_classes.count, and 1,133 are at or above the
largest per-level table. Both were already correct -- only the denominators were
wrong -- so a find-and-replace across the number would have corrupted two correct
figures. The tests confirm:

    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

Left one deliberate occurrence of "1,163" in the spec, in a note explaining that the
figure was 1,163 until the terminator fix and why, so the correction is traceable
rather than looking like a typo.

293 passing with the disc and savestate present, clippy clean. Documentation only.
2026-10-15 03:15:02 +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 1aa94ee288 0x152c70 rejected as the camera; the savestates are not consecutive frames
Ran the cheapest confirmation from a991088 on the candidate:

    [247.000, 194.000,  49.890]  step   0.000
    [293.960, 411.810, 115.000]  step 232.133
    [506.306, 311.614, 116.656]  step 234.804
    [300.170, 227.480, 128.000]  step 222.933

Rejected. A camera between consecutive frames moves a little; this moves ~230 units
per step and is not monotone on any axis. It is per-frame state but not a camera
position. a991088 wrote the prediction down before the measurement, so the
candidate is out rather than reinterpreted.

The more useful consequence: these four savestates are not consecutive frames, so
smoothness cannot filter the 2,005 candidates until consecutive frames exist.

Both cheap confirmations need something the savestates do not provide -- consecutive
frames, or an actual camera perturbation. That moves the camera hunt from offline
to needing a PCSX2 PINE session, the same category as the tfrag morph.

Worth noting the shape of it: the differential produced 2,005 candidates and the
confirmation reduced them to zero in one step, because the confirmation was
written before the measurement was interpreted rather than after.

286 passing with the disc and savestate present, clippy clean.
2026-10-15 03:04:33 +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 d4aeb94616 Extent check inconclusive; the opcode field itself is not understood
Tried to answer whether the decomp extents land on CPU code by checking for a
prologue. Only 29 of 3,835 matched 'addiu $sp,$-N' or 'lui $gp' -- but the other
bucket is full of textbook prologues my two patterns miss, because PS2 leaf
functions save registers with no frame adjustment:

    0x2a4ef0  ffb00000 ffb10008   sw $s0,0($sp); sw $s1,8($sp)
    0x2a48f0  3c013f80 44816000   lui $1,0x3f80; bc1t

Not reading a result into that.

Separately, ffb00000 has major opcode 0x3f, unused in MIPS32, yet is
unmistakably a register-save prologue and ffbf0020 for 'sw $ra,0x20($sp)' appears
throughout this code. Either the EE encodes sw outside MIPS32 or I am reading the
opcode field wrong somewhere. Not resolved -- and it undercuts every opcode-based
check in this investigation, the 391 and the neighbour ratios included.

Position after twenty-one commits: the mapping is verified, two targets are VU
microcode, and the candidate windows are not established to be CPU code. What this
needs is one authoritative disassembly of one extent compared against
tools/overlay-anchors, to establish what the opcode field means. That is the same
mistake one level down from the section-indexing bug: using a decoding never
checked to produce results, then measuring those results with the same unverified
decoding.

286 passing with the disc present, clippy clean.
2026-10-14 06:28:26 +02:00
ReaperOfVeriod 91b09955c8 The shortlist points at VU microcode; the decomp extents are unverified
391 candidates on correct bytes, 73 under 16 immediates. The densest, 0x2dca20,
164 bytes with three distinct immediates, disassembles through tools/overlay-anchors
as:

    xori.b $w0, $w0, 0xa2
    ext    $v0, $s0, 0, 1
    addu   $a2, $v0, $a2

xori.b and ext are VU1 mnemonics and three-operand addu is not MIPS32. Capstone is
decoding COP2: 0x2dca20 is VU microcode. Same answer 0x2ee138 gave.

That calls the frequency result into question more seriously than the displacement
did. The measurement filtered on opcodes excluding COP2, so VU cannot explain the
391 counts directly -- but the function extents come from the decomp's CPU
analysis, and if an extent spans a VU region the window scanned is mixed and the
immediate set attributed to that function is not a function's.

Status: the enrichment is real as a measurement of those windows; what the windows
are is now doubtful.

Next: verify decomp extents land on CPU code before trusting any of them -- decode
a sample and require a plausible prologue. Same shape as the cd16485 check, and the
fourth time a 'real shortlist' has needed one more verification first.

286 passing with the disc present, clippy clean.
2026-10-14 06:27:05 +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 4a826997f6 Redone on correct bytes: the disassembly chain is gone, 0x2ee138 is VU microcode
Re-read through overlay_bytes_at, the verified flat mapping from cd16485.

0x2ee138 is VU1 microcode: five COP2 instructions and a jr $ra stub. Nothing you
jal into, so the jal read from 0x3a3d70 cannot be right either -- and that
instruction was itself read from displaced bytes and must be re-read.

0x3a8060 is not what cee17c9 said. On correct bytes there is no addiu r17, r18,
0xc0, no 16-byte-stride walk and no function prologue; the first word is opcode
0x1f, which is not a MIPS user opcode. cee17c9's central finding does not exist.

State: overlay_bytes_at verified; 0x2ee138 is VU microcode, verified; the 0x3a8060
walk retracted; the frequency and neighbour-enrichment numbers not re-measured and
not quotable as exact; the e1482ab jal chain unverified, built from displaced bytes
and pointing at now-known VU code.

What is left of moby_anim is nothing. No loader located, one frequency result to
redo from scratch on correct bytes, and no reason to expect it to survive.

285 passing with the disc present, clippy clean.
2026-10-14 06:20:27 +02:00
ReaperOfVeriod e6b3e0b091 Root cause: the per-section indexing used throughout this investigation was wrong
The verification f2d7e7e asked for. Reference case: the tool disassembles
0x2ee138 as 0x4bc00083; reading the flat overlay at 0x2ee138 - LIT_BASE gives
83 00 c0 4b, little-endian 0x4bc00083. Match. Correct mapping is
off = ram - LIT_BASE indexed into the whole overlay.bin.

What I used in every probe: off = addr - secs[6].dest indexed into secs[6].data,
and OverlaySection::data includes the 16-byte section header. Every read was
displaced.

Invalidated:
- cee17c9's addiu r17, r18, 0xc0 disassembly, the 16-byte-stride walk, and the
  'address not float' conclusion. Built on wrong bytes.
- the immediate-frequency and neighbour-enrichment numbers in 3ea5ab7 and
  a2f8c92. Scanned per-function windows, so uniformly displaced: ratios are
  probably robust, exact counts are not measured on the bytes they claim.
- e1482ab, d97e7df, f2d7e7e: the jal decoding was verified correct, so branch
  targets stand; what lives at them was read wrongly.

Survives intact: every chunk-offset measurement in the tie and overlay work,
which read core_data and the overlay flat and never went through the section
index.

Fix: never index secs[n].data by addr - secs[n].dest. Either use
overlay[addr - LIT_BASE], or add a helper that accounts for the 16-byte header so
call sites cannot forget it -- and verify it against a word the decomp pins, as
above.

This was available from the first probe and skipped sixteen times.

284 passing with the disc present, clippy clean.
2026-10-14 06:14:14 +02:00
ReaperOfVeriod f2d7e7ecd4 d97e7df was wrong: my section offset was wrong, not the decoder
Ran gc-overlay-anchors on 0x2ee138 as d97e7df directed. It reports 6 of 12 words
did not decode -- but the words disagree:

    tool:    0x2ee138  0x4bc00083
    my read: 0x2ee138  0xd8a10000

Different bytes at the same address. My offset into the section is wrong, so the
'opcode 0x36 is invalid' finding was about bytes never at that address. That
invalidates d97e7df's conclusion and its 'second decode error' framing: the
decoder was never the problem.

The tool's words are COP2 (0x12), VU1 vector-unit instructions, which capstone
will not render under plain MIPS32. So its 'did not decode' is expected for VU
code and says nothing either.

Four bugs in this investigation, all mine: un-masked branch target, transposed
address, invalid opcode read from wrong bytes, and the offset mapping itself.

Every one was a self-consistent probe reporting a clean number. The check that
catches all four is verifying the tool against a known-correct case before using
it to generate a finding -- which d97e7df recommended and I then skipped.

Nothing about 0x2ee138 is now known: not VU code, not data, not a function. The
address is the correctly decoded jal target; what is there is unestablished.

Next step is to fix and verify the address-to-overlay mapping before re-reading
anything. Until then no address computed by hand in this investigation is
trustworthy, including the 0x3a8060 walk that happened to decode.

284 passing with the disc present, clippy clean.
2026-10-14 06:01:54 +02:00
ReaperOfVeriod d97e7df41f Second decode error in the same investigation; stopping rather than trusting this decoder
Following e1482ab I disassembled the function supplying r2 and made two errors,
both mine. First I read the jal target as 0x2ee178 when it is 0x2ee138 --
0x0bb84e << 2 is 3,072,312. Second, at the correct address the bytes do not
decode: d8a10000 is opcode 0x36, which is not a valid MIPS major opcode for
user-space code. The coherent readings are that this is data, that the decomp's
extent is mis-bounded, or that the jal target is still wrong.

Stopping here deliberately. An un-masked branch target that reported a false
negative, and a transposed address that produced plausible-looking garbage, are
enough to make anything else from this decoder untrustworthy. The next step is not
another immediate scan or another hand-decoded disassembly -- it is
tools/overlay-anchors, which links capstone, verified against a known-good
function first.

The established chain, with nothing inferred beyond it:

    0x3a3d70  jal   0x2ee138     target decoded, callee unidentified
    0x3a3d84  jal   0x3a7c30     r2 = return value of the above
    0x3a7c30                     580 bytes, never writes r2
    0x3a7d88  jal   0x3a8060     passes r2 through
    0x3a8060  addiu r17, r18, 0xc0  with 0xd0 and 0xe0; 16-byte-stride walk

What 0x2ee138 returns is unknown, and it is the single remaining link between the
program and moby_classes.offset.

284 passing with the disc present, clippy clean.
2026-10-14 05:56:00 +02:00
ReaperOfVeriod e1482abd75 Correct my own decode bug: there is a caller, and r2 is a return value
349b498 reported that 0x3a7c30 has no jal callers. That was wrong and the bug was
mine: the scan built J-type targets as ((instr & 0x03ffffff) << 2) | 0x0c000000
without masking to the upper nibble, adding 0x0c000000 to every target so nothing
matched.

Corrected: exactly one jal caller, at 0x3a3d84. Still no j tail-calls, still not a
vtbl handler, still not a pointer in any level-0 data file.

Tracing back, r2 is never written between the call and the use:

    0x3a3ce4  lw    r3, 0x1500(r2)
    0x3a3d70  jal   0x2ee178
    0x3a3d84  jal   0x3a7c30

so r2 is the return value of 0x2ee178, passed through as the first argument. The
chain is now 0x2ee178 returns a pointer -> 0x3a7c30 -> 0x3a8060 -> addiu r17, r18,
0xc0.

Next question is one function: does 0x2ee178 return core_data + moby_classes.offset?
If so the reading is confirmed and the loader is located; if not, the 0xc0 in
0x3a8060 is an unrelated struct member.

Also recorded in docs: a negative from a hand-rolled decoder is worth nothing
until checked against a known case. jal 0x3a8060 decoded correctly under a
different constant in the same probe and I did not notice.

284 passing with the disc present, clippy clean.
2026-10-14 05:47:29 +02:00
ReaperOfVeriod 349b498ddd r2 is never written in the enclosing function, and it has no callers
The trace eca1a16 asked for. The call site 0x3a7d88 sits in the function at
0x3a7c30, 580 bytes.

r2 is never written there: scanning all 580 bytes for any instruction storing into
r2 finds none. So it arrives from outside the function.

And 0x3a7c30 has no jal callers anywhere in level-0 .text, and is not a lvl.vtbl
handler -- level 0's vtbl has 233 records and 185 distinct handlers, and neither
0x3a7c30 nor 0x3a8060 is among them.

That rules out the tidy story, which was the obvious guess given r2 arrives
unexplained: the vtbl dispatcher calling it with a class pointer in v0. What
remains is an indirect jalr call, a dispatch table in data, or code that is never
called. Not distinguished; the question for anyone continuing is whether
0x3a7c30 is a jalr target and from where.

The chain is now ? -> 0x3a7c30 (no static caller) -> 0x3a8060 -> addiu r17, r18,
0xc0. Every remaining question is about control flow and register provenance, not
counts, which is where this stretch's method stops working.

284 passing with the disc present, clippy clean.
2026-10-14 05:44:46 +02:00
ReaperOfVeriod eca1a16b17 Call sites: one caller, and it does not compute base + 0xc0
0x3a8060 has exactly one call site, at 0x3a7d88:

    0x3a7d84  addiu  r18, r2, 0x202d      r18 = r2 + 0x202d
    0x3a7d88  jal    0x3a8060

No 0xc0 in the eight instructions before the call, and r18 is built from a
different offset. So the 0xc0 inside the callee is a field offset applied to a
pointer the caller already built, not a chunk offset the caller resolved.

Two readings remain: r18 is core_data, making r18+0xc0 equal &moby_classes, which
fits since level 0's moby_classes.offset really is 0xc0; or 0xc0 is a member
offset in an unrelated structure and the match is coincidence.

Settling it needs the provenance of r2 at the call site -- real dataflow, not
counting. That is where this stops.

Recorded in docs/plan/level-assets.md. The previous commit's doc edit failed
silently on a line-break mismatch, so this appends rather than replaces.

284 passing with the disc present, clippy clean.
2026-10-14 05:41:48 +02:00
ReaperOfVeriod cee17c95b1 Disassembled: 0xc0 is an address offset, and the walk is 16-byte strided
Closes the ambiguity 05d9bf6 raised. At 0x3a8060:

    263200c0   addiu  r17, r18, 0x00c0     r17 = r18 + 0xc0
    263000d0   addiu  r17, r16, 0x00d0     r17 = r16 + 0xd0
    262400e0   addiu  r17, r4,  0x00e0     r17 = r4  + 0xe0
    3c013f80   lui    r0,  r1,  0x3f80     the actual float constant: 1.0f

0xc0 is an address offset, not a float constant. The float constant is in the same
function and is 0x3f80, where it belongs -- so the ambiguity does not arise here,
and the reading of 0xc0 as moby_classes.offset survives.

The three offsets are 0xc0, 0xd0, 0xe0: sixteen bytes apart. The function walks a
table of fixed-stride structures and indexes the field at offset 0xc0, which is
what a class-table lookup looks like.

WHAT THIS DOES NOT SETTLE

That 0x3a8060 is the moby class loader. It indexes a 16-byte-stride table at
offset 0xc0, which is consistent with a moby class walk, with a different
structure that also lives at 0xc0, and with ordinary field access on an object
handed in by the caller.

The entry code sets a 64-byte frame and saves r16 through r20, and the three
indexes come off three different registers -- r18, r16 and r4. So the walk is over
arguments, not over a global. That is informative in itself: a loader would more
plausibly be handed the chunk by its caller than re-derive core_data + 0xc0 on
every call. Which also means the chunk reference is in the *caller*, and that is
where the next step is.

NEXT STEP, narrow and concrete: trace where r18 and r4 come from, and find the
call sites of this function. If a caller does `addiu r, base, 0xc0` and passes it
in, that caller is doing the chunk lookup and this function is the per-class work
inside it.

Worth recording how long the ambiguity took to close. It was raised one commit ago
and resolved by reading twelve instructions. Five commits before that the same
constant was reported as a "real shortlist", and two before that as a size
artifact. Every one of those states was arithmetically correct; only this one was
the right question, and it took looking at the operands rather than counting them
to arrive at it.

284 passing with the disc present (23 + 19 + 64 + 156 + 22; disc binary 35.00s),
clippy clean.
2026-10-14 05:40:08 +02:00
ReaperOfVeriod 05d9bf6cd7 The 72 concentrated candidates, and a float-constant ambiguity
Looking at the 72 candidates with fewer than 16 distinct immediates, the sets are
full of values that look like lui high halves: 0x3f80 is the high half of 1.0f,
and 0xff40/0xff70/0xff80/0xffc0 are negative high halves. 0xc0 << 16 is 0x00c00000,
a small float.

So 0xc0 in these functions may be a float constant rather than a chunk offset.
The neighbour control cannot tell the difference, because 0xb8 and 0xc8 are
equally plausible float high halves and are rare for their own reasons.

Also recorded, and explicitly not as a finding: exactly 1 of 394 candidates
contains another chunk offset, and none of the 72 does. That sounds like evidence
of moby-specificity and is not -- the other chunk offsets are 0x1e80 and above and
are globally rare (0 to 2 functions each, measured in 3ea5ab7), so their absence
here is exactly what rarity predicts.

What survives is the enrichment fact from a2f8c92. What does not survive is
reading 0xc0 as a chunk offset. There are now two live explanations, a moby chunk
reference and a float constant, and only disassembly separates them.

Cheapest test: 0x3a8060, 148 bytes, six immediates. If the lui carrying 0xc0 is
paired with an ori it is a float; if it feeds an addiu computing base + 0xc0 it is
the chunk.

284 passing with the disc present, clippy clean.
2026-10-14 05:36:13 +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 06531fb66c The 394 does not survive: 0xc0's rate rises with immediate-set size
Ran the size-independent statistic db1e69c asked for, and it confirms the
confound. Bucketing level-0 functions by distinct-immediate count, the rate
containing 0xc0 is:

    0-15   72/1082    6.7%      64-79   31/59   52.5%
    16-31 119/713   16.7%      80-95   13/25   52.0%
    32-47 104/291   35.7%     112-127   3/7    42.9%
    48-63  39/119   32.8%     624+      1/1   100%

Monotone from 6.7% to over 50%. Pigeonhole: a function with hundreds of distinct
immediates has more chances to contain any particular value.

So the 394 is largely a list of large functions and '394 of 2,323' was never a
shortlist. db1e69c was right; 3ea5ab7's framing was wrong and is corrected here.

This does not retract the neighbour result. 0xc0 being enriched over 0xb8-0xc8,
and 0xc0 being enriched in bigger functions, are separate claims and both hold.
What it retracts is the inference that the 394 are candidates for anything.

The control that would settle it: 0xc0's rate against a specific neighbour's rate
within the same set-size bucket. Not measured, and named so the next attempt
starts there rather than re-ranking by size.

No test -- the bucket rates are the finding, and an assertion on any single
threshold would be arbitrary.
2026-10-14 05:32:34 +02:00
ReaperOfVeriod db1e69c0ab Ranking the 394 by size is backwards; record the confound
Ranking the moby_classes candidates largest-first is the wrong first cut. The
top entries are 34,316 and 26,432 bytes with 631 and 421 distinct immediates,
median candidate 852.

A function with 631 distinct immediates is far more likely to contain any given
value, 0xc0 included, than one with 80, purely by pigeonhole. Sorting by size
therefore selects for the functions most likely to be false positives, and the
34,316-byte outlier is probably a mis-bounded extent -- the decomp corrects
three level-00 entries -- rather than a 34 KB loader.

The 394 needs a size-independent statistic: whether a function contains 0xc0 more
often than its immediate count predicts, not whether it contains it at all. Not
measured.

Recorded as a negative about the method rather than committed as progress. No test
-- there is no assertion here to make.

283 passing with the disc present, clippy clean.
2026-10-14 05:30:07 +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 59957a02a2 Attribute the 0xc0 immediate sites to functions: 410 candidates
The decomp's functions.tsv makes this cheap -- 3,835 level-0 functions with start
and size, so each immediate-bearing instruction is attributable to its container.

410 of 3,835 functions (10.7%) contain one; sizes 20 to 34,316, median 808.

Ten per cent is what a common small immediate buys, so this confirms 56b00fb
rather than narrowing anything. 410 is not a shortlist. What it does establish is
that the attribution works and the decomp's extents are usable for it.

The loader hunt now has a concrete next step: of those 410, the ones that also
touch a class span. That is dataflow, not counting, and it is where this resumes.

Caveat recorded: a 20-byte 'function' holding the immediate is a mis-bound extent
whose tail happens to include another function's instruction. Sizes need a floor
before the set is used.

No test -- an attribution count with no assertion behind it would only make the
410 look like a finding.
2026-10-14 05:23:14 +02:00
ReaperOfVeriod 56b00fb305 Fix the immediate scan by filtering on opcode; still no signal
Saying the previous method 'cannot tell' was right but the reason deserved a step
rather than a hand-wave: it is fixable by counting immediates only where they are
immediates, which needs an opcode filter and no disassembler.

All 27 levels: 11,911,320 instructions, 2,985,897 carrying an immediate
(addiu/lui/ori/xori/andi/sltiu), 13,813 distinct immediate values.

    moby_classes 0x00c0   17,699
    tie_classes  0x1e80       17

And now there is a control the raw version never had -- the program's most
frequent immediates are 0x0001 (200,784), 0x001b (149,259), 0x0010 (137,523),
0xffff (98,312). 0xc0 at 17,699 sits below the top of that small-value cluster
rather than standing out, so it is not a distinctive reference.

Result unchanged from the raw scan and now defensible: no signal. The loader is
not findable by frequency, and the remaining route is structural dataflow --
functions touching both the chunk offset and a class span.

No test: asserting that an offset is unremarkable is not an invariant worth
locking, and the two numbers are already recorded with their denominator.
2026-10-14 05:17:47 +02:00
ReaperOfVeriod 27891ce5b4 Cross-reference scan by raw immediate: the method cannot tell
Sought the moby class loader by counting raw u16 occurrences of each chunk offset
in .text. moby_classes 0xc0 hits 49,230 times, tie_classes 0x1e80 hits 111 --
and that contrast means nothing.

Small u16 values are simply common: the most frequent value in .text occurs
1,834,535 times. A two-byte value that is frequent by construction beats a rarer
one regardless of what either references, so the comparison has no shared
denominator. Rule 3a again, one level below where it has bitten before -- not the
measurement being wrong but the comparison being uninterpretable.

Recorded as a methodological negative rather than a finding. Doing it properly
means counting immediates only where the instruction decoder says they are
immediates, which is what tools/overlay-anchors exists for.

No test added: a test asserting a meaningless comparison would make the next
reader trust it.
2026-10-14 05:15:07 +02:00
ReaperOfVeriod 743107e864 moby_anim: the class table is not the loader
Ruled out the cheap approach: moby_classes (offset 0xc0, count 238 in level 0)
holds float-shaped values, and only 120 of the first 4,096 u32s land inside the
overlay .text against ~259 expected by chance. Fewer than chance, so the loader
is not dispatched from a pointer here.

Not concluded what the 238 entries are -- the float reading is suggestive but was
taken at one stride on one level.
2026-10-14 05:13:02 +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 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 87e2367daf Retract the "cutout matters far more" claim; the counts do not support it
25d73b4 said:

    alpha testing (cutout) matters far more than alpha blending on this disc

and cited 0.98% fully clear against 1.08% partial as the evidence. Those two
numbers are the same size. The cutout path handles the fully-clear texels and the
blend path handles the partial ones, so the measurement I gave is if anything
marginally against the claim, not for it. The conclusion did not follow from the
number attached to it.

What survives is a different and weaker claim, and it is about failure shape
rather than texel count:

- a texel that should be discarded but is drawn puts geometry where there is
  none. That is visible from a screenshot and someone will file a bug about it.
- a partial texel drawn opaque is a slightly wrong soft edge. That is not visible
  and will not be reported.

So cutout first is still the right order, but the reason is which failure a human
notices, not how many texels are involved. Stated that way it is defensible;
stated the way I did it was an overclaim with a number attached to make it sound
measured, which is the specific failure mode rules 3 and 3a exist to prevent.

This is the fourth thing this session where a plausible implementation or claim
turned out to be wrong on inspection, and the second in two commits where the
error was in the reasoning rather than the code. Code errors get caught by tests
because the tests execute. Reasoning errors get caught by re-reading the number
the claim rests on and checking that it says what the sentence says.

Retracted and replaced rather than softened, per AGENTS 3a.
2026-10-14 04:44:37 +02:00
ReaperOfVeriod 25d73b4fa8 What the disc says about GS priority: cutout matters, blending barely does
The alpha histogram from 777a6a8 had a consequence sitting in plain sight that I
had only used as a regression guard. It is a prioritisation input.

Measured over every decoded tfrag texture on all 27 levels:

    total texels            23,168,000
    fully opaque (255)      22,690,698   97.94%
    fully clear  (0)           227,497    0.98%
    partial (1..254)           249,805    1.08%
    distinct partial values            126

Two conclusions, and they point in opposite directions from the one I would have
assumed.

The 0x80 -> 1.0 rule is not an edge case to be got right eventually. It is what
22.7 million texels -- 97.94% of every pixel of terrain on the disc -- are
already doing. Getting it wrong is not a subtle shading difference, it is the
texture decode being wrong almost everywhere.

But blending is close to irrelevant at this level: partial alpha is 1.08% of
texels. A correct but imperfect blend path costs almost nothing visually; a
correct alpha *test* is what determines whether foliage, fences and cutaway
geometry exist at all. That reorders the remaining GS work, where I had been
planning to do blending and the cutout together.

The 126 distinct partial values are also evidence about what they are. If the
partial alpha were a blend ladder or a dither, it would populate a handful of
values. It populates all 126 evenly, which is what smooth alpha looks like --
soft edges and distance fade, not discrete states.

Caveat worth stating: these are tfrag textures, so this is a statement about
terrain. Moby and sky alpha may behave differently and are not covered by this
measurement.

Test count read from the raw result lines per AGENTS 3b, with the disc present:
278 passing, clippy clean.
2026-10-14 04:42:59 +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 bbd8cdccab Phase 5 is Aranos, not Veldin -- Veldin is not on this disc
Caught by the user, and it was wrong in four places: the Phase 5 heading, its
gate, its go/no-go line, and the PCSX2 verification plan's "Level 0 (Veldin)".

Veldin is Ratchet & Clank's home planet in the 2002 game. Going Commando opens on
Aranos, and there is no Veldin anywhere in this disc. The error looks like it
carried over from the first game when the roadmap was drafted, which is an easy
slip to make and an unhelpful one to leave standing: a vertical slice defined as
"completable and matching PCSX2 frame-for-frame" is not something anyone could
have attempted or judged while pointing at the wrong planet.

Confirmed against the vendored decomp rather than taken on faith. Its
docs/LEVELS.md refers to level 00 as Aranos directly -- "corrects three measured
Aranos entries before fingerprinting ... These corrections apply to level 00 only"
-- so level 0 is Aranos in the reference material this project has been using all
along, and the roadmap disagreed with the decomp.

There is a real R&C1 reference nearby and it is worth keeping, because the
reasoning under the gate is sound and only the noun was wrong. Phase 5 argues that
spherical gravity is the thing most likely to break, because it invalidates the
movement and camera assumptions the engine inherits from Ratchet & Clank (2002).
That is about inherited code from the previous game, not about the level being
built, and the text now says so explicitly so the next reader does not have to
work out which game is meant.

No code change. Documentation only.
2026-10-14 04:29:57 +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 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 3bc6c2d155 Phase 3 gate, second half: cross-check our masking against an independent hash
The gate asks for the relocated-copy result to be cross-checked against an
independent cluster hash. OpenRAC/rac2-gc-decomp's config/levels/functions.tsv is
exactly that: 7,765 functions carrying a content fingerprint and the levels each
appears on. 3,452 appear on all 27, in 2,984 distinct fingerprints.

Cross-checked against our own masking of the level-0 copy of each:

    our masked groups                      3,389
    reference fingerprint groups           2,984
    pairs the reference merges, we split   3,231
    pairs we merge, the reference splits       0

ZERO is the number that matters. Whenever our masking says two functions are the
same code, the independent fingerprint agrees -- our method never merges what
theirs separates. It is strictly finer: 3,389 groups against 2,984, splitting
3,231 pairs the reference keeps together.

The likely reason ours splits is that the reference fingerprint evidently ignores
something our mask preserves, so functions it calls identical differ in the words
our mask keeps. Coarseness, not contradiction. That is the compatible reading and
it is the one the 0 supports; had it been non-zero, this would have been a
conflict and worth chasing.

THE COMPARISON WAS INVALID BEFORE IT WAS VALID

The first run reported near-unanimous disagreement -- 1,900 of 3,392 pairs
differing -- which read as a significant finding. It was a category error in the
harness: key() masks the FIRST FOUR WORDS, while the reference fingerprints the
WHOLE function. A four-word summary compared against a whole-body hash is not a
comparison of two clusterings, and it produced confident numbers either way.

Recording it because nothing in the invalid output distinguishes it from a real
conflict, and the correction is only visible from the harness, not the numbers.
That is the third time this session that a measurement error produced a result
that looked like a finding -- after the constant-zero validity test and the stride
sampling artifact -- and the common factor is comparing things whose denominators
or extents differ.

The gate's second half is met, with "agrees" meaning "never contradicts" and ours
the finer of the two partitions.
2026-10-14 04:10:50 +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 88bc9566f2 Narrow moby_anim: the class header has no pointer to anything external
Finished the unblock attempt with two cheap tests on the 187 level-0 class
headers, because "which field points to the animation" needed to be ruled in or out
properly rather than guessed at.

NO HEADER u32 EQUALS THE SPAN'S OWN TABLE OFFSET, on any class. So the varying
fields are not self-relative pointers to the part table.

THE LARGEST VALUE IN ANY HEADER u32 IS 3,332,678,933. At +0x0c the value is
16,711,748 across classes. That is packed data or an identifier, not a
displacement.

So the header's varying fields are a MIXTURE: some hold in-span offsets (+0x10
through +0x1c run 192 to 10,800, comfortably inside the spans) and some hold packed
values far too large to be offsets. There is no single "field that points to the
animation", because not all of them point anywhere.

That is a real narrowing even though it is a negative. If per-class animation data
exists on the disc it is INSIDE the class span, not at a separate pointer target.
So the next step is not "find the pointer" -- it is to subtract the geometry the
parser already accounts for from the span and look at what remains. That is a
bounded question with an existing control, rather than an open-ended one.

moby_anim is still not unblocked. It is now bounded rather than open.

The other three blocked items are unchanged and, as recorded, not reachable from
here without something this repository does not have: a debug build or an external
format description for collision, ground truth for volumes, and a captured frame at
a known camera distance for the tfrag morph.

Moving to Phase 3 next. Its first unchecked item is "extend the level table from 19
to 27" -- concrete, bounded, and independent of everything above.
2026-10-14 04:03:53 +02:00