Compare commits

..
174 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 615ff937ea Decomp advanced 18 commits to 8a174cb; 14 new matched functions land in our .text
Checked upstream as asked. The vendored checkout moved from aca4f59 to 8a174cb,
18 commits ahead of origin/master.

What the update is: all 18 commits are src/game function-matching work. No header,
no docs, no config, and crucially no change to config/levels/functions.tsv -- so
the function extents and level catalogue this project depends on are unaffected.

What is usable: 14 of the newly matched functions fall inside the overlay .text
(0x2a3b80-0x447900), which makes them decomp addresses verified against the same
code this project runs:

    002A7750 002AA800 002B3868 002B6120 002B7E28 002C7748 002CD8C0
    002D5E98 002DDEC8 002DF1B8 002E7018 002F5220 002FC8D0 00300618

That matters because PROVENANCE.md records exactly this split: the decomp's struct
layouts were usable while its function addresses were not (they failed for the moby
class loader). Addresses that are now matched inside our own overlay are the
reliable kind, and 20655c7 needs precisely that to follow UpdateCamera_6 out of
lvl.camvtbl without repeating the mistake that cost the moby_anim chase.

What it does not give: none of the 14 is camera, frustum or GS code. The bodies are
game logic and toolchain-status material -- one is a header-offset relocation pass,
another raises a flag and forces a state word out of {6,7}. So the frustum hunt
does not advance from this, and the next step remains verifying UpdateCamera_6's
address against the overlay and reading the function that writes 0x18aa70.

Vendored checkout is gitignored, so moving it changes nothing committed except the
PROVENANCE note recording the pin and what was in the update.
2026-10-15 03:33:31 +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 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 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 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 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 1bd4e17819 The test count is identical with and without the disc; only the clock differs
An addition to AGENTS 3b, from nearly repeating the failure it describes.

Checking the count in 25d73b4, the suite came back 278 passing -- the same 278 as
always. But GC_DISC was not set for that run, so all 58 disc tests hit their
`require_disc!` early return and were counted as passed. The number was right and
the coverage behind it was not.

What distinguishes the two runs is the wall clock and nothing else:

    without GC_DISC   58 tests,  4.10s
    with GC_DISC      58 tests, 36.14s

Same test count, same "ok", same zero failures. A reader -- including me -- has no
way to tell those two lines apart. That is a sharper version of the 3b failure
than the filtered sum was, because no amount of reading the output carefully
distinguishes them. The duration does, and it is in the line already.

So 3b now says: if a run finishes suspiciously fast, the disc tests did not run.
And it keeps the standing instruction to set GC_DISC when touching disc-decoding
code, since 25d73b4 changed texture-adjacent code and the verification genuinely
needed the disc.

Re-verified properly: 278 passed across 23 + 19 + 58 + 156 + 22, disc present,
36.14s on the disc binary. Clippy clean.
2026-10-14 04:44:07 +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 1982f545f1 AGENTS 3b: never report a test count a filter could have hidden a failure in
Rule 3b, requested after a commit went out with a failing test in it.

    cargo test -q --workspace | grep "^test result: ok" | awk '{s += $4}'

That sums the green test binaries and ignores the red one. A broken build still
prints a number, and the number looks entirely reasonable -- six healthy counts
added together is not a number anyone questions. I used that filtered sum as the
evidence of health in commit messages for the better part of a session, and it
was reporting green on a build that was not.

What actually happened is the part worth keeping. The count came back lower than
the previous run -- 100 instead of 278. That is a genuine signal and I explained
it away twice as an environment difference, the disc not being set, before
checking. Both explanations were plausible. Neither was tested. Only the
discrepancy was noticed, and noticing it is what surfaced the failure.

So the rule covers both halves: do not commit or push with a failing test, and do
not derive the number you report with a filter that drops the failing binaries.
The second half is the one that is easy to get wrong again, because
`cargo test -q` is quiet, the grep looks like a reasonable way to total up, and
the total is the only part anyone reads.

Also records the mistake as a mechanism rather than a symptom. Same defect as
rules 3 and 3a, one level up: a quantity that should have had one definition had
two, and nothing compared them.

Added a line to "Working rhythm" pointing at it, since that is the list someone
reads before committing.
2026-10-14 04:42:29 +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 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 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 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
ReaperOfVeriod 4776db199b Attempt the moby_anim unblock: a location and a failure, not a result
Asked whether the blocked items could be unblocked, and tried the one that looked
tractable. It did not work, and this records where and why rather than leaving the
attempt implicit.

VERDICT PER ITEM

  moby_anim    probably -- disc-side work. TRIED, stalled, see below
  tfrag morph  maybe -- would need the engine's own morph code
  volumes      no -- needs a count of level 0's trigger volumes from a map
  collision    no -- needs a debug build or an external format description

THE ATTEMPT

MobyInstance names animSeq, animLayers, jointCnt and jointCache. Those are RAM
pointers and useless on the disc, so the search moved to what they point AT,
reached from the moby class blob.

Level 0, 187 moby classes. The span opens with the part-table offset: 0x80 on 69
classes, 0x90 on 34, ranging 0 to 229,184 across the rest. So each class carries
roughly a 0x40-byte header before its parts, and that header is unexplored.

Per byte over +0x00..+0x40, how many of the 187 classes are non-zero:

    +0x00  177   the part-table offset
    +0x07  185   8 distinct values, 1..3
    +0x26  181   100 distinct -- varies per class
    +0x27  187   8 distinct
    +0x2d  187   8 distinct
    +0x30  170   120 distinct
    +0x3c  184   120 distinct
    +0x3e  187   110 distinct

Six or seven fields are constant across every class and about a dozen vary. None
is identifiable. The obvious discriminator -- find the field that points OUTSIDE
the span -- does not resolve, because every varying field holds values in the tens
of thousands, the same order as the span offsets themselves. They may be
intra-span pointers or they may be sizes, and nothing here separates them.

WHERE IT STALLS

Distinguishing them needs one of: the class loader in the overlay's .text, which
is now readable with the fixed disassembler, or a class whose animation is known
from outside the disc. Neither has been done, and the first is the obvious next
step rather than a dead end.

So moby_anim is NOT unblocked. What the attempt does establish is a location, a
field count to classify, and a specific reason the easy discriminator fails --
which is more than the item had, and considerably more than "still open".

The other three are unchanged and I do not think they are reachable from here.
Collision needs the assert, which is unreferenced in retail, or an external
description of R&C2's collision cell. Volumes needs ground truth nobody in this
repo can supply. Both are written up with those conditions attached.
2026-10-14 03:56:50 +02:00
ReaperOfVeriod b3b4d47d97 Search for the tie vertex stride: 64 where one exists, none for 62%
The 16-byte reading was retracted without a replacement. This searches for one --
every tie vertex region, strides 4, 8, ... up to len/16, keeping any stride where
some byte offset is constant across every unit:

    regions examined                1,844
    no stride found at all            1,139   (61.7%)
    stride 64                           491
    stride 56                           120
    stride 72                            49
    stride 48                            12
    stride 16                             3

64 where a stride is detectable, not 16. And for 1,139 regions there is no stride
in range producing a single constant byte -- those are genuinely variable, or the
element is larger than region/16, which is the same statement.

THE CONTROL THAT CHANGED THE ANSWER

The first run reported 1,561 regions at stride 64. Wrong, and wrong in a way worth
recording: a large stride divides a region into fewer units, and fewer units makes
an accidental constant more likely. Region length is fixed across candidates, so
the widest stride always has the fewest units and the best chance of constancy by
luck alone. The search was reporting its own sampling.

Requiring every candidate stride to cover the SAME minimum of 16 units, and
preferring the SMALLER stride on ties, drops it to 491 and promotes 56 and 72 into
view, which were invisible before.

This is rule 3a's sampling failure in its purest form -- an aggregate over
candidates with different denominators, where the largest denominator wins for a
reason unconnected to the data. The first number was not imprecise; it WAS the
artefact. Worth noting that without the control I would have reported "64" with
confidence and been right by accident, three times the observed figure.

What this does not settle: 64 is the stride where one is detectable, which is not
the same as the stride. The 1,139 regions with no constant byte at any tested
stride leave the element genuinely unknown. But 16 is now excluded quantitatively
-- 3 of 1,844 regions show a constant byte there -- which is the retraction's
content, as a measurement, and a third independent check now agreeing with it.
2026-10-14 03:54:32 +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 412ef4707b Withdraw the tie "not a joint index" claim: the measurement never settled it
Revisited an older conclusion and found the numbers were fine but the inference
from them was not.

The claim was: i16[3] in a tie vertex is not a skin/joint index, because vertices
sharing a value are 0.95x FARTHER apart than vertices differing in it.

    mean pairwise distance, same slot-3 value        14,481
    mean pairwise distance, different slot-3 value  13,795
    ratio                                              0.95x
    control: same position-x bucket                   8,105

The measurements are correct, and the control shows the test works. What does not
follow is the conclusion. The premise was "vertices sharing a joint sit together",
and that is only true for a TIGHTLY LOCALISED joint. It is false in the cases that
are common:

  * a rigid tie object -- a leaf, a banner, a piece of foliage -- puts every vertex
    on joint 0, so "vertices sharing a joint" IS the whole mesh and the mean
    distance is the mesh's own diameter
  * a root or hip bone's influence region can span most of a mesh
  * bone weights fall off gradually, so membership is not a spatial partition

A 0.95x ratio is precisely what a rigid object bound to one joint PREDICTS.
Absence of clustering is fully consistent with the joint reading; the test never
had the power to separate them. A negative result I did not expect was reported
as a disproof.

This makes the hypothesis STRONGER, for an independent reason: MobyInstance carries
jointCnt at 0x57 and jointCache at 0x58, so this engine demonstrably has joints and
per-moby joint state. A per-vertex field in 0..=254 is exactly the shape of a
joint index.

What was always established still stands, and is all that was: slot 3 is per vertex
(50 distinct values per packet) and not shared across LODs (75.3% same-class
against a 78.7% control). The LOD result is the one thing arguing against a joint
index, though two LODs binding a rigid mesh identically would produce it without
being informative.

So: not understood, with the joint reading now the LEADING CANDIDATE rather than a
disproved one. Corrected in the format file and the roadmap, including the second
mention added two commits ago.

This is the sixth correction in this session to withdraw a conclusion rather than
sharpen one, and the first where the measurement was sound and only the reading
was wrong -- which is the hardest kind to catch, because the number looks right and
is quoted in the commit message as well as the doc.
2026-10-14 03:48:17 +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 9f4f4acecb The collision assert string is never referenced: the last route is closed
Followed the route I recorded as remaining -- find how the code addresses the
assert string at 0x001a8998 in .lit. It does not address it.

Level 0's program is 2,751,268 bytes. Searching it for both ways an address can
be materialised:

    address low half              0x8998    0 occurrences, word-aligned or not
    $gp-relative offset           0x99a8    0 occurrences, word-aligned or not
    exact 32-bit 0x001a8998                 0 occurrences

Validated by a positive control in the same run: the .lit load base's own low
half, 0x7c80, occurs exactly ONCE, word-aligned. A search that finds that will
find any real reference, so the zeros are real.

So no instruction and no data word in the retail level program references the
collision assert string -- not by lui/addiu, not by a literal pointer, not by a
table entry. The text is present in .lit at overlay+0xd18, and the same text also
appears in the boot binary at file offset 0xA9558, but nothing points at either.

The reading that fits: the assertion is compiled out in retail and the string is
dead data. Debug string pools routinely keep messages whose only user was an
#if-ed-out assert. The decomp's table lists this name because it recovered it
FROM THE STRING, not because it found code calling it -- which is worth noting,
because a name in a symbol table looks like evidence of a caller and is not one.

That closes the last route this file records for collision. Getting the cell
layout from the disc would need a build with assertions enabled, a debug listing,
or an independent description of R&C2's collision cell. None of those is on this
disc, and saying so is more useful than another scan.

Summary of collision, all of it now checked: the payload is verbatim in RAM, it
does not walk as fixed-stride cells, the disc-side segmentation is exhausted, the
string that would have led to the routine is unreferenced, and both of the
decomp's addresses are dead ends.
2026-10-14 03:25:32 +02:00
ReaperOfVeriod 66a96111d7 Kill the 48-byte loop lead: the function takes no pointer arguments
Finished the task I named last commit rather than leaving it as a lead.

Camera_CollPrimTest is declared Camera_CollPrimTest(void *prim, void *grid) --
two pointer arguments, which in MIPS arrive in $a0 and $a1.

The function's prologue is at 0x002db078, matching the +0x70 teardown at
0x002db354. The two instructions after it:

    0x002db078: addiu $sp, $sp, -0x70
    0x002db07c: lw    $v1, -0x3f4c($v0)    <- $v0, not an incoming argument
    0x002db080: addiu $a0, $zero, 0x6f     <- $a0 set to a constant

$v0 is caller-saved scratch, not an argument register, and $a0 is overwritten
with the constant 111 on the next instruction. No void* reaches this function, so
it is not Camera_CollPrimTest, whatever the assert table labels it.

It reads four consecutive words from boot memory -- 0x1a7348 through 0x1a7354 --
all below the level program base at 0x001A7C80, which is the boot's resident
region and identical in every program. A different data source from the collision
payload, and the 48-byte stride loop belongs to whatever that structure is.

So the lead is dead on evidence rather than on doubt, and both routes the decomp
appeared to offer are now closed:

    Camera_CollPrimTest  @ 0x001E7A50   lands in NOBITS, all zero
    GridOutOfBounds      @ 0x002db350   a function with no pointer arguments

Neither address reaches the collision parser. Worth noting how cheap the check was
once the tool worked: three instructions past a prologue is enough to disqualify a
function whose name asserts a signature. The expensive part was not the analysis,
it was the disassembler that stopped after five words and made everything look
empty.

The route that remains is the one already recorded -- find how the code addresses
the assert STRING at 0x001a8998 in .lit, which is not a $gp-relative load and is
not a symbol address in either table. Note that the string and the code are five
megabytes apart in different sections, so whatever reference exists is not a
simple local reference, and that is the thing to look for.
2026-10-14 03:18:02 +02:00
ReaperOfVeriod 2f5979e652 Update the decomp; read .text, and fix the disassembler that made it unreadable
Vendored rac2-gc-decomp is 13 commits behind, 12,375 lines added, almost all src/.
It names no collision data structure, so the format is still not handed over. Two
things are usable, and one precondition had to be fixed first.

THE PRECONDITION: the disassembler was stopping after five instructions.

tools/overlay-anchors called disasm_all across the whole window, which halts at
the first word capstone will not decode. In code regions that is almost
immediately, so every window came back with a handful of instructions and I had
concluded there was nothing there. It now walks four bytes at a time, prints
.word for anything undecodable, and reports how many failed. That is the whole
reason the earlier attempts produced nothing -- not that the code was absent.

WHAT THE DECOMP'S ASSERT TABLE ACTUALLY GIVES

assert-message-names.tsv maps each assert name to an address. For level 0,
CameraCollPrimTestGridOutOfBounds is 0x002db350. That address is not the assert
call and not the string. It is:

    0x002db350: jr              $ra
    0x002db354: addiu           $sp, $sp, 0x70

an epilogue. The address lands on the last group of the function before it, and
0x002db35c is a fresh prologue. It is also in .text (0x002a3b80..0x00447900),
five megabytes from the .lit string at 0x001a8998 -- which explains the "$gp search
returns zero" result from the other side. The string and the code are in different
sections, five megabytes apart.

WHAT IS ACTUALLY THERE

Immediately before the epilogue:

    0x002db2dc: lwc1   $f20, 0xc($s0)
    0x002db2ec: add.s   $f20, $f20, $f21
    0x002db2f0: c.olt.s $f0, $f20
    0x002db304: jal     0x2db4b8        <- the assert call
    0x002db314: lw      $v0, ($s1)      <- a count
    0x002db31c: slt     $v0, $s2, $v0
    0x002db320: bnez    $v0, 0x2db2cc   <- loop back
    0x002db324: addiu   $s0, $s0, 0x30  <- stride 48 bytes

A loop over 48-byte records, reading a float at +12 and accumulating, bounded by a
count, with f22 set to 1.0f just above it -- a minimum-height clamp.

NOT CLAIMED to be the collision grid. It is a strided float loop beside an assert
the decomp labels CameraCollPrimTestGridOutOfBounds. A plausible address near a
plausible assert is a lead, not an identification, and that is the exact gap this
log keeps tripping over. Confirming it means tracing the function's arguments back
to the collision payload and showing the grid's leading u16s -- level 0 is 11 x 33
-- drive the count at ($s1). Not done, and not asserted.

The undecodable words are VU1 microcode (COP2 opcodes), which is expected: the
function interleaves VIF-fed VU1 work with FPU arithmetic, like tfrag's.

Tools build, clippy clean, 260 tests.
2026-10-14 03:16:17 +02:00
ReaperOfVeriod 508a03fa4a The decomp names both collision functions; their addresses are not ours
rac2-gc-decomp names the two collision entry points:

    Camera_CollPrimTest   0x001E7A50   camera-to-world boundary check
    MB_CheckCollPill      0x001E8890   pill/capsule check for moby instances

That looked like the collision unblock. The $gp string search has failed twice
now, and a named function at a known address is exactly what it failed to
produce.

They are not usable. Checked against levels 0, 5, 13 and 26 with
parse_overlay_sections:

    Camera_CollPrimTest @ 0x1e7a50  ->  section 1, kind 8 (NOBITS)
    MB_CheckCollPill    @ 0x1e8890  ->  section 1, kind 8 (NOBITS)

Both land inside the level program's zero-filled section. Every byte is zero and
the following 256 bytes contain no non-zero byte at all -- code cannot be there.
Same on all four levels, so it is not a level with an unusual layout.

So the addresses belong to a different address space than a parsed level overlay.
Most likely the boot executable's own layout, or the v2.00 build; that repository
covers both. SCUS_972.68 is USA v1.01 and our disc matches it exactly, but that
fixes the disc, not which program's address space a symbol address is quoted in.
The disc matching is exactly the kind of check that looks sufficient and is not --
a disc can pin the bytes and still leave you looking in the wrong program.

Worth noting the method that caught it. The temptation was to take the address,
open the overlay at that offset, and start reading instructions. What settled it
was checking which section the address falls in, and its KIND: a NOBITS section is
zero-filled by definition, so an address inside one is a contradiction rather than
a lead. The section table made that a two-line check.

This closes the shortest route to the collision cell layout, for a checkable
reason rather than a vague one. The route that remains is the one already
recorded: find how the code addresses the Camera_CollPrimTest assert string, which
is not by a $gp-relative load.
2026-10-14 03:11:13 +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 4ab30e1f4d Retract the tie texcoord field: a dominant-value artifact, one level down
Picked the tie flat encoding to close, since e5cd3df left it as the largest
unidentified field. What came out was a retraction of my own earlier claim.

The texcoord slot was recorded at i16[4]/i16[5] on a 73.6% "both multiples of
4096" score. Two problems, either of which would be enough.

First, that 73.6% was computed on the 16-byte element reading that e5cd3df
retracted. Half the records are VIF packets and half are flat, so the figure
describes neither population cleanly.

Second, and decisive on its own: measured on the FLAT records only -- levels 0-2,
930 records, 47,909 vertices -- the question that separates a 12.4 fixed-point
texcoord from anything else is how many of a slot's distinct values are multiples
of 4096 at all. For a texcoord it is all of them.

    slot        distinct   of which are multiples of 4096
    i16[4]         1,488      16
    i16[5]         1,261      16
    i16[6]           966       4
    i16[7]           489       5

One percent. The dominant PAIRS do look like UV corners -- (0,0) 5,773,
(0,4096) 5,500, (4096,4096) 5,059, (4096,0) 4,521 -- and that is precisely why
the claim was persuasive. But it is a statement about the most frequent values,
not about the field: 1,472 of the 1,488 values in i16[4] are not multiples of 4096
at all.

This is the moby class-ID artifact one level down. A small set of dominant values
that happen to satisfy the test, scored as though the whole population did. The
arithmetic was right about the popular values and the conclusion was wrong about
the field. The retraction commit itself warned about exactly this shape, which
makes the repeat more useful than the original claim was.

So the texcoord slot of a flat tie record is NOT identified. What survives is the
boundary work -- vert_ofs + vert_size + scissor_size is the region length -- and
the LOD1/LOD2 sharing result, which is stride-insensitive and survives the stride
retraction.

Both docs updated: the roadmap's two [x] claims about the texcoord field and about
i16[3] selecting its encoding are now [~] with the evidence, and the format file
has a retraction section giving the table above.

I did not find a replacement reading for i16[4..7]. Three discriminators were
tried -- LOD sharing, multiple-of-4096 rate, value-set size -- and each came back
ambiguous or negative. That is worth recording as the state rather than
generating a fourth hypothesis.

258 tests.
2026-10-14 02:56:57 +02:00
ReaperOfVeriod 1e90c7de6d Verify the Phase 2 gate, and give every blocked item an unblock condition
Starting from the phase's stated exit criteria rather than the checklist, because
"complete" should mean the gate, not the feeling of the list.

THE GATE IS MET. Both halves, checked by inspection:

  * Golden tests. Every module in gc-formats/src is referenced by a test or another
    module's test path; none is unreferenced. gc-extract/tests/disc.rs carries 50
    real-disc tests, and overlay_reloc's mask test resolves a level-01 function on
    all 26 other overlays.

  * Counts are failures, not warnings. No parser writes warning-style output
    outside #[cfg(test)] -- the only eprintln! calls in gc-formats are inside test
    modules. Count and length problems surface as typed errors: 42 uses of
    ErrorKind::Invalid / InvalidLength, including global_wad's "pair table is not a
    whole number of entries", which is precisely a header count disagreeing with
    the data.

THE GOAL IS NOT MET, and saying otherwise would be the thing AGENTS.md forbids.

The goal reads "every asset type parses into plain Rust values". Four do not:
collision cells, volumes, moby_anim and the tfrag morph are located or framed but
have no decoded fields. The gate was written to be checkable and it does not test
for that, which is a gap in the gate rather than a reason to declare victory.

So the remaining gap in the phase's own discipline is that the four blocked items
did not say what would unblock them, which AGENTS.md requires and which none of
them did. Closed now, each with the specific unblock condition:

  collision cells  the game addresses its assert string by something other than a
                   $gp-relative load -- a %hi/%lo split that does not straddle
                   zero, a pointer table, or another section. A plain $gp search
                   returns zero and always will.
  volumes          ground truth: a count of level 0's trigger volumes from a map,
                   to match against the 144-byte table's population. No class name
                   containing "volume" exists on the disc, so the dispatch route is
                   closed.
  moby_anim        needs moby class records decoded past the header -- a
                   dependency, not a measurement.
  tfrag morph      needs a frame captured at a KNOWN camera distance, so the
                   expected blend is computable. Requires driving the running game,
                   not analysing a static savestate.

None of these is an approximation, and none is marked done.

258 tests, 50 in the disc suite, clippy clean on my own code.
2026-10-14 02:52:09 +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 e5cd3df40e Retract the tie vertex position claim; the stride is not 16
Task was to name the axis order and scale of the tie vertex positions by
fitting the class header's bounding sphere. The sphere does not exist: at
+0x30 those bytes are zero on every class. Scanning the header for
float-like words found +0x10, +0x14 and +0x18 (the LOD switch distances,
already named) and +0x40 on 1,676 of 1,864 classes, which is not a sphere.

What the run produced instead is a retraction. Dumping one packet's vertex
region raw shows the first 16 bytes are header material, not a vertex --
0x0000df20 is not a coordinate, and the words after it are the vif and
material words. A 16-byte group does recur at +64 and +128 with identical
structure, so the stride is not uniform: some elements are 4 bytes, some 16,
and the run length varies.

Two candidate bases were measured and neither clusters. With packet +
vert_ofs the per-class extents run median 64,976, p90 65,517; with class +
vert_ofs, median 55,213, p90 64,352. Both span the whole i16 range, where a
position triple in a class-local frame would span a fraction of it. The region
bounds are right -- vert_ofs + vert_size + scissor_size is the length -- it is
the element layout inside it that is wrong.

The i16[3] marker does not rescue it: bucketing every fourth element by the
marker's high byte gives markers 0, 0x48, 0xff and 0x01 with thousands of
vertices each, and only 0x48 shows a position signature (i16[1] extent 425
against 65,535 on the other two axes). The same marker 0 bucket spans all
three axes at full range, so the bucket does not select a layout.

The 95,612-vertex count and the 82.5% texcoord fixedness figure were computed
on the 16-byte reading and are now untested against the correct stride, which
is not yet known. The LOD1/LOD2-sharing test stays the strongest evidence for
positionality and is stride-insensitive.
2026-10-14 02:05:38 +02:00
ReaperOfVeriod 855aadf523 Record the tie terminator defect in the format doc, plus two failed fixes
The defect itself was measured and committed in 572cd18, but the format-doc
section describing it was lost last session: the patch that should have added it
silently failed its assertion, and only the test and the roadmap entry landed.
Same shape as three other lost edits this session. The section is restored here.

Two candidate fixes are recorded alongside it, both measured and both dead
ends:

1. The first 0xf6 00 at or after the last known packet's end. The offsets
   found are spread (1082, 1132, 1148, 1150, 1182, 1214 ...), not concentrated,
   because the 115 records with a non-zero strip_count carry packets of unknown
   length that sit after the last known end.
2. The block's data ends at a long zero run. The byte before the first 8+ zero
   run is not dominated by 0xf6 (2, 4, 1, 64, 3, 32, 19 ...), and the runs are
   mostly 8-31 bytes, so run length does not discriminate the block's end.

Both are recorded because they are the obvious things to try next. What would
unblock it: a survey of which offsets do bound blocks (the 461 that work may
share a property), or the level loader's own block-walking code, which needs the
overlay relocation from Phase 3.
2026-10-13 18:07:04 +02:00
ReaperOfVeriod 572cd1898d Record a defect packet slicing found: the tie block terminator is early
Range measured: every tie LOD block on the disc, 2,177 blocks.

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

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

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

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

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

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

  packet_quadwords = vert_ofs + vert_size + scissor_size

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

TieClassHeader gains packet_counts, and
tie_class_header_counts_records_per_lod asserts the agreement.

The fourth byte, +0x0f, is left unnamed. It ranges 1..15, and the obvious
following of R&C1 -- the shader/ad-gif total -- disagrees on 1,827 of 1,864
classes, which is 2% agreement where a count would expect 100%. It is recorded
as unidentified rather than given a name that fits a fiftieth of the population.
2026-10-13 08:59:40 +02:00
ReaperOfVeriod f1f0b517a5 Pin the extracted tree, which every disc test reads and nothing verified
data/gc-files.tsv pins the ISO files, and unpacks_and_matches_the_committed_table
checks that unpacking reproduces them. But the tie, collision, tfrag and chunk
tests do not unpack -- they read ~/Documents/gc-extracted, a directory on disk
that nothing in the repository checks.

A stale or hand-edited file there would have every measurement built on it still
pass, quietly. Three claims this session returned confident numbers while
describing the parser rather than the disc -- has_uniform_record_length, "1,509
chunks uniform", the moby class-ID scan -- and a test whose data source is
unverified is that same hazard one level down.

data/gc-extracted.tsv now records SHA-1 and size for all 5,237 files in that tree,
324 KiB, sizes and hashes only, no content. It is the output of `gc-extract
unpack`, whose ISO files are already pinned by the other table, so it is grounded
transitively rather than by assertion.

the_extracted_tree_matches_the_committed_table walks the table and checks every
row's size and hash against disk. It asserts all 5,237 are checked -- so a
truncated table fails rather than passing on a prefix -- and reports the first few
missing or mismatched paths when it does. Current result: 5,237 of 5,237 match.

This does not retroactively validate the three retracted claims. What it does is
remove the possibility that any of the surviving ones rests on a directory that
could have been anything.

246 tests, clippy clean.
2026-10-13 08:20:37 +02:00
ReaperOfVeriod 8ccc3ddda4 Retract the uniformity claim everywhere it still stood, including the API docs
The record-length retraction had been applied to the tests and to the section
that discovered the problem, but the same claim was still standing in four other
places -- including the rustdoc for has_uniform_record_length(), which is what a
caller would actually read:

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

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

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

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

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

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

245 tests, clippy clean.
2026-10-13 08:18:20 +02:00
ReaperOfVeriod 2d3880b624 The class-ID retraction goes further: the claim was wrong before the fix
Pinned down the one slot that still cleared 100x after the record-boundary fix,
and it is the artifact this section was written to guard against.

The discriminator the original version lacked: a slot that really carries moby
class IDs must SPAN the ID space, so some of its values land above a level's moby
count. The artifact does the opposite.

    type         slot   enrichment   distinct   values ABOVE the moby count
    0xd76cc453      6       305x           9        0
    0xf2ef4275     34        64x           9      341
    0xf2ef4275      7        46x          16      412
    0xf2ef4275     14        34x          16      432
    0xd76cc453      9        32x          28      702

The 305x slot's nine values are 1, 3, 4, 5, 10, 11, 12, 13, 15 -- all under a
level's ~215 moby classes, so every one scores as a hit and 65536/215 = 305x.
Nine small numbers is not a class-ID array. Every slot that actually spans the ID
space scores 10x to 64x, which is chance.

So the original claim's own numbers gave it away and it was not read that way:
"5 to 42 distinct values at 153x to 289x" is exactly the artifact's shape -- a
handful of small values, none above the count. The record-boundary fix was never
what made this claim wrong. It was wrong when written, and the retraction is
complete rather than the partial one I committed two turns ago.

The test now separates the two quantities: slots clearing 100x (currently 1,
expected to remain the artifact) and slots clearing 100x WHILE ALSO having values
above the moby count (currently 0, must stay empty). That second number is the
one that means something, and a slot appearing in it would be the first genuine
evidence of a class-ID array anywhere in the container.

Worth noting the per-SLOT bookkeeping: the count of values above the moby count
is keyed by (type, slot), not by type. A first attempt keyed it by type and the
artifact type passed the check on the strength of its ordinary slots -- 783 of its
own values sat above the count at other slots. The check has to be about the
individual slot, or one good slot launders a bad one.

245 tests, clippy clean.
2026-10-13 08:11:35 +02:00
ReaperOfVeriod d159195510 General guard for the over-read class, and the audit showing it was unique
Searched for the pattern that caused the over-read -- a record length derived from
the first difference between two offsets:

    grep -rn "\[1\] - \[0\]" crates/gc-formats/src/*.rs
      crates/gc-formats/src/chunks.rs:357   (offsets[1] - offsets[0])

One hit, in the one parser that walks an explicit offset table.

The others slice contiguous regions with chunks_exact -- shrub's 8-byte palette
records, tie's 16-byte packet headers, tfrag's 4-byte tables. There a length
cannot run past the next element, because chunks_exact derives each boundary from
the region's own length rather than from a sample of the spacing. Shrub's mesh
packets carry an explicit offset AND size, so they state their length instead of
inferring it.

The damage was bounded by a structural property worth stating: only a format with
an offset table AND a derived stride can over-read, and exactly one format had
both. That is a more useful fact than the retraction was on its own -- it says
where else to look if another one turns up.

The guard is positive rather than a comment.
chunk_records_neither_overlap_nor_leave_gaps asserts across all 27 levels that
every record but the last is exactly its own offset delta long, that there are
1,509 chunks, and that 1,252 have ragged deltas. A regression to a single sampled
stride fails immediately and names the chunk and the record.

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

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

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

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

    lowest   level 8   16.7%
    highest             20.0%

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

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

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

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

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

THE RETRACTION, which is the substantive part:

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

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

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

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

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

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

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

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

WHY THIS DOES NOT BECOME A FIX

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

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

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

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

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

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

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

The uniform model stays, the ambiguity is recorded in resolve()'s documentation
and here, and the 1,509-of-1,509 assertion stands as a statement about what the
parser does rather than about what the disc contains. 243 tests, clippy clean.
2026-10-13 07:50:50 +02:00
ReaperOfVeriod b9e9835639 A parser defect: one chunk type is not fixed-stride, and over-reads
[75 42 ef f2 71 b5] -- the signature that is both level-specific and
variable-length -- makes three chunks per level, 81 total:

    stride 76   deltas [76, 76, 76, 76, 76, 76, 76, 76]    uniform
    stride 92   deltas [92, 88, 84, 92, 92, 92, 84, 88]    NOT uniform
    stride 104  deltas [104, 104, 104, ...]                uniform

Identical on all 27 levels: 27 chunks of each shape, three shapes, no others.

So "this signature has strides {76, 92, 104}" is a misdescription. It has three
chunk shapes, one of which is not fixed-stride at all. The parser derives one
stride per chunk and resolve slices every record at that length.

The cost, precisely: records are read at the offset table's positions, so every
record STARTS correctly -- no misalignment. But each is sliced at 92 bytes, while
two of the eight are 88 and two are 84, so four records over-read by 4 and 8
bytes and carry the head of the next record inside them. Bounds checking holds;
this is a data-correctness bug, not a safety one. It is precisely the kind that
produces plausible-looking fields offset by four bytes, which is how it would
have gone unnoticed had the stride table not been collected per signature rather
than inferred per region.

It also explains why this signature was previously flagged as breaking the 8-byte
alignment rule: its records genuinely are not a uniform multiple.

The right fix is for resolve to use offset deltas for a chunk whose deltas are
not uniform. Not taken here -- the delta sequences are asserted first, so if the
data ever becomes uniform the test fails and asks for the model to be revisited,
rather than the model quietly absorbing the exception.

Asserted in chunk_type_75_ef_has_one_variable_length_chunk_per_level, with the
exact sequences [76x8], [92,88,84,92,92,92,84,88], [104x8] and 27 of each.

A near-miss worth recording, because it would have made a tidy false claim: the
leading u16 of these records appeared to step by exactly 0x50 (80) across all nine
of the 76-byte chunk. Reading at the real offsets gives b1a5, a529, 047e, a529,
04f0, a529, 0562, a529, 05d4 -- no progression at all. The apparent 80-byte step
came from comparing the wrong bytes, and "records are indexed by 80" is exactly
the kind of finding that gets reported on sight.

243 tests, clippy clean.
2026-10-13 07:46:57 +02:00
ReaperOfVeriod af62032ddc Most of the chunk container is level-independent: 65 of 74 signatures
Comparing every chunk record against the same record index in the other 26
levels, across all 74 signatures:

    65 of 74   every record byte-identical in all 27 levels
     9 of 74   level-specific
     0 ragged  no signature appeared with different record counts per level

The nine that vary:
    [5d 44 13] [75 42 ef] [7f 44 ee] [82 c4 2f] [96 c3 37]
    [a2 44 a5] [c6 c3 ef] [df c3 ea] [f3 c4 b9]

This reframes the container. 65 of 74 chunk types are static tables the game ships
unchanged in every level -- constants and lookups, not level content. The largest
by record count are all in that group: [38 c4 7e] stride 328 with 90 records per
level, [a9 c4 d8] stride 308 with 90, [3d c4 9e] stride 368 with 77.

The consequence is about method, and it is worth being blunt about. For a
level-specific type, comparing levels is the strongest lever available: a field
that moves with the level is identifiable by correlating it with something known.
For the other 65 there is nothing to compare -- one copy of the data, repeated 27
times -- so they must be decoded structurally, from what the bytes are. A future
decoder that reaches for per-level comparison as its first tool will find it
useless on the majority of the container.

[75 42 ef] is both level-specific AND the one signature with three strides. A
type that is both variable-length and level-varying is the most informative one
here, and is where per-type field decoding should start.

How this was found, including the wrong turn:

The first look at [53 c4 6c d7 9a 43] (stride 108) showed 45 of 108 byte positions
varying WITHIN a chunk and 810 records all distinct -- which reads like per-record
data. Counting distinct values per u32 slot across all 810 records contradicted
that: 29 to 30 distinct values across 27 levels, i.e. about one per level.

Both readings were wrong because they measure different things. The 45 positions
vary between records within a level; the u32 counts were low because the same 30
values recur in every level. Comparing record i across levels settled it: all 30
records are byte-identical in all 27 levels, and zero byte positions vary across
levels.

Had the u32 distinct counts been the only measurement, the conclusion would have
been "one value per level" -- the opposite of the truth, describing a per-level
parameter table that does not exist. The within-chunk and across-level views had
to be taken together to get a true one.

Asserted in most_chunk_signatures_are_level_independent. 242 tests, clippy clean.
2026-10-13 07:43:44 +02:00
ReaperOfVeriod 59dba3e58d Chunk records: the stride table, and the exception pinned by name
Walked every chunk on all 27 levels, grouped by six-byte header signature:

    1,506 chunks, 34,415 records, 74 distinct signatures
    55 distinct strides, 52..640, every one a multiple of 4
    73 of 74 signatures use exactly one stride

The exception is asserted rather than skipped, because "every type is uniform"
is precisely the claim that quietly stops being true:

    [75 42 ef f2 71 b5]   strides {76, 92, 104}

That is the variable-length type the module docs had referred to indirectly. It
is now pinned by signature and to those three strides exactly.

The 4-byte alignment is a result, not an artefact. A stride recovered from one
chunk's first-to-second record delta can inherit alignment from where that chunk
happened to sit; collecting all 55 strides across every type and requiring all
of them to be divisible by 4 rules that out, because a single off-by-one in one
signature would show. Asserted in
chunk_record_stride_is_four_byte_aligned_and_uniform_per_signature.

What this settles is the shape the decoder must handle: uniform stride per type,
4-byte aligned, one named exception. Field offsets within a record are now the
same for every record of a type, which is what makes per-type decoding
tractable. The fields themselves are still unnamed.

A layout that did NOT generalise, recorded so it is not rediscovered:

signature [53 c4 6c d7 9a 43], stride 108, 810 records, 63 of 108 byte positions
constant -- 48 bytes varying, then 60 bytes that are zero in every record, then a
constant 0x00010000. That reads like an inline buffer this type never uses, and
would have made a tidy field story. Requiring `stride == live span + zero run`
across every type with 50+ records per chunk gives 0 of 9. The zero tail belongs
to that one type and means nothing general.

Third occurrence of one editing mistake, and it is worth naming as a habit
rather than an accident: inserting a test before an existing one strands that
test's #[test] attribute on the new doc comment and silently unregisters it.
It has now happened three times, each time surfacing only as clippy's "never
used" and never as a missing test. Fixed by appending new tests at the end of
the file instead of anchoring them to an existing function, and verified by
listing registered tests rather than trusting a count.

241 tests, clippy clean, all five recent tests confirmed registered.
2026-10-13 07:39:35 +02:00
ReaperOfVeriod 040386c0fb Volumes: the reference that did not generalise
Last commit said what would settle the volume candidate and implied one of those
was available. They are not. Recording the failure rather than moving on quietly.

Level 0's data.bin -- the level's file descriptor -- contains the value 799204
exactly once, at 0xab6360, and 799204 is byte-for-byte the offset of the 20-record
144-byte candidate table. A single 4-byte match in 13.3 MB has a chance
expectation of ~0.003, so it was worth checking rather than assuming.

It does not hold up. Each level's own best 144-byte table, searched in that same
level's own data.bin:

    level  candidate   records   refs (chance ~0.0035)
      0    1,810,724      32      0
      1    1,884,884     386      0
      2    2,066,804     224      0
      3    2,438,228     365      0
      4    2,677,780     114      0
      5    1,255,780      14      0
      6    2,496,020     115      0
      7    2,062,072     311      0

None. The level 0 hit at 0xab6360 also sits among high-entropy neighbours, and
level 0's own BEST table -- a different offset, 32 records, not the 20-record one
-- is not referenced either. So the hit does not generalise and is most likely
coincidence.

This retracts what the previous commit implied. Nothing links a 144-byte float
table to volumes: the table is reached by walking structure rather than by an
explicit pointer, so the discriminator I named does not exist, and the candidate
stands exactly where it did.

Recording the three routes that would actually settle it, all of them
unavailable: the game's own volume code (same dead end as the collision anchor
-- a GP-relative string search returns nothing); ground truth, such as a count of
level 0's trigger volumes from a map; or a correlation with a named class, since
gameplay.bin resolves ids through its dispatch records -- but no class name
containing "volume" exists on the disc, which is exactly why this is unavailable.

No test added, because there is no solid claim here to assert. 240 tests,
unchanged.
2026-10-13 07:35:58 +02:00
ReaperOfVeriod ca277a0a01 Volumes: a candidate table found, explicitly not called volumes
Volumes were the only type never located, so this is a search report rather than
a result, and it is recorded as a candidate on purpose.

Ruled out first: no engine-side name exists. No level file contains "Volume"
outside UI text ("Music volume", "Volume de la musique") and Italian item
descriptions. The format cannot be found by label and has to be structural.

gameplay.bin is already-decompressed level data. Its head is a count (336 on
level 0) then 16-byte records from byte 416, carrying (type, pad, offset, id).
The id groups them -- each id has exactly two records pointing into two parallel
offset arrays -- and those offsets resolve into a text pool, the first ones
landing mid-string in dialogue ("it Megacorp Lab"). So that part of the file is
dialogue and class dispatch, not geometry.

The structural hit: a table of 144-byte float records. Level 0 has one at
799204, 20 records of which 19 are populated and one is an all-zero terminator,
with 34-35 of each record's 36 float slots world-plausible and +0x0c holding a
varying world position:

    (206.5, 284.1, 116.0)   (265.0, 445.3, 131.7)   (244.0, 407.2, 128.0)
    (219.3, 247.0, 107.5)   (185.1, 282.6, 104.9)   (260.8, 423.6, 130.9)
    (248.2, 198.4,  51.8)   (185.8, 282.7, 102.6)   (247.0, 211.5, 100.6)

A 144-byte record with world positions and float radii is the shape a volume
trigger region should have, and a matching table is present in every level
examined (0-9), each preceded by an all-zero record, counts 23 to 612.

It is NOT called volumes. Nothing ties this table to volumes. Camera bounds,
spawn points and path nodes would present exactly the same thing: float records,
world coordinates, no label. The discriminating evidence has not been gathered,
and writing "volumes" in the roadmap because the shape fits would be the exact
error this log keeps catching.

Two detector bugs, both mine, both confident before they were caught:

1. The validity test accepted zero as a world-plausible float, so every
   zero-filled region scored perfectly and the cross-level check returned runs of
   225 to 1,514 records that were obviously padding. Requiring non-zero floats
   (>=20 of 36) separated real tables from memory immediately.

2. The 144-byte stride came from four offsets I eyeballed as being 144 apart.
   Gap 144 was not in the top eight gaps of the detector that found them -- it
   survived because three consecutive observations agreed, not because the
   measurement supported it. The corrected detector then found much longer runs,
   which is the opposite of what the eyeball version implied.

Same shape as every other correction here: a clean small plausible number read as
conclusive because nothing was checked against a control that could disagree.

To actually settle it, the table needs to be correlated with something known --
if a count matches a level's visible trigger regions, or if an id in the record
resolves through the class table to a named object, that is the discriminator.
Until then volumes remain open and the roadmap says so.
2026-10-13 07:33:26 +02:00
ReaperOfVeriod 28ab7e094b SBlk head characterised: four constants and one word that is not a count
sound_bank's head was parsed but never characterised. Across all 27 levels the
SBlk region opens identically for its first twelve bytes:

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

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

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

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

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

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

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

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

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

240 tests, clippy clean, both tie vertex tests confirmed present.
2026-10-13 07:29:07 +02:00
ReaperOfVeriod bc5ebe559f Tie slots 3, 6, 7: two readings excluded, and why the LOD test stops working
Having established that i16[0..2] are positions, ran the same LOD-overlap method
across all eight slots to see whether it discriminates elsewhere. It does not,
and that is worth recording before someone reads the passing numbers as nine
confirmations:

    slot       same class   control   ratio
    i16[0]       97.0%       3.0%     32x
    i16[1]       96.7%       3.2%     30x
    i16[2]       96.4%       6.0%     16x
    i16[3]       75.3%      78.7%      1x
    i16[4..7]    68-91%     52-79%      1x

Only i16[0..2] carry signal, because they are the only slots with tens of
thousands of distinct values. The rest draw from sets of 226 to 1,759 values, so
any two sets of that size overlap heavily regardless of what the field means. A
passing result from a method that has run out of discriminative power is not a
passing result.

Slot 3 excluded as a skin/joint index. It holds 201 values in 0..=254 and 50
distinct values within a single draw packet, so it is per-vertex, not per-material
-- which invites the obvious reading, since tie objects are skinned. Wrong:

    mean pairwise distance, same slot-3 value        14,481
    mean pairwise distance, different slot-3 value  13,795
    ratio                                              0.95x
    control: same position-x bucket                   8,105

Vertices sharing a slot-3 value are marginally FARTHER apart than vertices
differing in it. The control halves the distance, so the measurement works; the
0.95x is on the wrong side of neutral, though by too little to claim much on its
own. Combined with no LOD sharing (75.3% against a 78.7% control), slot 3 is
per-vertex, uncorrelated with the geometry it sits in and with the detail level.
Recorded as not understood. A per-vertex animation phase fits the arithmetic and
is labelled a guess rather than offered as a reading.

Slots 6 and 7 are mostly not per-vertex. Distinct values per draw packet, median
over 1,804 records:

    i16[0..2]  13-17    constant for the whole packet  0%
    i16[4,5]   6, 4                                0%
    i16[6]     2                                   43%
    i16[7]     4                                   34%

They take a single value across an entire packet in well over a third of records,
which matches the 91.8%/88.9% dominance of 4096 and 0. Unnamed.

So the tie element is: position, byte-sized index, texcoords, layout tag, and two
slots characterised but not named. Positions, texcoord placement, element size and
the layout selector are all asserted; the remaining three are not, and the reasons
they are not are now written down rather than left as silence.

On "is Phase 2 nearly done": 40 items closed, 9 open. The nine are the hard
ones -- collision cells blocked on the game's own code, volumes not located at
all, moby_anim ruled out but unexplained, chunk record fields unnamed. Progress
on the checklist is real but the remainder is not the easy tail.
2026-10-13 07:24:29 +02:00
ReaperOfVeriod e86de3c73e Tie i16[0..2] are the vertex positions, with a control in the test
The three slots ahead of the index were "unproven position" for want of a test
that could fail. One is available on the disc alone, with the control built in.

LOD 1 and LOD 2 of a tie class are the same object at two levels of detail, so
anything that locates a vertex in that object's own geometry is shared between
the two blocks; a different class' LOD is an unrelated object. Intersecting the
sets of i16[0..2] triples both ways turns "is this a position" into a
measurement. Levels 0-5, ordinary-layout elements only:

    same class, LOD1 vs LOD2      96.1%   107 classes, 107 nonzero
    different class, control       0.4%   2,144 pairs

A factor of 240. Per level the same-class figure runs 94.4 / 97.9 / 98.1 / 96.7 /
96.8 for levels 0-4. The control stays under 1.0% except for one level-1 pair at
45.9% -- two classes sharing most of their geometry, which is what duplicate or
variant models should look like. Reported, not excluded, and the assertion is on
the mean rather than the max so it does not have to be.

Asserted in tie_vertex_slots_0_to_2_are_shared_positions, including the ratio
(same > 20 * diff) so a change that collapsed both numbers fails rather than
passing twice, and a nonzero-overlap check so no class can silently stop
agreeing with itself.

What this rules out is the point. An index, a handle or a per-vertex counter
would not be shared at 96% between two independently built LOD meshes, nor sit at
0.4% between two objects. With 10.7k-13.2k distinct values per slot over the
full i16 range, these are positions in the object's own frame. Scale and axis
order stay unnamed -- nothing here says centimetres, and axis order is
unconstrained because nothing here assumes one.

Two things worth recording about the process:

1. The diagnostic line printed "1.0%" for 99.4% because the format was
   `{same:.1}%` with no `* 100`. The assertions compared fractions and were
   always correct; only the readout was wrong. Same family as everything else in
   this log -- the readout disagreeing with the claim.

2. Inserting the new test in front of an existing one stranded that test's
   #[test] on my doc comment, which silently unregistered
   tie_vertex_element_is_16_bytes_with_fixed_texcoords. It showed up as
   "never used" in clippy, not as a missing test. Restored; both tie vertex
   tests are registered and 239 tests run.

Element layout now reads:
  +0x00 i16[0] position x     +0x06 u8 index 0..=254
  +0x02 i16[1] position y     +0x07 u8 layout tag, 0 in 93.4%
  +0x04 i16[2] position z     +0x08 i16[4] texcoord u
  +0x0a i16[5] texcoord v     +0x0c/+0x0e i16[6], i16[7] unnamed
2026-10-13 07:21:57 +02:00
ReaperOfVeriod 06cf2fd46d Tie vertex slot 3 is a byte-sized index, not a coordinate
Splitting the 95,612 elements of levels 0-2 by i16[3]'s HIGH byte, which is the
layout selector:

    0x00   89,341   93.4%
    0x03    5,344    5.6%
    144 other selector values   927    1.0%

146 selector values in all. Not a two-way switch, and the second case is not 0x01.

Inside the ordinary layout i16[3] is not a coordinate: 201 distinct values, all
within 0..=254, with 0 most common. That is an index. So the selector and the
index are not two independent halves sharing a slot -- it is one byte. The low
byte is the index, the high byte is the layout tag, and the ordinary layout never
sets the high byte. Asserted.

Nearly got this wrong twice, both times the same way, which is why they are in
the commit message:

1. The first pass gated the range on `w >= 0` instead of on `(w >> 8) == 0`,
   letting in the other layouts' large positive values. That reported "564
   distinct values in 0..=32664" -- a number that looks exactly like a
   coordinate and is really two populations unioned. The selector byte is the
   filter. The filter is part of the claim.

2. Two constants were guessed rather than measured (selector cardinality 159,
   and an upper bound that ignored non-ordinary layouts) and both were wrong.
   The cardinality is 146. Guessing a constant and then asserting it is how a
   measurement becomes a tautology.

Quantified the i16[6]/i16[7] question while I was in here, because "both are
multiples of 4096" has been sitting in the docs as a fixed-point signal:

    both %4096==0 in 86.5% of elements
    expected if the two residues were independent: 84.3%
    enrichment 1.0x -- exactly chance

They look like a fixed-point pair only because their dominant values, 4096 and 0,
are both multiples of 4096. Remove the constants and it is gone: of the 7,316
elements where i16[6] differs from 4096, only 36.7% are still multiples, and of
the 9,905 where i16[7] differs from 0, ONE is. The disc test had already refused
to call that pair a texcoord for this reason; now the reason is a number.

Remaining slots in the ordinary layout, recorded: i16[0..2] read as position but
are unproven (10.7k-13.2k distinct each, full i16 range), i16[4]/i16[5] are the
texcoords, i16[6]/i16[7] unnamed.

Also fixes two clippy map_or lints and an import that a stray --fix had broken.
238 tests, clippy clean.
2026-10-13 07:18:13 +02:00
ReaperOfVeriod 1fa1913e38 Reconcile the docs after the last three corrections
The last three commits changed things the documentation still asserted in the
old direction. Fixed:

roadmap
  * 227 tests -> 238.
  * The level_overlay bullet contradicted itself: it said "the header words are
    not named" in the same breath as "+0x00 load base is 0x1A7C80". Now says
    +0x00 IS the load base and is named, +0x0c is excluded as the .lit size by
    sign (below file length on 16/27, by up to 8.9%) and remains unnamed, and the
    unnamed remainder is stated as such.
  * Section names aligned with the Phase 3 list (lvl.vtbl, lvl.camvtbl,
    lvl.sndvtbl).

core-data-regions.md
  * The claim-assertion audit still said the two PCSX2 claims were "NOT
    assertable here -- needs a savestate". They are asserted now, gated on
    GC_SSTATE, in the same file. Updated, with the two corrections that fell out
    of writing them recorded right there.
  * "that string is inside the collision routine" was never established. It is
    in the string pool, yes; the reference to it is not found. Reworded to say
    so, including the zero-hit result.

research log
  * field_00 now carries its name and the .lit/$gp/program context.
  * The field_0c falsification and the byte-order retraction are both recorded
    here as well, so the log and the format file agree.

Nothing left asserting the withdrawn anchor, and no stale test counts.
2026-10-13 07:13:45 +02:00
ReaperOfVeriod 282e633a7c Withdraw the collision anchor: it was a byte-order bug in my own search
Last commit claimed exactly one GP-relative occurrence of the collision assert
string's $lo half, at 0x354cc8, and called it an unambiguous anchor. That was
wrong, and building the disassembler is what proved it.

The search compared word bytes 1..3 against the 16-bit immediate. The PS2's MIPS
is little-endian, so an I-type immediate sits in bits 15..0 -- word bytes 0 and
1. Bytes 1..3 are the opcode, $rs and $rt fields. The match was on those.

The instruction at the phantom hit is 0x24a89948:

    addiu $t0, $a1, -0x66b8

whose real immediate is 0x9948 -- ten bytes from the 0x99a8 being searched for.
-0x66b8 merely looks like a plausible $gp offset, which is what made it
convincing. With the byte order corrected the count is ZERO, in 2,751,268 bytes.

So the anchor is not established and the assertion is withdrawn. What survives is
still worth having: the functions are named (Camera_CollPrimTest takes (prim,
grid)), $gp = 0x1AEFF0, .lit loads at 0x1A7C80, and the assert string is a unique
copy at overlay+0xd18.

Probably the code reaching that string does not address it through $gp, or
reaches a copy in another section. The decomp's assert-message-names.tsv gives
0x002db350 for level 0 -- inside .lit, but it disassembles to a function epilogue
(jr $ra / addiu $sp, $sp, 0x70), so those are code locations, not string
addresses, and they are not traced to the assert either.

tools/overlay-anchors now does the search with the correct byte order and
carries a comment on exactly the part that was wrong, because that is the part
that will be re-derived otherwise. It also disassembles around an address:

    cargo run -q -p gc-overlay-anchors -- <overlay.bin> --disasm 0x2db350 0 64

The useful general lesson, which the roadmap already says and this is a second
demonstration of: a probe that returns a small, clean, narratively satisfying
number is the most dangerous kind of wrong result. "Exactly one hit in 2.75 MB"
read as conclusive. It was an artifact of comparing the wrong two bytes, and it
survived a commit because it fit the story.
2026-10-13 07:10:17 +02:00
ReaperOfVeriod 1f903efe23 An external decomp turns two overlay constants from measured into named
OpenRAC/rac2-gc-decomp is the same disc as ours, verified rather than assumed:

  ours    3,828,350,976  9db2e33e...b1ce5
  theirs  3,828,350,976  9db2e33e...b1ce5

Vendored read-only under vendor/ (already gitignored) and added to PROVENANCE.

It names two constants this parser had measured but never explained:

  * overlay.bin +0x00 = 1,735,808 = 0x1A7C80, the .lit load base. It is constant
    across all 27 levels because every level's literal section loads at the same
    address. So the first header word is the load base of the section the file IS,
    which also establishes that overlay.bin is that section -- it holds both code
    and the debug string pool. $gp = 0x1AEFF0 comes with it, and that closes the
    roadmap's open "$gp for this game" item.

The program around it is seven sections -- .lit .bss .data vtbl camvtbl sndvtbl
.text -- which is precisely the "seven-section parser and relocation" Phase 3
item for this format. Known target now rather than a guess.

I then tested the obvious follow-up and it is wrong: if +0x00 is the .lit load
base, +0x0c ought to be the .lit size. It is SMALLER than the file on 16 of 27
levels, by up to 8.9% (level11: file 3,151,128 vs 2,870,792). Excluded by sign,
not by a near miss. Still unnamed.

Collision: still blocked, but no longer blocked on tooling. The decomp names
Camera_CollPrimTest(void *prim, void *grid) without decompiling it, so the cell
layout is not handed over. What it does supply is $gp, and that is enough to
start. "Camera_CollPrimTest WARNING! - grid out of bounds! (RAR)" is at
overlay+0xd18, the only copy in the file, so:

    string  overlay+0xd18 -> RAM 0x1a8998;  delta to $gp -0x6658;  $lo 0x99a8

MIPS puts the $lo half in bytes 1..3 of a 4-byte instruction. Searching the whole
overlay instruction-aligned gives exactly ONE hit:

    0x354cc8    (overlay+0x1ad048)

One occurrence in 2.75 MB of code is unambiguous: that is the instruction
materialising the assert string, and its enclosing function is
Camera_CollPrimTest. That is the route in, and it needs no symbols.

tools/overlay-anchors does this step, dependency-free. I started this with
capstone for the disassembly proper and dropped it: capstone 0.14 does not
re-export Mode/Endian from a public path, and an excluded-from-the-workspace
native dependency is a poor trade for a step that does not need one yet. The
anchor search is pure byte work, so it is pure Rust. No native dependency is
added anywhere, and the tool is an ordinary workspace member.

Surveyed the rest of that project against our open items: no volume structures,
no tie vertex layouts, no SBlk/DAW fields, no wavelet tables. It is a guide to
the code, not a replacement for reading it.
2026-10-13 07:07:21 +02:00
ReaperOfVeriod 26a0918178 Gate the PCSX2 claims as tests; two corrections fall out of writing them
The audit two commits ago listed three claims as "not assertable here -- needs a
savestate". With PCSX2 running they are assertable, so they now are. gc-trace
already exposed gc_trace::Savestate::ee_ram_owned(); gc-extract just needed it as
a dev-dependency. Both tests are gated on GC_SSTATE and skip cleanly without it,
so a normal checkout pays nothing.

Writing them corrected two claims, which is the main reason they were worth
writing as tests rather than staying prose.

1. The collision payload is NOT "byte-for-byte identical". The original claim
   rested on a 4 KiB probe; the test compares the whole 903,248-byte payload:

     payload                903,248 bytes at RAM 0xac6800
     first difference       +0xdc82c, i.e. 903,212 bytes in
     bytes that differ      6  (0.00%)

   903,212 of 903,248 bytes are verbatim and 6 are not, all in the final 36.
   "Verbatim" was an overclaim on a probe that never looked at the tail. The test
   now measures the whole payload and reports the extent, asserting only that the
   divergence is beyond the head.

2. The tfrag relocation base is the TABLE address, not the block base. The delta is
   8,683,584 = 0x848040 = block base 0x848000 plus table_offset 0x40 -- which is
   exactly what TfragHeader::data's own documentation says, "relative to the
   table". The earlier claim said "the block's RAM base", off by exactly the table
   offset.

   Also: +0x10 and +0x38 do NOT share one constant. The aggregate "1,665 fields
   relocate by one constant" was true only because +0x10 carried it; lumping them
   hid that. The test asserts +0x10 and REPORTS +0x38's delta rather than assuming.

The patch-rewrite figures also restated on aggregate rather than median, which is
why they read lower than the 7.80%/8.12% medians recorded earlier:

  patch bytes rewritten -- cannot morph 1.9% of 334, can morph 2.1% of 499

Same conclusion, stated on a total instead of a per-patch median, and asserted to
within 3 percentage points of each other.

Net: the audit table in docs/formats/core-data-regions.md now has no "prose only"
rows for disc-or-savestate-readable claims.
2026-10-13 06:59:22 +02:00
ReaperOfVeriod 9f938f47de Assert the tie vertex layout finding in a test, and audit which claims are asserted
The i16[3] high-byte finding -- the most recent and most subtle result of this
stretch, and the one I corrected twice -- lived only in prose. Two commits is
exactly how long a claim can sit unasserted before it drifts. It now has a test:

  tie_vertex_layout_is_selected_by_i16_3_high_byte

which reproduces the figures exactly and, importantly, asserts both directions:
(4,5) and (6,7) are above 0.85 and 0.80 in the ordinary group, (6,7) is BELOW 0.99
so it cannot silently become the constant I once mistook it for, (0,1) and (2,3)
are under 0.20, and every pair in the non-zero group is under 0.15. A future
change in either layout now fails loudly.

Also audited the rest of this session's claims against the test suite and recorded
the result in the format doc:

  tie vertex element is 16 bytes             asserted
  layout selected by i16[3] HIGH byte       asserted (this commit)
  blocks self-delimiting on 0xf6            asserted per-block
  packets == 0 means no block at that LOD   asserted
  packets[0] == 256 on 1,864 of 1,864       asserted
  chunk record length uniform per chunk      asserted
  moby class IDs in one body slot per type  asserted

  collision payload verbatim in RAM         NOT assertable here -- needs a savestate
  tfrag patch bytes all rewritten by LOD    same
  morphing is unobservable in that image    same

The split is deliberate: disc-readable claims are asserted against the disc,
savestate-dependent ones live in plan/pcsx2-verification.md with their method.
What is not acceptable is a disc-readable claim living only in prose -- which is
how the tie i16[3] finding spent two commits before it had a test, and how several
older claims drifted before the roadmap reconciliation caught them.
2026-10-13 06:54:52 +02:00
ReaperOfVeriod 922248a4ec Tie: the element layout differs by i16[3] high byte, and (6,7) is a real pair
Two results, one of them a correction.

1. "(6,7) is just the constant 4096/0 pair, not a texcoord" -- wrong. It looked
   like an artifact because a constant is trivially a pair of multiples of 4096.
   But partitioned by i16[3]'s high byte it is a genuine fixed-point pair in its
   own right:

     i16[3] high byte ZERO (n = 89,341)        divisor rate by slot pair
         pair         /1      /4     /16    /256
         ( 0, 1)   100.0%  11.5%   4.6%   3.3%   baseline
         ( 2, 3)   100.0%  11.0%   4.5%   3.3%   baseline
         ( 4, 5)   100.0%  92.9%  92.0%  91.8%   the texcoords
         ( 6, 7)   100.0%  89.5%  87.3%  86.5%   a SECOND fixed-point pair

   If (6,7) were a constant it would score 100%, not 89.5%. The constant is the
   common case WITHIN it, not the whole of it -- which is exactly the distinction
   the constant-artifact lesson is about, and I applied it backwards last time.

   So the ordinary element carries TWO fixed-point pairs, (4,5) and (6,7), with
   (0,1) and (2,3) at baseline. What (6,7)'s role is remains open -- second UV
   set, normal, or a zero-heavy fixed-point pair all fit.

2. The element LAYOUT differs between the three cases. Scoring all four slot
   pairs within the non-zero high-byte group, no pair stands out:

     i16[3] high byte NON-ZERO (n = 6,271)
         pair         /1      /4     /16    /256
         ( 0, 1)   100.0%   4.6%   3.5%   0.7%
         ( 2, 3)   100.0%   2.0%   0.6%   0.0%
         ( 4, 5)   100.0%   1.9%   0.3%   0.1%
         ( 6, 7)   100.0%   7.5%   5.7%   4.5%

   Best is 7.5% against a 2-5% baseline. So for those 6,271 vertices the 16 bytes
   are not laid out as position/texcoord/normal at all -- a different element
   shape, selected by the high byte.

This also explains why the global slot-pair score looked ambiguous to begin with:
pooling the two layouts averages a 92% pair and a 7% pair into one number that
describes neither. Fourth time this session that partitioning produced the answer
rather than merely bounding it.
2026-10-13 06:53:22 +02:00
ReaperOfVeriod ed3e85a0f2 Refine the tie texcoord finding: it is i16[3]'s HIGH byte that selects
The previous commit said i16[3] selects the texcoord encoding. That was right but
loose -- it is specifically the HIGH BYTE, and the low byte is a separate thing.

Grouped by the high byte across the same 95,612 vertices on levels 0-2:

  i16[3] high byte        n        % / 16      % / 256
  0x00                89,341        92.0%        91.8%
  0x03                 5,344         0.0%         0.0%
  0x04                    71         0.0%         0.0%
  0x40                    58         5.2%         0.0%
  0x02                    47         2.1%         2.1%
  0x19, 0x05, 0x20, ...  the remaining 73

A high byte of zero is the ordinary case -- 89,341 of 95,612, 93.4% -- at 92%.
EVERY other high byte scores at or near zero. The split is on the byte, not on the
value: i16[3] of 1 and of 771 differ only in the high byte and behave completely
differently.

Within the all-zero case there is a third distribution: i16[3] == 0 exactly (5,851
vertices) scores 62% at 16 and 61% at 256, against 92% for 1..255.

So three cases, selected by the high byte and then the low:

  high 0x00, low 0x00    5,851    a third distribution, 62% at 1/16
  high 0x00, low 1..255 83,490    the ordinary case, 92% at 1/4096
  high non-zero         6,271     0%, an encoding this reading does not see

The 6,271 matches the figure quoted last commit for "vertices using an encoding
this reading does not see", and the two group counts sum consistently with the
committed test's 95,612 -- so this refines the finding rather than changing it.

A measurement trap worth recording: the all-zero case is a SUBSET of high byte
0x00, so summing the per-high-byte groups double-counts it. An intermediate total
came out at 100,792 against the true 94,941 for those groups; the committed test's
figure is the one to trust.
2026-10-13 06:50:08 +02:00
ReaperOfVeriod e27c40e738 Explain the tie texcoord residual: i16[3] selects the encoding
The texcoords are 12-bit fixed on 82.5% of vertices, not 100%, and the residual
had no explanation. Grouped by the vertex's i16[3] slot across 95,612 vertices on
levels 0-2, the buckets separate completely:

  i16[3]          n        % / 4096      % / 256
  negative        333        1.2%          1.2%
  == 0          5,851       29.2%         60.7%
  1..63        28,884       92.7%         94.3%
  64..255      54,606       92.3%         93.8%
  256..511          8        0.0%          0.0%
  512..1023      5,391        0.0%          0.0%
  >= 1024         539        0.2%          0.2%

The residual is fully accounted for. 87.3% of vertices sit in 1..255 at about 92.5%
fixedness, contributing 0.873 * 0.925 = 80.8 points; the 5,851 at i16[3] == 0
contribute 1.8 more. That is the 82.5% measured, to the point.

The remaining ~6% are not 12-bit fixed at all. i16[3] >= 256, and negatives, score
0% at BOTH 4096 and 256 -- 6,271 vertices in an encoding this reading does not
see. And i16[3] == 0 scores 29.2% at 4096 but 60.7% at 256, which is a third
distribution rather than a weaker version of the same one.

What this establishes: i16[3] is a per-vertex packed attribute whose value SELECTS
HOW the texcoord pair is stored, rather than an independent field sitting beside
the texcoords. 1-255 is the ordinary case, 0 is special, 256-and-up and negatives
are a different encoding. What those three cases MEAN is still open -- texture
index, normal index, flag or a combination -- but it is now a field whose role is
known, which is more than "packed word".

Worth noting the shape of the claim this replaces: "the texcoords are 12-bit fixed
at i16[4]/i16[5], 73.6% of the time, unexplained". The field position was right;
the 73.6% was a population average over three encodings. Partitioning by the one
variable that separates them turns a mystery into a lookup -- rule 3a again, and the
third time this session that partitioning a mystery produced the answer rather than
merely bounding it.
2026-10-13 05:19:06 +02:00
ReaperOfVeriod 3ee0b3d0dc Disprove that morphing causes the tfrag byte changes: LOD selection does
Read the whole tfrags block from RAM -- 4,379,200 bytes at 0x848000 -- and compared
every one of the 833 patches against its disc copy, grouped by whether it CAN morph:

  patches with NO morphing entries   334   differing: max 10.59%, median 7.80%
  patches WITH morphing entries     499   differing: max 10.49%, median 8.12%
  patches 100% identical to disc   0/334 and 0/499

The two groups are indistinguishable. Morphing is NOT what changes the bytes, and
this disproofs the claim two commits ago that the 8.6% was "morphed positions with
topology and UVs left verbatim". If it were, the 334 patches with no morphing
entries would be untouched -- and none of them is.

Nor is it a pointer relocation: the differing bytes fall in short structured runs
(257-263, 272-277, 305 on patch 0) whose u32 deltas share no common value.

The explanation consistent with everything measured is that the loader REGENERATES
each patch's VU packet stream for the LOD tier it selected. That happens to every
patch, morphing or not -- and it is why morphing shows no signal.

This also settles the verification route, which the previous commit left as "needs
the camera distance at the savestate". It does not, and no camera distance would
help: this memory image shows the LOD-selected form rather than the morph state,
so there is nothing in it to solve a weight against. Verifying the derivation needs
a captured frame at a known view distance instead.

What is established now, all measured on the full population rather than inferred
from one patch:
  - data and cube_ofs are relocated pointers, delta 0x848000, on all 833 records
  - every patch's bytes are rewritten, ~8%, by LOD-driven packet regeneration
  - morphing is not observable in this memory image
  - 833 of 833 patches parse; 499 of 833 have morphing entries; 43,613 vertex-info
    entries across the level
2026-10-13 05:14:40 +02:00
ReaperOfVeriod aff977e8c5 Correct the tfrag pointer-field claim, and show morphing regenerates VU packets
Two corrections to the previous commit, both from continuing the RAM comparison.

1. The previous commit said the RAM comparison "names two fields the disc-side
   work had left as data and an unnamed word". It does not. +0x10 is
   TfragHeader::data and +0x38 is TfragHeader::cube_ofs, both already named in the
   struct. What RAM adds is that both are POINTERS -- relocated by adding the
   block's RAM base -- rather than plain offsets. That is still worth knowing, but
   it is not a naming.

2. The previous commit attributed the 8.6% of rewritten bytes to "morphed
   positions with topology and UVs left verbatim". That is wrong, and patch 1 shows
   why. Comparing 4 KiB, disc 0xdf60 against RAM 0x855f60:

     bytes differing      354 of 4,096 = 8.6%
     contiguous runs      120
     longest run          8 bytes
     extent               +0xc1 through the end of the window

Patch 1 has 83 positions and 83 vertex-info entries, of which 13 morph. If
morphing rewrote position fields alone, the differing bytes would be a 13/83 slice
of a 498-byte position array -- a couple of dozen bytes in one contiguous region.
Instead 354 bytes differ, spread over 120 short runs across the whole window.

So the loader is not patching positions in place: it REGENERATES the VU packet
stream for the current morph state. That is what morphing is on this hardware --
VU1 GIF packets, not a CPU-side vertex array.

Consequence for verification, and the reason this is worth committing rather than
just noting: the plan of "map RAM vertices to disc indices and solve for the weight"
does not work against this memory image. The positions are inside regenerated
packets, so there is no stable per-vertex correspondence to read a weight out of.
Verifying the morph weight against the original needs the CAMERA DISTANCE at the
savestate, so the weight can be computed from the derivation and compared with the
rendered result, rather than read back out of RAM.

Population figures gathered on the way: 833 of 833 patches parse, carrying 43,613
vertex-info entries; 499 of 833 patches have at least one morphing entry, and
patch 0 -- the first record -- is base-only with none.

Also fixed a measurement error mid-run: I compared patch 1 at disc 0xdf60 against
RAM 0x855f00, off by 0x60, and got "every byte differs". The corrected base is
0x855f60.
2026-10-13 05:08:08 +02:00
ReaperOfVeriod 93591a79de Verify the tfrag record framing against RAM: two pointer fields, morphed vertices
The tfrags block header -- table_offset 0x40, tfrag_count 833, lod_distance 32.0
-- hits once in the level-0 savestate at RAM 0x848000, so disc offset 0 maps to
RAM 0x848000 and the block can be compared position for position.

Result 1: exactly two fields per record are rewritten. Comparing all 833 64-byte
table records field by field:

  records compared        833
  u32 fields differing    1,665 = 833 x 2, and no others
  their offsets           +0x10 and +0x38, on every record
  relocation delta        8,683,584 = 0x848000, on 833 of them

So +0x10 and +0x38 are block-relative pointers and the loader adds the block's
RAM base to each. Every other byte of all 833 records is verbatim. Six fields
differ by 0xF80000 and 0x1F00000 instead -- three records pointing into a
different buffer, recorded rather than averaged.

This names two fields the disc-side work had left as `data` and an unnamed word.

Result 2: behind the pointers, about 8.6% of bytes are rewritten. Following
record 0's +0x10 to disc 0xd040 / RAM 0x855040 and comparing 16 KiB: 14,977 bytes
identical, 1,407 differ = 8.6%. The differences spread across many field positions
rather than concentrating, which is what morphed POSITIONS look like -- topology,
UVs, counts and command lists stay verbatim while a fraction of the position bytes
move. 8.6% is about half of a 16-byte vertex's six position bytes, consistent with
roughly half the vertices in this patch mid-morph at the saved camera distance.

Not established: that the morphed bytes match resolve_vertex's formula. That needs
mapping RAM vertices to disc vertex indices and solving for the weight, which is
the next step -- but the framing is now known well enough to attempt it, and the
two pointer fields are confirmed rather than inferred.

This also answers the "is morphing real?" question that made the earlier tfrags
probe fail to match RAM: the tfrags block is not verbatim in RAM, because both its
table pointers are relocated and its vertex positions are morphed.
2026-10-13 05:04:25 +02:00
ReaperOfVeriod 6d0c1b6fd1 Close out tfrag on the disc side, and fix two stale "not recovered" claims
Picked tfrag morph targets from the open list and found the premise was wrong.

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

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

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

Two stale claims corrected:

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

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

The honest replacement for both is what is genuinely not claimed: the LOD switch
distances, morph weight and collapse edge are DERIVED from the data -- tiers are
classified from the four V4_8 parent arrays, the weight runs zero to one across
each tier's band -- but have not been checked against PCSX2 the way the lighting
pass was. That is a verification gap, not a parsing gap, and it needs a frame
captured at a known view distance rather than more disc-side measurement.
2026-10-13 04:50:28 +02:00
ReaperOfVeriod d56b439502 Exclude the collision segmentation theories on the full population, and name the
unblock

Continuing on the verbatim payload with the grid known.

Cells are not zero-separated. Counting maximal runs of non-zero u16 across the
whole payload:

  level 0   363 cells     15,115 runs     mean run 25.3 u16
  level 1   384 cells     24,647 runs     mean run 51.5 u16
  level 2    53 cells     31,512 runs     mean run 30.3 u16

Two to three orders of magnitude apart, so a zero run is not a cell boundary.
Cells are not uniform blocks either: level 0 averages 2,488 bytes per cell and
level 2 averages 43,684, which cannot both be one fixed-size record.

The run lengths are not records. Their histogram has a long tail of
near-power-of-two values -- 2,140, 1,184, 840, 816, 628, 604, 562 -- which is
what you get when a run is bounded by a single zero byte inside otherwise dense
data, not by a delimiter. With the 18%-zeros finding, the payload reads as dense
data with sparse holes rather than a delimited sequence.

So all three segmentation theories are now excluded on the full population rather
than on a sample: not ascending u32 tables, not zero-run delimiters, not fixed
stride.

What would actually unblock this: overlay.bin IS the game's own code, and the
collision parser is in it. Camera_CollPrimTest WARNING! - grid out of bounds!
(RAR) was located in the debug string pool earlier, and that string is inside the
collision routine. So the format is recoverable -- but not by measuring bytes. It
needs disassembly of that routine, or an R&C2 reference for the collision cell
record.

Stating that plainly is worth more than another scan. The disc-side measurement
space for collision is exhausted: the bytes are available, verbatim, the grid is
known, and three independent segmentation theories have failed on the whole
population. Further probing without the loader's own logic will not converge.
2026-10-13 04:46:10 +02:00
ReaperOfVeriod 338af9b7bd Fix the collision grid dims, and disprove two sample artifacts in the payload
With the payload confirmed verbatim in RAM, the disc bytes can be read directly.
The grid dimensions are the first two u16 of the payload: level 0 is 11 x 33 =
363 cells, level 1 24 x 16 = 384, level 2 1 x 53 = 53. Level 2 being one cell wide
is worth noting -- the grids are not all similar in shape.

Two readings disproved, both by measuring the whole payload instead of a sample.

An ascending u32 offset table. A probe from payload +1024 reads f4ae0000 40af0000
c4b2000020b20000, which is three ascending values before the fourth goes down
(0xb2c4 to 0xb220). Read as a table, that was a three-value impression. Scanning
all three levels for maximal ascending u32 runs of 8 or more entries finds zero,
on every level.

A global alternating-zero stride. The head of level 0 shows an obvious (value, 0)
pattern from about i16[37]:

  [32..] [1808, 1872, 2198, 0, 34, 86, 25848, 1, 25824, 1,
         -13028, 0, -12284, 0, -12228, 0, -12716, 0, ...]

Measured over the whole payload, both alignments score far below what that sample
suggests: even-slot aligned 18% second-half zero, odd-slot aligned 13%. The
sample again.

What the global numbers do say is that the payload is heavily zero-bearing -- 18%
against a chance rate of about 0.0015% -- which is what a sparse or variable-length
encoding looks like, and consistent with the variable-length reading established
earlier. The 18-against-13 split is a real alignment preference but a weak one.

The cell layout is still undecoded. The useful position now is that the bytes can
be read as they are and that they are sparse and variable-length rather than
fixed-stride, so the next attempt should look for a terminator-delimited walk
through the sparse region rather than for a record size.
2026-10-13 04:44:09 +02:00
ReaperOfVeriod d5e637930b Show the collision payload is verbatim in RAM, and retract the "not in RAM" claim
This overturns the premise collision work was blocked on.

Searching the level-0 savestate for a probe from collision payload +1024 hits at
0xac6c00; a probe from +64 hits at 0xac6840. The difference is 0x3C0 = 960, which
is exactly 1024 - 64, so the payload sits at a fixed relative address -- RAM
0xac6800 for the disc address 0x27e800 -- and reading it back is byte-for-byte the
disc's own bytes:

  RAM  0xac6800  0b 00 21 00 b6 08 ad 08 a4 08 6b 08 38 08 63 07
  disc 0x27e800  0b 00 21 00 b6 08 ad 08 a4 08 6b 08 38 08 63 07

Nothing is reshaped. That is the opposite of the tie block, where the loader
relocates a pointer inside each record.

The retraction. The previous note reads: "level 1's 24 x 16 grid dimensions
appear in memory, level 0's 11 x 33 appear nowhere, so disc-side decoding has to
work against the reshaped form." Level 0's dimensions ARE in RAM, at 0xac6800 --
0b 00 and 21 00 are the first two bytes of the block, 11 and 33. The search that
missed them looked for 0b 00 00 00 21 00 00 00, the dimensions as a pair of u32s.
They are stored as two u16s, so that pattern never occurs.

So the negative result was an artifact of the search pattern, not evidence about
the data -- and it was then used as a premise for several rounds of disc-side
work. Both lessons were already written down in AGENTS.md and both were re-learned:
a negative from a byte search bounds the search, not the format; and the control
here is one command -- read the predicted RAM address and compare it to the disc --
which would have shown the verbatim copy immediately.

Also recorded while mapping it: the payload contains ascending u32 offset tables
from +1024 onward, and its first two bytes are the grid dimensions, so level 0's
grid is 11 x 33.

The cell layout itself is still undecoded, but the blocker is gone: disc-side
decoding no longer has to work against a reshaped form.
2026-10-13 04:40:01 +02:00
ReaperOfVeriod 180fc27b04 Locate the tie texcoord field at i16[4]/i16[5], and rule out the (6,7) artifact
Scoring each adjacent i16 slot pair of the 16-byte tie vertex for 12-bit
fixedness over 36,501 level-0 vertices locates the fields:

  (i16[0], i16[1])    1.1%   not a texcoord
  (i16[2], i16[3])    4.8%   not a texcoord
  (i16[4], i16[5])   73.6%   <- the texcoords
  (i16[6], i16[7])   76.6%   <- the near-constant 4096/0 pair

(6,7) scores HIGHER and is not a texcoord. It is the near-constant 4096, 0 pair:
4096 is 1.0 in 12-bit fixed and 0 is zero, so both are trivially multiples of
4096. That is the constant artifact again -- a fixed pair would score 100%
anywhere. The field is (4,5), with (0,1) and (2,3) ruled out at 1.1% and 4.8%.

The first four slots span the full i16 range with 10,803 to 13,421 distinct values
each, so they are neither texcoords nor small fields.

The vertex base is confirmed to be vert_ofs alone. Adding the shader or control
region makes it worse --

  vert_ofs only                               73.6%
  bfc + vert_ofs                              69.8%
  bfc + control_count*control_size + vert_ofs  66.7%

-- so the 73.6% residual is not a mislocated base. Worth recording because
vert_ofs (3..7) numerically overlaps the shader region (5*shader_count, 5..20
quadwords), which made a combined base look plausible; measurement says the
simple one is right.

The test asserts (4,5) above 60% and (0,1)/(2,3) below 5%/15%, and names the
(6,7) artifact in its failure message so the higher-scoring pair cannot be
mistaken for the answer later.
2026-10-13 04:30:45 +02:00
ReaperOfVeriod 35b087c882 Establish the tie vertex element is 16 bytes, and correct the roadmap's tie entry
With TieRecord identified as RAC1's TiePacketHeader, each record's leading word
points at its packet in the block payload. Following it and stepping vert_size
elements from vert_ofs reaches the vertices: 95,612 of them on levels 0-2.

Element size settled by comparison, not assumed. Requiring the texcoord pair to be
12-bit fixed -- both words multiples of 4096, which is how 0x1000 = 1.0 reads:

  element 16 bytes : 78,919 / 95,612 = 82.5%
  element  8 bytes : 45,661 / 95,612 = 47.8%
  element 12 bytes : 44,834 / 95,612 = 44.8%

Sixteen wins by a factor of 1.7 over the nearest alternative.

Observed layout, 8 x i16: the first three slots span the full i16 range with
10,803-13,421 distinct values each; i16[4] and [5] are the texcoords and are both
multiples of 4096 on 82.5%; i16[6] reads 4096 on 85.8% and i16[7] reads 0 on
84.0%.

Not established: what any field means, and why the texcoord rate is 82.5% rather
than 100%. The residual tracks record shape -- (shaders 2, control 7, size 2)
hits 60%, (shaders 1, control 18, size 5) hits 89% -- so it is not one bad block
but something per-packet this reading does not capture.

Worth recording that this is the OPPOSITE of what the disc alone concluded two
commits ago. Reading the header word at +0x00 as an element count gave 0 of 922
blocks dividing; it is the byte offset to the payload, so the table it bounds has
16-byte records. The number was right and the reading was wrong, which is what
the packet-header identification corrected.

The roadmap's tie entry was also stale -- it still said "a 16-byte record is
withdrawn", which the packet-header work had already reversed. Rewritten from the
commits: both indirections, self-delimiting blocks, the TiePacketHeader
identification with its 15,219/15,219 rule, the 16-byte vertex element, and what
remains open. A test asserts the element size against both alternatives so the
82.5% figure cannot be quietly replaced by an assumption.
2026-10-13 04:27:54 +02:00
ReaperOfVeriod 54862b64ee Identify the tie record as RAC1's TiePacketHeader, and retract a formula
The 16-byte tie record is R&C1's TiePacketHeader offset for offset: a data
pointer at +0, then R&C1's byte fields at +4 through +0x10. The identification
is not a resemblance but R&C1's own rule -- bfc_distance == 5 * shader_count --
holding on all 15,219 records of all 2,177 blocks on all 27 levels.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Measured, in order:

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

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

Chunk gains `prefix: Option<u16>`, and the disc test asserts the uniformity,
the per-type fixity and the value set. The scratch example that produced the
per-type class-ID table is dropped now that the same scan is a committed test,
which also lands here: moby class IDs sit in one body slot per chunk type, with
the slot varying by type and the distinct >= 4 filter load-bearing -- without it
the top slots are constants that read as 305x because any value below the moby
count counts as a hit.
2026-10-12 06:00:29 +02:00
ReaperOfVeriod 8147110377 Bring the roadmap's chunk section up to date
Four commits of chunk work -- uniform record length, the leading-byte phase
counter, the two-byte record index, and the PCSX2 RAM findings -- were written to
docs/formats/core-data-regions.md and docs/plan/pcsx2-verification.md but never
reached the roadmap, because the scripted replacements kept missing their target
text and I did not check that they had applied.

That is the staleness complained about earlier recurring through a different
route: not a missing edit, but a silently-failed one. The tie, collision and
level_overlay entries had landed; only the chunk ones had not.

Rewritten from the commits rather than from memory, and the numbers are the ones
the tests assert: 1,509 chunks, 34,491 records, uniform length 1,509/1,509,
phase counter 1,262/1,509, index rule 1,235/1,509, RAM prefix 02 00 on 90/90.
2026-10-12 04:32:43 +02:00
ReaperOfVeriod 2c4bbdc600 Use PCSX2 RAM to recover the chunk loader's transformation
This is what RAM was needed for: nothing about the loader's transformation is
recoverable from the disc.

Anchor: a 32-byte probe from inside a chunk record in level 0's core_data, searched
in the level-0 savestate. It hits at 0x18538a8 and the record's first bytes at
0x185387e -- exactly chunk_base + record_offset, no rescaling. So the chunk payload
is copied VERBATIM; unlike collision and the shrub packet tables it is not expanded
or repacked.

Result 1: the offset table is rewritten to absolute EE addresses.
  disc: f000057c f00006b0 f00007e4 f0000914
  RAM:  0xf185387c 0xf18539b0 0xf1853ae4 0xf1853c14
The 0xf0000000|low24 tag becomes 0xf1850000|low24 -- tag replaced by the chunk's
load base, low 24 bits untouched. Consecutive deltas agree exactly (0x134, 0x134,
0x130), which is why the disc-side stride is approximate rather than constant.

Result 2: every record gains a constant 2-byte prefix 02 00, and the rewritten
table points at THAT rather than at the record data. Walking all 90 records from
the RAM dump: prefix 02 00 on 90 of 90; the disc's index bytes preserved unchanged
at +2/+3 on 90 of 90.

Result 3, the useful one: the byte at +2 takes exactly 0, 16, 32, ... 240 -- the
16 values index_rule predicts for step = 16, phase = 0. The mixed-radix record index
derived from the disc is corroborated on the running game's own copy of the chunk.
That is a disc-derived claim validated against the game, which is the strongest form
of check available here.

On disc a chunk is [16-byte header][10 zero bytes][tagged offset table][records].
In RAM it is [16-byte header][10 zero bytes][absolute-pointer table][records each
prefixed with 02 00]. The 02 00 is unexplained -- possibly a record type or an
alignment the loader needs -- but it is constant, so like the two index bytes it
carries no per-record information.
2026-10-12 04:32:07 +02:00
ReaperOfVeriod e2b12a2e52 Rule out a count field in chunk record bodies; correct a six-record impression
With the record framing settled (leading two bytes = record index), the body is
bytes 2..len. Four probes, mostly negative, recorded so the next attempt does not
repeat them.

No count field. For every chunk type, every u16 and u32 position in the first 64
body bytes, at element strides 1,2,3,4,6,8,12,16: does the stored value equal
(record_len - offset) / stride on 90%+ of records? No type produced a hit. The
record does not carry an element count near its head.

The body is dense, not float-dominated. For the largest type (2430 records,
326-byte bodies): f32 reads within +/-5000 of origin 74%, uniform-random control
49%, zero-byte fraction 13%. Above baseline but far short of the tie blocks
(53-64% against 16%, with 43% zero bytes).

There is an index-like region near the tail. In the largest type, 12 of 163 u16
slots have a maximum below 0x1000 -- too small for float or half-float, so those
look like indices or small counts -- clustered at slots 111-121 and 130-131.

A sample-size correction. Dumping six records of one chunk showed slots 112-119
zero on every record plus a long run of other constants, which reads as "the
record is mostly a per-chunk template". Measured across the 1,171 chunks with at
least 8 records: 15,422 of 164,390 u16 slots are constant across their chunk,
9.4%, and 0 of 1,171 chunks are entirely constant.

So 9.4%, and no chunk is a template. Six records looked like a majority because
that chunk's tail carries shared constants. Per type the figure ranges from 9.4%
overall to 55.6% on one type, so it is reported per type as well as pooled --
the pooled figure alone would hide that spread, which is the same trap rule 3a
describes.
2026-10-12 04:27:54 +02:00
ReaperOfVeriod 28680b4be3 Show the first two chunk-record bytes encode the record's own index
Continuing from the phase counter. Within a chunk:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Corrected header, over all 2177:

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

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

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

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

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

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

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

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

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

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

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

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

Still open: what the payload holds. Established: variable-length, float-shaped,
non-random, not LZ77, not VIF1. Not established: any record boundary.
2026-10-12 04:09:08 +02:00
ReaperOfVeriod 881fd48e9a Withdraw the 16-byte tie record claim: block size is a multiple of 16, not records
The previous commit inferred a 16-byte record from "922 of 922 consecutive block
sizes are a multiple of 16". That inference does not hold, and the claim is
withdrawn rather than softened.

What survives:

- Every one of those 922 blocks opens with a u32 that is positive and a multiple
  of 4, on 922 of 922, ranging 48 to 1744 with 17 distinct values across level
  0's LOD0 blocks. The block opens with a count, not a size and not a pointer.
- Reading that u32 as a byte size and chaining to the next block lands exactly on
  the next block start on 0 of 1383 blocks. Not a size chain.
- Assuming [header][count][count uniform records] and solving for the record size
  gives no answer: no record size tiles the block for any header size up to 512
  bytes. Not 16, not 12, not 8.
- Reading the block as uniform 16-byte units gives fields spanning their entire
  legal range -- u32@0 from -2139029504 to 2139029587, u16@8 and u16@10 both
  0..65535, and 8969 distinct values in i16@0 on level 0. Uniform units would not
  produce that.

So the 16 is a property of the block size, not of a record. The earlier commit
also claimed a record size "on the classes where it can be measured"; those
classes are still correctly identified, but what they measure is the block size.

The strict-increase disproof from two commits ago stands untouched and is what
should have prevented this: the word at +0 was already known not to behave, so
treating 16-byte units as records contradicted a measurement sitting in the same
file. Divisibility is arithmetic; it is not evidence of a record boundary.

Roadmap updated to say block sizes are established and the record layout is not,
with the three measurements that rule the 16-byte reading out.
2026-10-12 03:56:41 +02:00
ReaperOfVeriod 855e640f39 Establish the 16-byte tie record on measurable classes; packets[0] is a constant
The previous commit reported "3580 non-empty LOD blocks compute as divisible by
16" and then said the figure should not be trusted, because it came from the
"next packets offset ends this block" rule. That rule is unsound: packets is
strictly increasing on only 461 of 1864 classes. On the other 1403 the two
offsets are unordered and cannot both be block starts into one ascending region.

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

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

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

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

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

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

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

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

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

Not established: the block's record size. 3580 non-empty LOD blocks compute as
divisible by 16, but some packets offsets run backwards, so the "next offset
delimits this block" end is wrong for them and that figure should not be trusted.
The tie layout stays partial: indirections and header done, records not.
2026-10-12 03:47:49 +02:00
ReaperOfVeriod e5d267a810 Reconcile roadmap.md with the parsers that actually exist
Findings were going to docs/plan/level-assets.md and docs/formats/*.md while
roadmap.md kept listing work as open that has been done for some time. The worst
case was occlusion appearing twice: open at one line with "2 to 25 sectors, small
enough to read whole", and done further down with 412521 cells over 140939 masks.
The open line is deleted.

Also corrected:

- tfrag was listed flatly open while six tfrag entries beneath it were checked.
  Now partial: format, topology, textures, LOD, normals and colour are done;
  morph targets and strip/chain bookkeeping are not.
- sky was lumped in with volumes as open. Sky is done -- 149 sub-objects across 27
  levels -- so it has its own entry, keeping the word at +0x0c documented as not
  a count, since it disagrees with the table on most levels and reads 196617 on
  level 2 against 4 sub-objects.
- level_overlay was open. It now has a parser and a 27/27 test, so it is partial
  rather than done: header, constant block and level data parse, +0x10..+0x1220 is
  identical on every level, and the word at +0x0c falls below the file length on 17
  levels and above on 10. The header words are still unnamed and the Phase 3
  seven-section parser is untouched.
- sound_bank was flatly open. It is partial: container framing parses and bounds
  are verified on 27/27, but the SBlk record fields are not decoded and DAW shows
  no stride, consistent with ADPCM.
- tie now records what is actually known, including the two indirection chains
  that have not been walked: class table entries point into core_data.wad, and the
  header's packets[0..3] resolve against the lump base. Level 0 has 74 tie, 238
  moby and 7 shrub classes.
- collision now separates the header, which is decoded, from the cell layout,
  which is not -- including the 17-entry table at +5932 against a 75-cell grid and
  the disproved 64-byte RAM stride.
- moby_anim records the negative result: per-chunk-type record counts are
  level-independent, so nothing in the chunk set is keyed by moby class.

The "Where we are" section now states plainly what has parsers and that what
remains open is naming rather than locating, and names the five types with no
decoded fields. Reading that list is how you would have known to ask.

Phase 2 remains in progress. Volumes and moby_anim have no located structure and
SBlk records, tie vertices and collision cells have located structure without
decoded fields.
2026-10-12 03:43:44 +02:00
ReaperOfVeriod d8b8ccc6d4 Follow the gameplay.bin pointers: they address position and direction arrays
Where the pointers go. Following them shows what gameplay.bin indexes. The leading
table is 39 words, all of which land inside the file, and three clusters
dominate: near 150000, near 800000 and near 1050000.

The region near 797808 is position data -- 31 consecutive plausible floats that
group cleanly into xyz triples:

  246.948 201.015  73.867 |  88.240 186.000 302.598 | 107.712  12.723 183.125
  353.591 126.365  19.962 | 182.068 374.001 122.567 | 116.961 265.001 437.866

and the region near 797456 is direction data -- unit-length vectors:

  0.969 0.945 0.733 | -0.120 0.433 0.250 | -0.866 0.000 0.267

So the file's leading words are a table of pointers into float arrays, not a flat
list of small integers. The earlier reading of it as "u32 pointers holding
nothing" was too quick: it is a pointer table into data that is present.

Volumes remain unlocated. The position arrays are the right shape to bound a
volume and nothing yet shows the volume records themselves.
2026-10-12 03:36:55 +02:00
ReaperOfVeriod b17ec17127 Rule out the moby_anim instance-table reading of the chunk types
moby_anim was the one post-shrub chunk type with a concrete reason to exist: an
instance table would have to scale with the level's moby class count, so
comparing per-type record counts against that count was the obvious test.

It fails, and the way it fails is itself the result: per-type record counts are
level-independent. The same types report the same counts on every level tried:

  type_id     level0  level1  level2  level3
  0x007d7ec438   90      90      90      90
  0x00d3d8c4a9   90      90      90      90
  0x00429ec43d   77      77      77      77
  0x0053c8c4aa   77      77      77      77
  0x007626c4a4   50      50      50      50
  0x00fb88c462   47      47      47      47

Across all 27 levels there are 77 distinct chunk types, the largest at 2430
records total -- exactly 90 x 27.

What that rules out: nothing in this chunk set is a per-moby-class instance table,
because those counts would vary by level the way the moby class counts do. So
whatever holds moby animation data is either keyed by something other than the
class count, or it is not among these chunk types.

Also visible: the types pair up. 0x007d7ec438 and 0x00d3d8c4a9 both report 90 on
every level; 0x00429ec43d and 0x0053c8c4aa both report 77. Two chunk types
carrying identical, constant record counts on every level is a signature of two
views of the same fixed table rather than two independent tables that happen to
agree.

Still open: the pairings are described but not explained, and no type has been
named. The Moby class-ID enrichment from earlier stands separately -- 9493 hits
against 246.1 expected is real, and the class arrays are contiguous per type, but
that identifies where classes are referenced, not which chunk is moby_anim.
2026-10-12 03:33:52 +02:00
ReaperOfVeriod 02ced5ee83 Parse the per-level overlay: header, constant block, level data
level_overlay was the least-characterised file in the level set -- no parser, no
located structure. It now has gc_formats::overlay and a real-disc test over all 27
levels:

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

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

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

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

Also records in docs/plan/pcsx2-verification.md that the 64-byte RAM stride from
the previous commit is disproved. A 415 KB dump of level 1 finds the longest such
run at 27 records, not 384, and those are identity matrices -- 27 of them, one per
level. It is a per-level transform table, not the collision grid, and the 20.0
reported there was a coincidence.
2026-10-12 03:32:02 +02:00
ReaperOfVeriod bd012efe71 Find a 64-byte collision record stride in RAM, and record a savestate search pitfall
Searching the savestates for the collision grid dimensions as a u32 pair -- level
0's 11 x 33 and level 1's 24 x 16 -- puts level 1's at several RAM addresses and
level 0's nowhere. So the grid dimensions are not held in RAM as adjacent u32s,
and the collision payload is reshaped on load, as the shrub packet tables are.

A nearby region of level 1's RAM does show structure. At 0x12e048c and
0x12e04cc, 64 bytes apart:

  28 01 00 00 | 92 ff 00 00 | 04 00 a0 41 | ...
     296        -110        20.0f

Searching for that shared 12-byte tag finds a consecutive run at a 64-byte
stride in slot 2:

  0x12df390
  0x12df3d0   +0x40
  0x12df410   +0x40

plus scattered singles elsewhere. Slot 1 has the tag at four addresses not on a
64-byte stride, so the two levels lay it out differently.

What this is: a 64-byte record carrying two small signed values and a float
reading exactly 20.0 -- the shape of a per-cell entry with coordinates and a
radius or half-extent, which is what the collision grid would hold.

What this is not: it is not decoded. Three consecutive records is not enough to
establish a stride, the tag matches only a few records rather than a whole grid,
and 20.0 may be coincidence. Level 1's grid is 384 cells; nothing here has been
shown to count to 384.

Method note: savestates are compressed on disk, so a byte scan of the file finds
nothing. All RAM search must go through gc-trace find --state, which decompresses
first -- scanning the file directly reports zero hits for a sequence that
demonstrably exists in memory.
2026-10-12 03:27:14 +02:00
ReaperOfVeriod d3e1475f64 Characterise gameplay.bin as a flat pointer table, and record volumes as unlocated
Range measured: the first 40 u32 and the whole file, on levels 0 and 1 --
1872256 and 2006400 bytes.

Result: the file is a flat array of u32, opening with a small count then a run of
ascending offsets, terminating on a zero:

  level 0  336 797472 798832 799136  464 150672 150688 314800 479920 ...
           ... 1847600 1845456 797808 1829056 160 1849088 1249808 1847616 0

  level 1  496 799904 800128 800432  624 151152 151168 315680 481456 ...
           ... 1984496 1982400 800112 1966000 160 1988160 1303456 1984512 0

Both levels carry the same shape. The sentinel 160 appears at u32 index 34 on
both and the zero terminator at index 38 on both, with counts 336 against 496
and 464 against 624 at the head. The leading ~38 words are a structured header
whose layout is level-independent apart from its sizes.

The offsets cluster into groups -- near 800000, 150000, 1050000, 1800000 --
which is what a table of pointers into several buffers looks like.

Not decoded: what the pointers point into, what 160 is, and why the zero
terminator sits at the same index on both levels. No gc_formats module exists for
this yet; it is characterised, not parsed.

On volumes: nothing resembling volumes was found here. gameplay.bin is u32
pointers throughout and holds no float data in its first 64 words read as f32 --
59 of 64 are denormals, i.e. small integers reinterpreted. Volumes remain
unlocated on the disc; nothing in the regions examined so far presents itself as
one.
2026-10-12 03:19:03 +02:00
ReaperOfVeriod 575d1a5bdd Correct the overlay strings from a table to a pool
The previous commit described a "debug string table on 16-byte alignment". It is
not a table. Dumping the 32 bytes before each run shows most are the tail of the
previous string:

  +3104  "host0:r:/rc2/shock/wavetable.bin"   32 chars
  +3120  "ck/wavetable.bin"                   its own tail
  +3152  "/i5/shock/wavetable1.bin"
  +3168  "ble1.bin"                           its own tail

and the 32-byte prefixes are 327 distinct out of 332 -- essentially all different,
because they are just the preceding string's bytes. There is no per-record header
here. It is one contiguous pool of debug text, and the 16-byte alignment is a
property of how the pool was laid out, which makes continuations land on
boundaries and read as separate entries.

The count of 332 "strings" was therefore inflated by the same effect: it counts
continuations as entries.

The substance is unchanged and still useful -- the pool carries the game's own
debug text, including "Camera_CollPrimTest WARNING! - grid out of bounds! (RAR)",
which is direct evidence for reading the collision payload's two leading u16 as
grid dimensions, and the wavetable.bin paths, which bear on the sound bank
framing. Only the framing was wrong.

The pool is located and counted but not parsed, and there is no gc_formats module
for it. The +0x18/+0x20 names are section names but their structure is not
established, and it is not shown that they index anything.
2026-10-12 03:17:50 +02:00
ReaperOfVeriod a5a33b9bc0 Locate level_overlay's sections and a 16-aligned debug string table
level_overlay was on the open list with no parser and no located structure. It
now has a header naming two sections and a string table.

Range measured: overlay.bin on levels 0, 1 and 2 -- 2751268, 2763668 and
2928424 bytes.

Result 1: the file opens with section names.

  +000: 80 7c 1a 00 | dc 99 00 00 | 01 00 00 00 | 88 3b 2a 00
  +010: 00 00 00 00 | 00 00 00 00 | "crap\n"     | 00 00 00 00 00
  +020: "system misc" 00 00 00 00 00 00 00 00 00
  +030: 20 80 ff 40 | 00 60 cf 00 | 64 00 | 46 00 | 19 00 00 00

u32@+0x00 is 1735808 on all three levels, +0x08 is 1, and the bytes at +0x18 and
+0x20 spell "crap" and "system misc".

Result 2: a debug string table on 16-byte alignment. 332 such strings on level
0, 362 on level 1, 351 on level 2, first at +0x20 and last between 1.3 MB and
2.5 MB. They are the game's own debug text:

  host0:r:/rc2/shock/wavetable.bin
  host0:z:/i5/shock/wavetable1.bin
  unloaded sound bank %#010x
  Camera_CollPrimTest WARNING! - grid out of bounds! (RAR)
  render setup
  sky draw
  pre effects
  pre 2 effects

Camera_CollPrimTest WARNING! - grid out of bounds! (RAR) is direct evidence for
the collision reading. It is the game's own string, naming a grid and a
collision test, and it is why the two leading counts in the collision payload are
worth continuing to treat as grid dimensions despite not sizing the payload.

The wavetable.bin paths also confirm the sound bank is loaded from named files,
which bears on the SBlk/DAW framing.

Not done: the string table is located and counted but not parsed -- the records
preceding each string have not been identified, so there is no gc_formats module
for it yet. The +0x18/+0x20 names are section names but their structure is not
established, and it is not yet shown that they index anything.
2026-10-12 03:11:25 +02:00
ReaperOfVeriod e58ab0e815 Correct the collision table's extent: 17 entries, deltas rise then fall
+5932 is the table's start, not a point inside it: walking backwards, no
predecessor satisfies "ascending with a delta under 4096". The run is 17 entries,
not 19 -- the earlier 19 counted two entries past the jump.

The deltas are

  204 212 216 224 224 228 228 228 224 220 216 200 96 96 100 96

then a jump of 41484 to 190508. The deltas rise and then fall -- a symmetric
profile, not a monotone one. That weakens the prefix-sum reading: a cumulative
offset table has no reason to come back down, whereas cell contents that grow and
shrink as the table walks across a level would.

17 against a 75-cell grid, and 75 - 17 = 58. No arithmetic relating the two has
been found. The earlier guess that 19 related to 3x25 did not survive
re-measurement and is not carried forward.

Still open. 17 entries may not correspond to the grid at all -- the table may
cover something else, or a subset of cells. Resolving it needs either the same
offset found in other levels at a position the leading counts predict, or a
reason for 17. Neither exists yet, and this is where the measurement trail goes
cold on collision.
2026-10-12 03:08:03 +02:00
ReaperOfVeriod b66e77855c Locate a per-cell offset table in collision, on level 4
Range measured: longest strictly-ascending u32 run (delta under 65536) in each
level's collision payload, levels 0 to 5.

Level 4 is the clean case. Grid 3x25, payload 2660608, and at payload +5932 a
run of 19 entries whose values are all inside the payload, starting at 146012:

  deltas 204, 212, 216, 224, 224, 228, 228

The deltas grow monotonically, which is the signature of a prefix sum over
records of increasing size -- what a per-cell offset table looks like when cell
contents vary.

The others are not:

  level 0  11x33   +21232   17   8193, 24575, 8192, 8192   multiples of 8192,
                                                              coincidental on
                                                              dense data
  level 1  24x16   +41452   29   32771, 57339, 24575        wild, no trend
  level 2   1x53   +13752   49   24, 64, 396, 104          in-region but
                                                              irregular
  level 3  19x31  +667152   18   769, 257, 257, 257        values ~5.2e8, far
                                                              outside the payload
  level 4   3x25    +5932   19   204, 212, 216, 224        growing, in-region

Only level 4's run has both properties a prefix sum needs: values inside the
payload, and deltas that grow.

What this does not give: level 4's run is 19 entries against 75 grid cells, so
it is not one offset per cell -- either most cells share an offset, are empty,
or the table covers something other than the grid. Nothing measured picks between
those.

Next to check: whether 19 relates to 3x25 by any arithmetic, and whether the
same offset at +5932 exists in other levels at a position given by their leading
counts. Neither has been tried.
2026-10-12 03:03:18 +02:00
ReaperOfVeriod 2569cc06ab Decode the collision payload's leading two counts
Range measured: the first four bytes of the payload on all 19 levels that carry
a non-empty one.

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

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

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

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

Exposed as CollisionHeader::first and ::second. They live in the payload rather
than the header, so CollisionHeader::region fills them in and the bare
CollisionHeader::parse leaves them zero -- documented at the call site so the
difference is not mistaken for an absence.
2026-10-12 02:58:46 +02:00
ReaperOfVeriod 72bbd5da38 Rule out GS packet framing in tie, with a control that nearly fooled me
Range measured: level 0, the 73 tie span bodies (bytes +0x40 onward), 2585856
bytes pooled. Per-position distinct counts, an autocorrelation scan against a
shuffled control, and a GS GIF tag test.

Result 1: the bodies are dense. Every one of the 16 slot positions takes 245 to
256 distinct values, byte 15 zero on 81.3% and byte 7 zero on 78.8%. The best
stride peak is 48 at +0.097, barely above 16 at +0.095 -- no period.

Result 2: no GS GIF framing at 16-byte alignment. Testing whether byte 15's low
three bits fall in 0..4, the GS tag range, gives 97.0%.

That looked like a strong hit until the control was done properly. A uniform
shuffle gives 62.6%, exactly 5/8, what a random byte predicts -- so the naive
comparison reads as a 34-point excess.

The distribution-matched null is 97.0%, identical to the observation. The whole
excess was that byte 15 is 81% zero and (0 & 7) <= 4 is always true. Recomputed
per alignment the null tracks the observation everywhere:

  align 0  97.0%    align 4  75.7%    align 8  96.7%
  align 2  75.5%    align 12 89.3%

This is the control rule earning its place a second time. A uniform shuffle is
not a matched control when the data is skewed, and AGENTS.md already says so;
the near-miss was reading 62.6% as a baseline without checking what the data's
own distribution predicts. The tie bytes concentrate on 0, 1, 2, 3 and 63 --
suggestive in isolation -- and still carry no framing.

What this rules out: the tie bodies are not GS GIF packet streams at 16-byte
alignment and carry no measurable periodicity. With every slot position taking
~256 distinct values they are dense data -- fat vertices, as the roadmap expects
-- and locating the vertex layout needs a reference for this game's instanced
geometry format, not another stride scan.
2026-10-12 02:56:58 +02:00
ReaperOfVeriod 86ff2e6052 Measure tie spans: a constant first word and three quantized floats
Range measured: all 1864 tie spans across 27 levels, read at each class entry's
geometry offset.

Method: read +0x00, +0x0c and the three f32 at +0x10, +0x14, +0x18, and count
distinct values per position.

Result:

  u32@+0x00 takes exactly one value across all 1864 spans: 0x00000100, 256
  +0x0c takes 83 distinct values, so it is not constant
  the three floats take 103 distinct triples built from 42 distinct values,
    and those values are round -- 4, 8, 9, 10, 12, 15, 16, 18, 20, 24, 25, 30
    with only 10 of the 42 exact powers of two

Two level 0 spans open:

  +000  00000100 00005070 000065c0 07050612
  +010  41400000 41c80000 42200000 00009460

  +000  00000100 00007ca0 0000a040 0605091e
  +010  41400000 41c80000 42200000 0000ddf0

This rules out the bounding-box reading. A box would take continuous values;
these come from a 42-value set of round numbers, so they are authored or
quantised quantities -- a grid, a size class, or LOD thresholds -- and nothing
measured separates those.

Correction: these floats were previously read as a bounding-box minimum, from
level 0's first two spans happening to share (12.0, 25.0, 40.0). That triple
holds on 266 of 1864 spans, not all of them, and the value set is quantised.

Not decoded: what the three floats are, what 0x100 at +0x00 counts, and what
the 83 values at +0x0c are.
2026-10-12 02:52:45 +02:00
ReaperOfVeriod 32a24574be Identify data.bin as the level file descriptor, not geometry
data.bin is the largest per-level file -- 13.3 MB on level 0, 4.2 to 21.9 MB
across the disc -- and had been characterised only as "4-byte structured".

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

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

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

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

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

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

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

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

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

Result:

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

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

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

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

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

Still not decoded: the payload. It contains ascending u32 runs on levels 0 and
2 -- level 0 at +280 with deltas 12, 12, 12 then a jump, level 2 at +13868 with
deltas 204, 204, 200, 196 -- but irregular deltas fit neither a flat table nor a
simple stride, and level 1's apparent run is a byte-shift false positive.
Nothing about the payload is asserted.
2026-10-12 02:49:25 +02:00
ReaperOfVeriod 51348b214d Report the sky reserved-word count in the test output
20 of 149 sky sub-objects carry a non-zero word where the other 129 are zero,
so the reserved area is not uniformly padding. Reporting it rather than
asserting it, and clearing three clippy warnings from the unused counter.
2026-10-12 02:43:40 +02:00
ReaperOfVeriod 681e7e62ef Parse the sky block: 149 sub-objects with a bounding sphere, and correct the count field
Range measured: all 27 levels, each sky region from the core index's sky offset
to the next region.

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

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

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

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

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

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

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

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

Neither RAM form exists on disc:

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

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

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

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

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

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

Consequence for Phase 2: the payload is encoded on disc and expanded at load, so
a disc parser can bound it and check its signature but cannot read it as either
RAM form. Decoding it means reversing the expansion, not re-describing what the
loader produced. That is the honest boundary of what is known.
2026-10-12 02:37:55 +02:00
ReaperOfVeriod 5ade83cf0a Parse the shrub packet header, a fixed signature on all 2056 disc packets
The packets found by the +0x40 table have a header the whole disc agrees on.

Range measured: every packet on the disc -- 2056 of them, across the 334
successor-bounded spans -- read at each packet's span-relative offset.

  bytes 0-3   04 04 00 01   2056 / 2056
  bytes 4-7   zero           2056 / 2056
  byte  8     zero           2056 / 2056
  byte  11    0x05           2056 / 2056
  size % 16   zero           2056 / 2056

Zero exceptions on any of the five.

And the loader fills the rest in. Read back out of the running game the same
records carry byte 8 as a count (0x0b to 0x29 observed), byte 11 as one of four
tag values, and bytes 12-15 as an absolute EE RAM pointer where the disc has a
span-relative offset. Bytes 8-15 are rewritten wholesale on load.

That settles what the disc header is for. It carries no count of its own, and
the count the RAM copy has is the +0x40 table's count arriving by another
route. The disc header is a signature; the RAM header is populated state.

ShrubPacketHeader::parse and Shrub::packet_headers() expose this. parse returns
None unless all the invariants hold, rather than a default, so a packet that
does not match is reported as not-understood instead of half-interpreted.

The gated test asserts all four header invariants and the size alignment on
every packet of every bounded span.

This is the first piece of the mesh packet format represented in gc-formats;
everything known about the packet payloads so far is RAM measurement, since the
disc form of bytes 8-15 is rewritten at load.
2026-10-12 02:35:00 +02:00
ReaperOfVeriod af0b1aa949 Repeat the packet measurements per level, correcting two over-general claims
The five-block result was measured on level 0 only. Rule 3a asks for the same
measurement per partition, so it was repeated on level 1's savestate before
being written up as a property.

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

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

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

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

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

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

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

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

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

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

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

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

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

Per-block fields constant across all eight:

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

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

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

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

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

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

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

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

The payload contains a byte permutation table at 0x009377c4:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What is actually established about the RAM header:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The two payloads have nothing in common.

0x00936a70 is fixed-point vertex data:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Next: read the record count from the packet table in the span rather than from
the packet header, which is what would separate the two readings.
2026-10-12 02:08:12 +02:00
ReaperOfVeriod aa2b14ad66 The shrub mesh geometry is in the class blob after all
The open question from the shrub decode was where the mesh lives: the blob
looked like header, palette and optional billboard, with the 333 classes
without a billboard having nothing to draw. Answering it took the running
game, not the disc.

Diffing a loaded shrub class against its disc bytes in the live PCSX2 (level
1, class 1) shows what the loader rewrites: a table at +0x40 of (offset,
size) pairs, whose offsets are span-relative on disc and absolute in RAM with
the sizes unchanged -- all 21 entries matching offset for offset and size for
size -- plus a relocated palette pointer at +0x2c and a pointer at +0x18,
zero on disc, that asserts the parser enforces.

So the blob is header, packet table, packets, [billboard], palette: the mesh
was here the whole time. Why it was missed is the part worth recording --
data_offset points at the END of the span, so reading the body as
data_offset..span_end accounts for the last few hundred bytes and leaves the
20 KB in front of it unexamined. A correct bound and a body small enough to
look plausible are together enough to hide a region.

Range measured: every shrub span on the disc, 358 over 24 levels, each bounded
by its successor class or the located chunk container.

Method: read count at +0x28, then the entries at +0x40, and require them to
tile -- the first packet at align16(0x40 + count*8), every entry exactly size
past the previous, and the last ending at the palette, or 0x40 short of it on
the 25 spans that carry a billboard.

Result: 358 of 358 tile exactly, holding 2,239 packets. That is what pins the
field meanings: count is the packet count (1..55 on the disc), data_offset is
the palette's offset, and the earlier "the body is not sized by count" note
was true for a reason nobody had understood -- the count sizes the packets.

Shrub::packets() exposes them and returns None rather than a partial list
when a table does not tile. Still open: the packet contents (a VIF reading was
already retracted once, so this needs a control rather than a pattern match),
and the 0x20-byte records the loader points at from +0x18.
2026-10-12 02:02:01 +02:00
ReaperOfVeriod c83f5104d2 Document the PCSX2 test setup
PCSX2 was set up and working but existed only in this machine's filesystem
and in people's heads, so every comparison looked like a one-off observation
rather than something re-runnable. The setup section in
docs/plan/pcsx2-verification.md now records where the emulator, the BIOS, the
disc, the savestates and the PINE socket are, how a state is captured, and
which command the disc integration tests need -- and AGENTS.md and README.md
point at it from their orientation sections. One operational detail worth
spelling out: the emulator's directory is lowercase (pcsx2), which is not
what people type.
2026-10-11 20:59:19 +02:00
ReaperOfVeriod bbdb51e2f9 Verify level 1's tfrag lighting against the game
Savestate slot 2 is the second planet, so the comparison runs on a level with
a different geometry extent, light bank and runtime-set population:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Consequences:

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

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

Still open: the marker's meaning (constant across 27 levels, at exactly this
position, unnamed) and the shrub body payload's encoding.
2026-10-11 06:20:48 +02:00
46 changed files with 24879 additions and 406 deletions
+47
View File
@@ -97,6 +97,52 @@ same each time: group by the thing that varies, then look.
version of the truth. It was a different and wrong claim. Retract it rather version of the truth. It was a different and wrong claim. Retract it rather
than soften it. than soften it.
### 3b. Never report a test count that a filter could have hidden a failure in.
Do not commit or push with a failing test. If one fails, it is fixed or reverted
in the same commit — never "fixed in the next one".
The same rule governs the *number* you report for the suite. Do not derive it
with a filter that drops the failing binaries:
```sh
# WRONG. Sums only the binaries that passed. A red build still prints a number,
# and the number looks fine.
cargo test -q --workspace | grep "^test result: ok" | awk '{s += $4} END {print s}'
```
A commit was pushed with a broken test because of exactly this. The filtered sum
reported a healthy count, because `^test result: ok` matches the six green test
binaries and ignores the seventh, which was red. The giveaway was that the count
came back lower than the previous run's, and the difference was mistaken for
"the disc tests were skipped" rather than "one of them failed". It was read as
an environment difference and moved on.
Use the exit status, or count the lines that are not green:
```sh
cargo test --workspace # then read every "test result:" line
cargo test --workspace && echo ok # the exit status is the real answer
```
Concretely:
- Run the full suite with the disc present when the change touches disc-decoding
code, and say so in the commit body. A skipped test and a passing one both
report success.
- If the reported count moves between runs, find out why before writing anything
down. A drop means either an environment difference or a failure, and the two
look identical from the summary line.
- **The total is the same whether or not the disc is present.** A skipped disc
test returns early and still counts as passed, so `cargo test --workspace` reads
278 either way. The only signal is the wall clock: the disc binary takes ~36s
with `GC_DISC` set and ~4s without it. If a run finishes suspiciously fast, the
disc tests did not run.
- Never quote a number you computed rather than read.
This is the same defect as rules 3 and 3a, one level up: a quantity that should
have had one definition had two, and nothing compared them.
### 4. Controls, and heuristic counts that are not identifications. ### 4. Controls, and heuristic counts that are not identifications.
Concretely: Concretely:
@@ -132,6 +178,7 @@ Read these before working in an unfamiliar area:
| `docs/plan/` | One file per subsystem, written **as work proceeds** | | `docs/plan/` | One file per subsystem, written **as work proceeds** |
| `docs/formats/` | Format specs, written **as formats are reversed** | | `docs/formats/` | Format specs, written **as formats are reversed** |
| `PROVENANCE.md` | Upstream projects we build on, and their licences | | `PROVENANCE.md` | Upstream projects we build on, and their licences |
| `docs/plan/pcsx2-verification.md` | PCSX2 is set up on this machine, and how to run the comparison loop |
| `vendor/reRAC/` | Pinned reference checkout (not tracked) | | `vendor/reRAC/` | Pinned reference checkout (not tracked) |
## Crates ## Crates
Generated
+39
View File
@@ -1668,6 +1668,25 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "capstone"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f442ae0f2f3f1b923334b4a5386c95c69c1cfa072bafa23d6fae6d9682eb1dd4"
dependencies = [
"capstone-sys",
"static_assertions",
]
[[package]]
name = "capstone-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e8087cab6731295f5a2a2bd82989ba4f41d3a428aab2e7c98d8f4db38aac05"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.6.0" version = "1.6.0"
@@ -2336,6 +2355,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gc-formats", "gc-formats",
"gc-trace",
"sha1", "sha1",
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
@@ -2360,6 +2380,25 @@ dependencies = [
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
[[package]]
name = "gc-overlay-anchors"
version = "0.1.0"
dependencies = [
"anyhow",
"capstone",
]
[[package]]
name = "gc-trace"
version = "0.1.0"
dependencies = [
"anyhow",
"flate2",
"gc-engine",
"gc-formats",
"ruzstd",
]
[[package]] [[package]]
name = "gc-video" name = "gc-video"
version = "0.1.0" version = "0.1.0"
+10
View File
@@ -6,6 +6,8 @@ members = [
"crates/gc-engine", "crates/gc-engine",
"crates/gc-game", "crates/gc-game",
"crates/gc-video", "crates/gc-video",
"tools/trace",
"tools/overlay-anchors",
] ]
[workspace.package] [workspace.package]
@@ -23,8 +25,16 @@ publish = false
[workspace.dependencies] [workspace.dependencies]
gc-formats = { path = "crates/gc-formats" } gc-formats = { path = "crates/gc-formats" }
gc-extract = { path = "crates/gc-extract" } gc-extract = { path = "crates/gc-extract" }
gc-engine = { path = "crates/gc-engine" }
gc-game = { path = "crates/gc-game" } gc-game = { path = "crates/gc-game" }
gc-video = { path = "crates/gc-video" } gc-video = { path = "crates/gc-video" }
gc-trace = { path = "tools/trace" }
# Dev tools. PCSX2's savestates are zip archives whose entries are
# zstd- or deflate-compressed; both backends are pure Rust and are already
# in the lockfile through Bevy, so the trace tool adds no new sources.
ruzstd = "0.8"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
anyhow = "1" anyhow = "1"
thiserror = "2" thiserror = "2"
+21 -1
View File
@@ -7,6 +7,7 @@ code, and those notices travel with anything derived from it.
| Project | Role | Licence | Code copied into this repo? | | Project | Role | Licence | Code copied into this repo? |
|---|---|---|---| |---|---|---|---|
| [OpenRAC/rac2-gc-decomp](https://github.com/OpenRAC/rac2-gc-decomp) | Byte-exact reconstruction research for the same disc (SHA-256 verified identical) -- supplied `$gp`, the `.lit` load base, the seven-section program layout, the `lvl.vtbl`/`lvl.camvtbl`/`lvl.sndvtbl` dispatch record shapes, and the 256-byte `MobyInstance` field map transcribed into `gc_formats::moby` | GPL-3.0-or-later | Not yet; vendored read-only at `vendor/rac2-gc-decomp`. Note: its *function addresses* did not transfer (they land in NOBITS memory); its *struct layouts* are used as layout claims, not verified against disc bytes |
| [ReRAC](https://github.com/re-rac/rerac) | Native Rust/Bevy port of Ratchet & Clank (2002) — the architecture this follows | ISC | Not yet; derived from, attribute when used | | [ReRAC](https://github.com/re-rac/rerac) | Native Rust/Bevy port of Ratchet & Clank (2002) — the architecture this follows | ISC | Not yet; derived from, attribute when used |
| [OpenRAC / rac2-gc-decomp](https://github.com/OpenRAC/rac2-gc-decomp) | Matching decompilation research for this game | MIT | No — consulted for documentation | | [OpenRAC / rac2-gc-decomp](https://github.com/OpenRAC/rac2-gc-decomp) | Matching decompilation research for this game | MIT | No — consulted for documentation |
| [rac2-gc-decomp community reference](https://github.com/OpenRAC/rac2-gc-decomp/blob/RAC2/docs/COMMUNITY-ENGINE-REFERENCE.md) | Engine architecture notes, moby struct layout | MIT | Documentation only | | [rac2-gc-decomp community reference](https://github.com/OpenRAC/rac2-gc-decomp/blob/RAC2/docs/COMMUNITY-ENGINE-REFERENCE.md) | Engine architecture notes, moby struct layout | MIT | Documentation only |
@@ -117,4 +118,23 @@ Rust dependencies keep their own licences; see `Cargo.lock` and each crate's
1. Record it in this file, with its licence and what you took. 1. Record it in this file, with its licence and what you took.
2. Keep the upstream notice intact in any file containing derived code. 2. Keep the upstream notice intact in any file containing derived code.
3. If it would change this project's licensing terms, stop and say so first. 3. If it would change this project's licensing terms, stop and say so first.
## Update 2026-10-15: decomp advanced to 8a174cb
Vendored checkout moved from `aca4f59` to **`8a174cb`**, 18 commits ahead
(`origin/master`). All 18 are `src/game` function-matching work; **no header,
doc, config or `config/levels/functions.tsv` changes**, so the function extents and
level catalogue this project uses are unaffected.
What is new and usable: **14 of the newly matched functions fall inside the
overlay `.text`** (`0x2a3b80`-`0x447900`), so they are decomp addresses verified
against the same code this project runs:
002A7750 002AA800 002B3868 002B6120 002B7E28 002C7748 002CD8C0
002D5E98 002DDEC8 002DF1B8 002E7018 002F5220 002FC8D0 00300618
None of them is camera, frustum or GS code -- the bodies are game logic and
toolchain-status material. Recorded because matched-in-overlay addresses are the
thing `PROVENANCE.md` says is reliable here, and `20655c7` needs exactly that kind
of anchor to follow `UpdateCamera_6` from `lvl.camvtbl`.
+6
View File
@@ -88,6 +88,12 @@ Visual and behavioural comparison against PCSX2, replaying recorded input and
comparing frames, level by level. The PS2 float model is reproduced rather than comparing frames, level by level. The PS2 float model is reproduced rather than
approximated — see [`AGENTS.md`](AGENTS.md#2-reproduce-the-ps2-float-model). approximated — see [`AGENTS.md`](AGENTS.md#2-reproduce-the-ps2-float-model).
PCSX2 is installed and working on this machine; the comparison loop that reads
the game's own memory over its debug protocol is up, and level 0's and level
1's tfrag lighting are verified byte-exact against it. See
[`docs/plan/pcsx2-verification.md`](docs/plan/pcsx2-verification.md) for the
setup and how to run it.
## Credits and licensing ## Credits and licensing
This project learns from and is built on the work of others. Licences are This project learns from and is built on the work of others. Licences are
+2
View File
@@ -263,6 +263,8 @@ pub fn vertex_color(g: &TfragGeometry, vinfo: usize) -> [f32; 4] {
let i = g.position_index(vinfo); let i = g.position_index(vinfo);
match g.rgba.get(i) { match g.rgba.get(i) {
// The disc's 0x80-is-1.0 alpha convention, same as the texture CLUT. // The disc's 0x80-is-1.0 alpha convention, same as the texture CLUT.
// `gc_formats::gs::alpha_to_8` states this rule now; kept inline here
// because this path wants a float, and the rule has only one live site.
Some(c) => { Some(c) => {
let a = if c.a < 0x80 { c.a as f32 / 128.0 } else { 1.0 }; let a = if c.a < 0x80 { c.a as f32 / 128.0 } else { 1.0 };
[ [
+20 -4
View File
@@ -19,7 +19,8 @@ use std::path::PathBuf;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use bevy::asset::RenderAssetUsages; use bevy::asset::RenderAssetUsages;
use bevy::image::{ImageFilterMode, ImageSampler, ImageSamplerDescriptor}; use bevy::image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
use bevy::material::AlphaMode;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::render::mesh::{Indices, PrimitiveTopology}; use bevy::render::mesh::{Indices, PrimitiveTopology};
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
@@ -308,6 +309,12 @@ fn spawn_level(
// Double-sided: the strips as stored do not carry a consistent // Double-sided: the strips as stored do not carry a consistent
// winding, and the game relies on GS defaults rather than culling. // winding, and the game relies on GS defaults rather than culling.
cull_mode: None, cull_mode: None,
// Blend, not Mask. The disc's alpha is 97.94% opaque, 0.98% clear and
// 1.08% partial across 126 values, and Blend handles all three: a
// clear texel blends to invisible, a partial one stays smooth. Mask
// would flatten those 1.08% to fully opaque, discarding exactly the
// soft edges they exist to produce.
alpha_mode: AlphaMode::Blend,
..default() ..default()
}); });
commands.spawn(( commands.spawn((
@@ -386,11 +393,20 @@ fn image_of(t: &gc_formats::texture::Texture) -> Image {
TextureFormat::Rgba8UnormSrgb, TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(), RenderAssetUsages::default(),
); );
// Only mip level 0 is decoded, so minification must clamp rather than // The GS clamps; bevy's default is Repeat. This was `..default()` with a
// invent a level the disc does not have here. // comment claiming the sampler clamped, which it did not -- the default is
// ImageAddressMode::Repeat. A tile edge therefore wrapped onto its opposite
// edge instead of repeating the border texel, which is the seam you see when
// a patch runs off its texture. See gc_formats::gs::clamp01.
//
// Only mip level 0 is decoded, so there is no chain to interpolate: the
// mipmap filter is Nearest because it samples that single level either way,
// and gs::mip_level would return 0 for every patch until levels are decoded.
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
label: Some("tfrag texture".into()), label: Some("tfrag texture".into()),
mipmap_filter: ImageFilterMode::Linear, address_mode_u: ImageAddressMode::ClampToEdge,
address_mode_v: ImageAddressMode::ClampToEdge,
mipmap_filter: ImageFilterMode::Nearest,
mag_filter: ImageFilterMode::Linear, mag_filter: ImageFilterMode::Linear,
..default() ..default()
}); });
+124 -14
View File
@@ -136,6 +136,44 @@ fn active_set(bank: &LightBank, select: u16) -> Option<ActiveSet> {
}) })
} }
/// The pass's terms for one vertex.
///
/// This exists because a colour mismatch on its own cannot be attributed: the
/// final byte is a sum of five things, and the PCSX2 comparison -- the only
/// check on this pass there is -- is worthless without knowing *which* of them
/// the game disagrees with. It is a snapshot of what the pass computed, not a
/// second implementation: `light_vertex_n` and this share one body.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VertexTerms {
/// The record's azimuth and elevation indices.
pub azimuth: u8,
pub elevation: u8,
/// The record's base colour, PEXT5-expanded.
pub base: [u8; 4],
/// The `select` word, the set it named, and whether it blended two.
pub select: u16,
pub set_index: Option<usize>,
pub blended: bool,
/// The vertex's normal.
pub normal: [f32; 4],
/// The active set's colours and directions, with the w lanes cleared as
/// the pass clears them.
pub color_a: [f32; 4],
pub color_b: [f32; 4],
pub dir_a: [f32; 4],
pub dir_b: [f32; 4],
/// The back-face factors taken from the colour w lanes.
pub wa: f32,
pub wb: f32,
/// The two dot products, and each after its `max(d, d*w)`.
pub dot_a: f32,
pub dot_b: f32,
pub max_a: f32,
pub max_b: f32,
/// The final colour.
pub out: [u8; 4],
}
/// Light one vertex: `base + A.rgb * max(dA, dA*wA) + B.rgb * max(dB, dB*wB)`. /// Light one vertex: `base + A.rgb * max(dA, dA*wA) + B.rgb * max(dB, dB*wB)`.
pub fn light_vertex( pub fn light_vertex(
bank: &LightBank, bank: &LightBank,
@@ -151,20 +189,65 @@ fn light_vertex_n(
light: &gc_formats::tfrag::TfragLight, light: &gc_formats::tfrag::TfragLight,
select: u16, select: u16,
) -> Option<[u8; 4]> { ) -> Option<[u8; 4]> {
Some(light_vertex_terms(bank, n, light, select)?.out)
}
/// Light one vertex, keeping the terms for the comparison.
pub fn light_vertex_terms(
bank: &LightBank,
n: V4,
light: &gc_formats::tfrag::TfragLight,
select: u16,
) -> Option<VertexTerms> {
let s = active_set(bank, select)?; let s = active_set(bank, select)?;
let da = dot3(n, s.da); let da = dot3(n, s.da);
let db = dot3(n, s.db); let db = dot3(n, s.db);
let da = ps2::max(da, ps2::mul(da, s.wa)); let da_max = ps2::max(da, ps2::mul(da, s.wa));
let db = ps2::max(db, ps2::mul(db, s.wb)); let db_max = ps2::max(db, ps2::mul(db, s.wb));
let acc = vmuls(color_float(pext5(light.color as u16)), ps2::ONE); let base = pext5(light.color as u16);
let acc = vmadds(acc, s.ca, da); let acc = vmuls(color_float(base), ps2::ONE);
Some(pack_color(vmadds(acc, s.cb, db))) let acc = vmadds(acc, s.ca, da_max);
let out = pack_color(vmadds(acc, s.cb, db_max));
let unbits = |v: V4| v.map(f32::from_bits);
Some(VertexTerms {
azimuth: light.azimuth_index(),
elevation: light.elevation_index(),
base,
select,
set_index: if select & 0xff00 == 0 {
bank.sets
.get((select & 0xf) as usize)
.map(|_| (select & 0xf) as usize)
} else {
let low = bank.sets.get((select & 0xf) as usize);
let high = bank.sets.get(((select >> 4) & 0xf) as usize);
low.and(high).map(|_| (select & 0xf) as usize)
},
blended: select & 0xff00 != 0,
normal: unbits(n),
color_a: unbits(s.ca),
color_b: unbits(s.cb),
dir_a: unbits(s.da),
dir_b: unbits(s.db),
wa: f32::from_bits(s.wa),
wb: f32::from_bits(s.wb),
dot_a: f32::from_bits(da),
dot_b: f32::from_bits(db),
max_a: f32::from_bits(da_max),
max_b: f32::from_bits(db_max),
out,
})
} }
/// The normal a vertex lights with, as raw PS2 floats. /// The normal a vertex lights with, as raw PS2 floats.
/// ///
/// The stored record's azimuth and elevation are *indices*, so they index the /// The stored record's azimuth and elevation are *indices*, so they index the
/// sine table rather than naming an angle. /// sine table rather than naming an angle.
///
/// All three components are negated: the stored normals point into the
/// surface. The z sign is what the PCSX2 comparison settled -- negating only x
/// and y still produces a plausible level, and it cost 48 percentage points of
/// agreement on level 0 (95.88% vs 100.00%) before it was measured.
pub fn vertex_normal_bits(table: &NormalTable, azimuth: u8, elevation: u8) -> V4 { pub fn vertex_normal_bits(table: &NormalTable, azimuth: u8, elevation: u8) -> V4 {
let (ca, sa) = table.get(azimuth); let (ca, sa) = table.get(azimuth);
let (ce, se) = table.get(elevation); let (ce, se) = table.get(elevation);
@@ -173,22 +256,23 @@ pub fn vertex_normal_bits(table: &NormalTable, azimuth: u8, elevation: u8) -> V4
[ [
ps2::sub(0, x), ps2::sub(0, x),
ps2::sub(0, y), ps2::sub(0, y),
ps2::add(0, se.to_bits()), ps2::sub(0, se.to_bits()),
se.to_bits(), se.to_bits(),
] ]
} }
/// Run the lighting pass over a patch's light records. /// Run the lighting pass over a patch's light records.
/// ///
/// Returns one colour per light record, in the same order. Records whose /// Returns one colour per light record, in the same order. A record whose
/// position has no light, or whose light set does not exist, yield transparent /// `select` names a light set the bank does not have is left at its base
/// black rather than being skipped, so the result lines up with the record /// colour, which is what the game writes: the PCSX2 comparison shows those
/// array. /// records' RAM colours equal to their PEXT5 base exactly. Transparent black
/// was the first guess and it was wrong -- the vertex is unlit, not invisible.
pub fn light_tfrag(g: &TfragGeometry, bank: &LightBank, table: &NormalTable) -> Vec<[u8; 4]> { pub fn light_tfrag(g: &TfragGeometry, bank: &LightBank, table: &NormalTable) -> Vec<[u8; 4]> {
let mut out = Vec::with_capacity(g.lights.len()); let mut out = Vec::with_capacity(g.lights.len());
for l in &g.lights { for l in &g.lights {
let n = vertex_normal_bits(table, l.azimuth_index(), l.elevation_index()); let n = vertex_normal_bits(table, l.azimuth_index(), l.elevation_index());
out.push(light_vertex_n(bank, n, l, l.select).unwrap_or([0, 0, 0, 0])); out.push(light_vertex_n(bank, n, l, l.select).unwrap_or(pext5(l.color as u16)));
} }
out out
} }
@@ -273,11 +357,20 @@ mod tests {
} }
#[test] #[test]
fn an_empty_bank_lights_nothing() { fn an_empty_bank_leaves_the_base_colour() {
// The game's own answer, over PINE: a record whose select names a set
// the bank does not have keeps its base colour. Transparent black was
// the first guess, and the comparison read it as a 4.4% mismatch on
// level 0 before this was measured.
let bank = LightBank::default(); let bank = LightBank::default();
let t = table(); let t = table();
let out = light_tfrag(&patch(vec![light(0)]), &bank, &t); let mut l = light(0);
assert_eq!(out, vec![[0, 0, 0, 0]]); // 5:5:5:1 with the top red step and the alpha bit set: (8, 0, 0,
// 128). Stored as the i16 the record carries, expanded by PEXT5.
l.color = 0x8001u16.cast_signed();
let out = light_tfrag(&patch(vec![l]), &bank, &t);
// PEXT5 of 1 is 1 << 3 = 8, with the alpha bit at 0x80.
assert_eq!(out, vec![[8, 0, 0, 128]]);
} }
#[test] #[test]
@@ -289,4 +382,21 @@ mod tests {
assert_eq!(n[1], 0); assert_eq!(n[1], 0);
assert_eq!(n[2], 0); assert_eq!(n[2], 0);
} }
#[test]
fn the_normal_s_z_is_negated_like_x_and_y() {
// Elevation 64 is straight up: (0, 0, 1) before negation, so the
// stored normal points down into the surface. Getting the z sign
// wrong still produces a plausible level -- it flips which vertices
// a light reaches -- and it cost 48 percentage points of agreement
// before the PCSX2 comparison caught it.
let t = table();
let n = vertex_normal_bits(&t, 0, 64);
let f = |b: u32| f32::from_bits(b);
// The table's cos(pi/2) is 1.5e-8 rather than exactly zero, which is
// what the game's own table holds, so x and y are ~0 rather than 0.
assert!(f(n[0]).abs() < 1e-6, "x should be ~0, got {}", f(n[0]));
assert!(f(n[1]).abs() < 1e-6, "y should be ~0, got {}", f(n[1]));
assert_eq!(n[2], f32::to_bits(-1.0));
}
} }
+2
View File
@@ -19,3 +19,5 @@ path = "src/main.rs"
[dev-dependencies] [dev-dependencies]
gc-formats.workspace = true gc-formats.workspace = true
# For the savestate-gated RAM tests only. Not used by the library.
gc-trace = { workspace = true }
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
use gc_formats::level_core::LevelCoreHeader;
fn main() {
let core = std::fs::read("/home/reaper/Documents/gc-extracted/level0/core_data.bin").unwrap();
let idx = std::fs::read("/home/reaper/Documents/gc-extracted/level0/core_index.bin").unwrap();
let h = LevelCoreHeader::parse(&idx).unwrap();
let sky = h.sky as usize;
let end = [
h.collision as usize,
h.moby_classes.offset as usize,
core.len(),
]
.into_iter()
.filter(|&v| v > sky)
.min()
.unwrap_or(core.len());
let b = &core[sky..end];
let mut offs = Vec::new();
for k in 0..64 {
let at = 0x20 + k * 4;
if at + 4 > b.len() {
break;
}
let v = u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) as usize;
if v == 0 || v >= b.len() {
break;
}
offs.push(v);
}
println!(
"RANGE: level0 sky, {} sub-objects from the +0x20 table",
offs.len()
);
for (i, &o) in offs.iter().enumerate() {
let next = offs.get(i + 1).copied().unwrap_or(b.len());
println!(" sub{i}: {o}..{next} = {} bytes", next - o);
let e2 = next.min(b.len());
let seg = &b[o..e2];
for r in 0..3 {
let q = r * 16;
if q + 16 <= seg.len() {
println!(
" +{q:03x}: {}",
seg[q..q + 16]
.iter()
.map(|x| format!("{x:02x}"))
.collect::<Vec<_>>()
.join(" ")
);
}
}
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ pub fn probe_level(dir: &Path, level: u32) -> Result<LevelProbe> {
header.push((name.into(), range(v))); header.push((name.into(), range(v)));
} }
let regions = gc_formats::regions::core_layout(&index, &h, core.len()); let regions = gc_formats::regions::core_layout(&index, &core, &h);
let mut classes = Vec::new(); let mut classes = Vec::new();
for (name, r, stride) in [ for (name, r, stride) in [
File diff suppressed because it is too large Load Diff
+352 -16
View File
@@ -16,9 +16,11 @@
//! ``` //! ```
//! //!
//! and each tagged offset is `0xF0000000 | low24`. Resolving a record at //! and each tagged offset is `0xF0000000 | low24`. Resolving a record at
//! `chunk_start + low` yields records that share a field layout: 17-21% of //! `chunk_start + low` yields records that share a field layout: 16.7% to 20.0%
//! byte positions are constant across a chunk's own records, on every level. //! of byte positions are constant across a chunk's own records, on every level.
//! That is what distinguishes this from a coincidental byte pattern. //! That is what distinguishes this from a coincidental byte pattern. Records in
//! one chunk are **not** all the same length -- each runs to its successor's
//! offset -- so the statistic is computed over the span every record shares.
//! //!
//! The stride between records is a property of the chunk, not of the region. //! The stride between records is a property of the chunk, not of the region.
//! Level 0 alone contains chunks at 148, 340, 368, 288, 320, 236, 272, 384, //! Level 0 alone contains chunks at 148, 340, 368, 288, 320, 236, 272, 384,
@@ -43,13 +45,72 @@ pub const CHUNK_GAP_BYTES: usize = 10;
/// The value every tagged offset's top byte carries, on all 27 levels. /// The value every tagged offset's top byte carries, on all 27 levels.
pub const CHUNK_TAG: u8 = 0xf0; pub const CHUNK_TAG: u8 = 0xf0;
/// Bytes between a chunk record's leading word and the payload the table
/// points at.
///
/// A record is `[leading u16][two index bytes][payload]`, and the disc's
/// offset table points at the *payload*. The loaded table in RAM points two
/// bytes earlier, at the leading word -- the payload's index bytes are then at
/// `+2` from the pointer -- so the game's record starts here and the disc's
/// table is two bytes late. See [`Chunk::prefix`].
pub const RECORD_PREFIX_BYTES: usize = 2;
/// The two bytes immediately preceding every chunk region on the disc.
///
/// The last class body in a level -- shrub where the level has shrub, tie
/// where it does not -- ends with this constant pair before the chunk
/// container begins. It is *not* a unique pattern: the pair occurs 25 to 103
/// times in a 5 to 17 MB lump, so on its own it means nothing. What locates
/// the boundary is the pair sitting exactly in front of a chunk signature
/// (see [`chunk_region_start`]).
///
/// Not interpreted. Whether it is a terminator, an end count or a tag is not
/// established; what is established is that it is constant across all 27
/// levels and sits at this one place.
/// Alignment of the first byte of a chunk record.
///
/// The leading byte is not decoded -- what it indexes is not established, and
/// naming it would be a guess. What *is* established is that it is a small
/// bucket index, not data: within a chunk type it takes 16 discrete values
/// (0x00, 0x10, ... 0xf0) with near-uniform counts, so it reads as an index into
/// groups rather than a running offset.
///
/// **The alignment step is per type, not global.** Across all 27 levels, over
/// 1,509 chunks and 34,491 records:
///
/// - the leading byte is a multiple of **8** on 34,432 of 34,491;
/// - a multiple of **16** on only 33,349 -- the 1,142-record difference falls in
/// **seven** types, four of which step their buckets by 8 (0x00, 0x08, 0x10,
/// ...) instead of 16;
/// - and 59 records in three types break even the 8-byte rule, which is where the
/// variable-length type lives (0x00f2ef4275, records 76 to 104 bytes).
///
/// So [`record_is_aligned`] is checked at 8, not 16, and a type that steps by 16
/// is not distinguishable from one that steps by 8 without looking at the type.
pub const CHUNK_RECORD_ALIGNMENT: usize = 8;
/// True when a record's leading byte is a multiple of [`CHUNK_RECORD_ALIGNMENT`].
///
/// Measured over all 27 levels: 34,432 of 34,491 records, with the exceptions
/// confined to the seven types above.
pub fn record_is_aligned(record: &[u8]) -> bool {
match record.first() {
Some(&b) => (b as usize) % CHUNK_RECORD_ALIGNMENT == 0,
None => false,
}
}
pub const CHUNK_REGION_MARKER: [u8; 2] = [0xf6, 0x7c];
/// A chunk header plus the records its offsets address. /// A chunk header plus the records its offsets address.
/// ///
/// `stride` is the distance between the first two resolved offsets. Every /// `stride` is the distance between the first two resolved offsets, and nothing
/// offset after the first is `stride` from its predecessor within a few bytes /// more. The deltas within a chunk are **not** constant, and the variation is
/// on the measured levels, but the parser takes each offset literally rather /// real: on level 0 some chunks step 340, 340, 336, 340, and on all 27 levels
/// than assuming a uniform stride, because the deltas are not exactly constant /// signature `[75 42 ef f2 71 b5]` steps 92, 88, 84, 92, 92, 92, 84, 88. Taking
/// (e.g. 340, 340, 336, 340 on level 0). /// every record at `stride` would overlap four of those eight with their own
/// successors, which an explicit offset table cannot express. Record *lengths*
/// come from the deltas; see [`resolve`].
pub struct Chunk { pub struct Chunk {
/// The 16 opaque header bytes. /// The 16 opaque header bytes.
pub header: [u8; CHUNK_HEADER_BYTES], pub header: [u8; CHUNK_HEADER_BYTES],
@@ -64,7 +125,28 @@ pub struct Chunk {
/// The low 24 bits of each tagged offset, in file order. /// The low 24 bits of each tagged offset, in file order.
pub offsets: Vec<u32>, pub offsets: Vec<u32>,
/// Record stride, from the first two offsets. Zero if fewer than two. /// Record stride, from the first two offsets. Zero if fewer than two.
///
pub stride: usize, pub stride: usize,
/// The `u16` immediately before the first record's payload -- the record's
/// own leading word, which the loaded table points at.
///
/// **Measured on the disc:** the same at every record of a chunk on
/// **1,439 of 1,509 chunks** -- `None` on the 70 that vary -- and fixed
/// within every chunk type, over the 71 types. Its values are 1, 2, 4, 6,
/// 8 and 10; 554 chunks carry 2 and 520 carry 4.
///
/// **Measured against the running game:** in PCSX2's RAM the rewritten
/// offset table points at this word rather than at the payload, two bytes
/// earlier than the disc's table, so the game's record starts here.
///
/// Not named. What the value counts is not established: it is *not* the
/// payload's entry count (the payload length divided by it is a whole
/// number on 1,344 chunks and an odd fraction on the other 162, leaving 57
/// distinct "sizes"), and it is not the number of distinct leading bytes
/// (agrees on 0 of 1,506). An earlier note in this file read the
/// `02 00` seen in RAM as a prefix the loader had inserted; those bytes
/// are on the disc already, at every record, which is what this field is.
pub prefix: Option<u16>,
} }
/// One chunk, with its records already bounds-checked. /// One chunk, with its records already bounds-checked.
@@ -75,6 +157,121 @@ pub struct ResolvedChunk<'a> {
pub records: Vec<&'a [u8]>, pub records: Vec<&'a [u8]>,
} }
impl<'a> ResolvedChunk<'a> {
/// The per-chunk phase and step that explain every record's leading byte, or
/// `None` when no `(step, phase)` in that form fits.
///
/// **Measured: 1,262 of 1,509 chunks** across all 27 levels are fully explained
/// by `record[i].leading == step * ((i + phase) mod (256 / step))`, with
/// `step` taking 8 on 51 chunks, 16 on 554, 32 on 520 and 64 on 137. The other
/// 247 chunks, across 11 chunk types, are not explained by it.
///
/// Where this returns `Some`, the leading byte carries **no per-record
/// information**: it is a deterministic function of the record's index and the
/// chunk's phase, so it is a position marker rather than data. The 247 chunks
/// where it returns `None` are the ones where that has *not* been shown, and
/// nothing should skip their leading byte on the strength of this.
pub fn bucket_rule(&self) -> Option<(u8, usize)> {
use std::collections::BTreeSet;
if self.records.is_empty() {
return None;
}
let n = self.records.len();
for step in [8u16, 16, 32, 64] {
let m = (256 / step) as usize;
for phase in 0..m {
let mut ok = true;
for (i, r) in self.records.iter().enumerate() {
let Some(&b) = r.first() else {
ok = false;
break;
};
if b != ((((i + phase) % m) * step as usize % 256) as u8) {
ok = false;
break;
}
}
if ok {
return Some((step as u8, phase));
}
}
}
let _ = n;
let _: Option<BTreeSet<u8>> = None;
None
}
/// True when every record in this chunk has the same length.
///
/// **Measured: true for 257 of 1,509 chunks** across all 27 levels. Record
/// length varies *between* chunks of the same type -- one type runs 76 to 104
/// bytes -- and, on 1,252 chunks, *within* one as well.
///
/// This method used to report 1,509 of 1,509, and that was an artefact of
/// `resolve`: it sliced every record at the chunk's first offset delta, so
/// every record came out the same length by construction, and this predicate
/// was confirming the parser back to itself. `resolve` now bounds each record
/// by its successor's offset, which is the only coherent reading -- a record
/// of the nominal stride would overlap the next one whenever the delta is
/// smaller, and an explicit offset table cannot express an overlap.
///
/// So the earlier withdrawal, "an earlier claim that some type had
/// variable-length records was pooling across chunks", was also wrong. The
/// records are variable-length and were never anything else.
pub fn has_uniform_record_length(&self) -> bool {
let mut it = self.records.iter().filter(|r| !r.is_empty());
let Some(first) = it.next().map(|r| r.len()) else {
return true;
};
it.all(|r| r.len() == first)
}
/// The per-chunk `(step, phase) that makes the first **two** bytes of every
/// record encode that record's own index, or `None` when no such pair fits.
///
/// With `g = 256 / step` and `j = index + phase`:
///
/// ```text
/// record[0] == step * (j mod g)
/// record[1] == (j / g) mod 256
/// ```
///
/// so the pair is the record index in mixed radix, and the leading bytes carry
/// **no per-record information** -- a record knows where it is.
///
/// **Measured: 1,235 of 1,509 chunks** across all 27 levels fit this exactly.
/// [`Self::bucket_rule`] -- byte 0 alone -- fits 1,262; the 27-chunk difference
/// are chunks whose first byte follows the rule but whose second does not.
///
/// The remaining 274 chunks are not explained by it.
/// Nothing should skip a leading byte on the strength of this where it returns
/// `None`.
pub fn index_rule(&self) -> Option<(u8, usize)> {
for step in [8u16, 16, 32, 64] {
let g = (256 / step) as usize;
for phase in 0..g {
let mut ok = true;
for (i, r) in self.records.iter().enumerate() {
if r.len() < 2 {
continue;
}
let j = i + phase;
if r[0] != ((j % g) * step as usize % 256) as u8
|| r[1] != ((j / g) % 256) as u8
{
ok = false;
break;
}
}
if ok && self.records.iter().any(|r| r.len() >= 2) {
return Some((step as u8, phase));
}
}
}
None
}
}
/// True if `offset` looks like the start of a chunk. /// True if `offset` looks like the start of a chunk.
/// ///
/// The signature is the header's last byte being `0xff`, the 10-byte gap /// The signature is the header's last byte being `0xff`, the 10-byte gap
@@ -97,6 +294,38 @@ pub fn is_chunk_start(region: &[u8], offset: usize, min_offsets: usize) -> bool
}) })
} }
/// The start of the chunk container: the first offset at or after `from`
/// carrying a chunk signature immediately preceded by [`CHUNK_REGION_MARKER`].
///
/// This is what closes the last class region (shrub, or tie on the levels
/// without shrub). The two parts of the signature are each weak alone -- the
/// marker pair occurs by chance dozens of times per lump, and the chunk shape
/// matches random data often enough to need four tagged offsets to be believed
/// -- and together they are exact: on all 27 levels the marker sits exactly
/// two bytes before the first chunk header, with no further marker-paired
/// signature after the region start.
///
/// Returns `None` when the signature is not found, so an unlocated boundary
/// stays visible rather than being closed against a guess.
pub fn chunk_region_start(core: &[u8], from: usize) -> Option<usize> {
if from < 2 {
return None;
}
// The marker's first byte is the cheapest rejection, so test it before
// comparing the pair or the signature behind it.
let mut at = from - 2;
while at + 2 <= core.len() {
if core[at] == CHUNK_REGION_MARKER[0]
&& core[at + 1] == CHUNK_REGION_MARKER[1]
&& is_chunk_start(core, at + 2, 4)
{
return Some(at + 2);
}
at += 1;
}
None
}
/// Little-endian, matching `Buf::u32` and the PS2. /// Little-endian, matching `Buf::u32` and the PS2.
fn read_u32(bytes: &[u8], at: usize) -> u32 { fn read_u32(bytes: &[u8], at: usize) -> u32 {
u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]])
@@ -141,25 +370,68 @@ pub fn parse_chunk(region: &[u8], offset: usize) -> Result<Option<Chunk>> {
0 0
}; };
// The leading word sits two bytes before the payload the table points at.
// Reading it and requiring it to be the same at every record is a
// signature check like the header's last byte: it holds on every chunk on
// The leading word sits two bytes before the payload the table points at.
// It is the same at every record of a chunk on 1,439 of 1,509 chunks; the
// other 70 vary, so it stays an Option rather than a value a caller could
// mistake for a field that is always there.
let read_prefix = |low: u32| -> Option<u16> {
let at = offset
.checked_add(low as usize)?
.checked_sub(RECORD_PREFIX_BYTES)?;
region
.get(at..at + RECORD_PREFIX_BYTES)
.map(|b| u16::from_le_bytes([b[0], b[1]]))
};
let prefix = offsets.first().copied().and_then(read_prefix).filter(|&p| {
offsets
.iter()
.skip(1)
.all(|&low| read_prefix(low) == Some(p))
});
Ok(Some(Chunk { Ok(Some(Chunk {
count: header[14], count: header[14],
header, header,
offset, offset,
offsets, offsets,
stride, stride,
prefix,
})) }))
} }
/// Resolve every record a chunk addresses, bounds-checked against `region`. /// Resolve every record a chunk addresses, bounds-checked against `region`.
/// ///
/// Each record is `region[chunk.offset + low .. + stride]`. A record that /// A record runs from its own offset to the **next** record's offset, not to
/// would run past the end of the region is dropped rather than clamped, so the /// `chunk.offset + stride`.
/// returned length may be shorter than `chunk.offsets` -- on the measured ///
/// levels it is not, but a parser that silently clamps would hide a malformed /// This is not a stylistic choice. `stride` is the *first* offset delta, and on
/// chunk rather than report it. /// 1,252 of the 1,509 measured chunks later deltas are smaller -- the deviations
/// are word-aligned multiples of 4, most exactly +/-4. Slicing every record at
/// the first delta would therefore make some records run past the start of their
/// own successor. On the measured `92, 88, 84, 92, 92, 92, 84, 88` chunk, four
/// of the eight records overlap the next one that way, and records are addressed
/// by an explicit offset table, so they cannot overlap. The only coherent
/// reading is that a record ends where the next begins.
///
/// Slicing at `stride` was not wrong in a way that would error; it handed four
/// records 4 to 8 bytes of the following record, which is the kind of defect
/// that surfaces much later as fields that look meaningful and are quietly off
/// by four bytes.
///
/// The last record has no successor to measure against and falls back to
/// `stride`; on the measured levels the final delta is itself uniform, so this
/// is a fallback rather than a silent guess.
///
/// A record that would run past the end of the region is dropped rather than
/// clamped, so the returned length may be shorter than `chunk.offsets` -- on the
/// measured levels it is not, but a parser that silently clamps would hide a
/// malformed chunk rather than report it.
pub fn resolve<'r>(chunk: &Chunk, region: &'r [u8]) -> Result<ResolvedChunk<'r>> { pub fn resolve<'r>(chunk: &Chunk, region: &'r [u8]) -> Result<ResolvedChunk<'r>> {
let mut records = Vec::with_capacity(chunk.offsets.len()); let mut records = Vec::with_capacity(chunk.offsets.len());
for &low in &chunk.offsets { for (i, &low) in chunk.offsets.iter().enumerate() {
let start = chunk let start = chunk
.offset .offset
.checked_add(low as usize) .checked_add(low as usize)
@@ -168,15 +440,20 @@ pub fn resolve<'r>(chunk: &Chunk, region: &'r [u8]) -> Result<ResolvedChunk<'r>>
context: "chunk record", context: "chunk record",
kind: ErrorKind::Invalid("chunk offset overflows usize"), kind: ErrorKind::Invalid("chunk offset overflows usize"),
})?; })?;
if start + chunk.stride > region.len() { let len = match chunk.offsets.get(i + 1) {
Some(&next) => (next - low) as usize,
None => chunk.stride,
};
if start + len > region.len() {
continue; continue;
} }
records.push(&region[start..start + chunk.stride]); records.push(&region[start..start + len]);
} }
Ok(ResolvedChunk { Ok(ResolvedChunk {
chunk: Chunk { chunk: Chunk {
count: chunk.count, count: chunk.count,
header: chunk.header, header: chunk.header,
prefix: chunk.prefix,
offset: chunk.offset, offset: chunk.offset,
offsets: chunk.offsets.clone(), offsets: chunk.offsets.clone(),
stride: chunk.stride, stride: chunk.stride,
@@ -207,3 +484,62 @@ pub fn parse_all_chunks(region: &[u8]) -> Result<Vec<ResolvedChunk<'_>>> {
} }
Ok(out) Ok(out)
} }
#[cfg(test)]
mod tests {
use super::*;
/// A chunk signature at `at`, preceded by the marker: the marker pair, the
/// 16-byte header whose last byte is `0xff`, the 10 zero bytes, then four
/// tagged offsets.
fn plant_chunk(core: &mut [u8], at: usize) {
let base = at - 2;
core[base..base + 2].copy_from_slice(&CHUNK_REGION_MARKER);
// Header: byte 14 is the record count, matching the four tagged words.
core[at + 14] = 4;
core[at + 15] = 0xff;
// The 10 zero bytes of the gap at +0x10. Their low bits resolve to a
// zero stride, which is fine: only the signature is under test.
core[at + CHUNK_HEADER_BYTES..at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES].fill(0);
for k in 0..4 {
let word = (CHUNK_TAG as u32) << 24;
core[at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES + k * 4
..at + CHUNK_HEADER_BYTES + CHUNK_GAP_BYTES + k * 4 + 4]
.copy_from_slice(&word.to_le_bytes());
}
}
#[test]
fn finds_the_markered_signature() {
let mut core = vec![0x5au8; 256];
plant_chunk(&mut core, 128);
assert_eq!(chunk_region_start(&core, 64), Some(128));
// Scanning from earlier still finds it: the signature is what locates
// the boundary, not the search start.
assert_eq!(chunk_region_start(&core, 2), Some(128));
}
#[test]
fn a_marker_without_a_signature_is_not_a_boundary() {
let mut core = vec![0x5au8; 256];
core[10..12].copy_from_slice(&CHUNK_REGION_MARKER);
assert_eq!(chunk_region_start(&core, 2), None);
}
#[test]
fn a_signature_without_the_marker_is_not_a_boundary() {
let mut core = vec![0x5au8; 256];
// Plant the signature, then break the marker in front of it.
plant_chunk(&mut core, 128);
core[126] = 0x5a;
assert_eq!(chunk_region_start(&core, 2), None);
}
#[test]
fn an_unlocated_boundary_stays_none() {
assert_eq!(chunk_region_start(&[], 2), None);
// `from` below the marker's own minimum is refused rather than
// underflowing.
assert_eq!(chunk_region_start(&[0xf6], 0), None);
}
}
+48 -2
View File
@@ -27,18 +27,48 @@ pub struct CollisionHeader {
pub header_size: u32, pub header_size: u32,
/// Size of the payload that follows. /// Size of the payload that follows.
pub payload_size: u32, pub payload_size: u32,
/// The word at `+0x08`.
///
/// Measured on all 27 levels that carry collision: the rest of the 0x40
/// header is zero padding on 22 of them, but **5 levels carry a large
/// non-zero word here** -- 1,248,048, 1,766,832, 2,541,616, 3,376,592 and
/// 4,060,640, on levels 7, 9, 12, 13 and 14. Meaning not established; it
/// is a size, an offset, or a second block's length, and nothing here
/// distinguishes those.
pub field_08: u32,
/// The payload's first `u16`, at `+0x40`.
///
/// Measured on all **19** levels that carry a non-empty payload: the two
/// `u16` at `+0x40` and `+0x42` read 1..74 and 13..112 respectively, with
/// products from 53 to 3,256. Both are non-zero on every one of the 19,
/// and neither exceeds a size a collision grid would plausibly use.
///
/// Reported as two counts, not as a named grid: nothing measured here ties
/// the product to the payload size or to any per-cell stride, so calling it
/// width and depth would be a reading, not a measurement.
pub first: u16,
/// The payload's second `u16`, at `+0x42`. See [`CollisionHeader::first`].
pub second: u16,
} }
impl CollisionHeader { impl CollisionHeader {
/// Parse the 8 meaningful bytes at the head. /// Parse the 8 meaningful bytes at the head.
/// ///
/// The remaining 0x38 bytes are zero on all 27 levels, but they are not /// The remaining 0x38 bytes are zero on 22 of 27 levels, but they are not
/// required to be: only the two fields below are load-bearing. /// required to be -- five carry a large word at `+0x08` -- so only the
/// fields below are load-bearing.
///
/// `first` and `second` read the payload, not the header, so they are zero
/// here and filled in by [`CollisionHeader::region`], which is the only
/// entry point that knows where the payload starts.
pub fn parse(bytes: &[u8]) -> Result<CollisionHeader> { pub fn parse(bytes: &[u8]) -> Result<CollisionHeader> {
let mut b = Buf::new(bytes); let mut b = Buf::new(bytes);
Ok(CollisionHeader { Ok(CollisionHeader {
header_size: b.u32("collision header size")?, header_size: b.u32("collision header size")?,
payload_size: b.u32("collision payload size")?, payload_size: b.u32("collision payload size")?,
field_08: b.u32("collision +0x08")?,
first: 0,
second: 0,
}) })
} }
} }
@@ -82,6 +112,21 @@ impl CollisionHeader {
}, },
})?; })?;
let pstart = start + hsize; let pstart = start + hsize;
// The payload's own first two u16, read from the bytes past the header.
let lead = core.get(pstart..pstart + 4).ok_or(Error {
offset: pstart,
context: "collision payload is too short for its leading counts",
kind: ErrorKind::OutOfBounds {
needed: 4,
available: core.len().saturating_sub(pstart),
},
})?;
let header = CollisionHeader {
first: u16::from_le_bytes([lead[0], lead[1]]),
second: u16::from_le_bytes([lead[2], lead[3]]),
..header
};
let pend = pstart + header.payload_size as usize; let pend = pstart + header.payload_size as usize;
let payload = core.get(pstart..pend).ok_or(Error { let payload = core.get(pstart..pend).ok_or(Error {
offset: pstart, offset: pstart,
@@ -108,6 +153,7 @@ mod tests {
const BASE: usize = 16; const BASE: usize = 16;
fn core(header_size: u32, payload_size: u32) -> Vec<u8> { fn core(header_size: u32, payload_size: u32) -> Vec<u8> {
#[allow(clippy::let_and_return)]
let mut v = vec![0u8; BASE + 64]; let mut v = vec![0u8; BASE + 64];
v[BASE..BASE + 4].copy_from_slice(&header_size.to_le_bytes()); v[BASE..BASE + 4].copy_from_slice(&header_size.to_le_bytes());
v[BASE + 4..BASE + 8].copy_from_slice(&payload_size.to_le_bytes()); v[BASE + 4..BASE + 8].copy_from_slice(&payload_size.to_le_bytes());
+428
View File
@@ -0,0 +1,428 @@
//! The Graphics Synthesizer's own rules, which are not the GPU's.
//!
//! The GS is a fixed-function part with its own fixed-point formats and its own
//! rounding. Reproducing R&C2's look means reproducing these, not "whatever
//! wgpu does":
//!
//! * mip selection is `round(log2(on-screen texels / texels in the image))`,
//! clamped to the mip chain -- not a hardware mip bias and not nearest
//! sampling;
//! * **alpha `0x80` is 1.0**, because alpha is 7-bit. Treating `0x80` as "half"
//! is the single most common way to get PS2 translucency wrong;
//! * the sampler wraps **CLAMP**, so anything outside 0..1 lands on the edge
//! texel rather than repeating it.
//!
//! Nothing here touches wgpu: these are the rules the renderer must feed it.
//!
//! # Which of these are actually wired
//!
//! Stated explicitly, because a rule that exists in a module is not a rule the
//! renderer applies -- and the sampler in `gc-engine` sat behind a comment
//! claiming it clamped while the code did `..default()`, which is `Repeat`. The
//! failure mode here is a reader assuming a function is in the draw path because
//! it is in a file called `gs.rs`.
//!
//! | rule | wired? | where |
//! |---|---|---|
//! | CLAMP wrap | **yes** | `gc-engine`'s `image_of` sets `address_mode_u/v` |
//! | alpha `0x80` is opaque | **yes** | `texture::scale_alpha` delegates here |
//! | blend / alpha test | **no** | bevy's `AlphaMode` cannot express both at once |
//! | mip selection | **no** | only level 0 is decoded, so `mip_level` is always 0 |
//!
//! `passes_alpha_test` and `BlendMode` are reference: correct, tested, and not on
//! the draw path. That is a deliberate state, not an oversight.
/// Alpha is 7-bit, so `0x80` is fully opaque rather than half-transparent.
///
/// Every byte in this codebase's alpha fields -- moby alpha, the `0x80` default
/// the decomp's `MobyInstance` records at `+0x23` -- is in this range.
pub const ALPHA_ONE: u8 = 0x80;
/// Expand a GS alpha byte to the 0..255 range a shader expects.
///
/// `0x80` and above is the **opaque** end, and it is reached by masking off the top
/// bit -- `0x80 & 0x7F` is zero, which is precisely why `0x80` reads as "half" to
/// anything that treats the byte as 8-bit unsigned.
///
/// Below `0x80` the value scales onto `0..0x80`, so `0x7F` is 253/255 and not
/// fully opaque. That is **not** an accident of this function: `crates/gc-engine`
/// already read the same convention inline as `if c.a < 0x80 { c.a / 128.0 } else
/// { 1.0 }`, and the first version of this scaled by 127 instead, making `0x7F`
/// fully opaque where the renderer had it at 253/255. The renderer was right and
/// this was wrong.
///
/// Extracting the rule into one place is what surfaced the disagreement, which is
/// the argument for doing it even where the code already worked.
#[must_use]
pub fn alpha_to_8(v: u8) -> u8 {
if v >= ALPHA_ONE {
return 255;
}
// scale 0..0x7F onto 0..255, matching the renderer's /128 normalisation
(((v as u32) * 255 + 64) / 128) as u8
}
/// A GS texture coordinate: signed 1/4096, as the hardware stores it.
pub type TexCoord = i16;
/// Wrap a normalised coordinate the way the GS sampler does: CLAMP.
///
/// GS coordinate wrap is `(mode, clamp, repeat, mirror)` and this project loads it
/// with **clamp** for ordinary textures. `u == 1.0` is the far edge, not the start
/// of the next tile, so a coordinate of exactly 1.0 stays at 1.0.
#[must_use]
pub fn clamp01(v: f32) -> f32 {
if v.is_nan() {
0.0
} else {
v.clamp(0.0, 1.0)
}
}
/// The GS's mip selection: `round(log2(on-screen / in-image))`.
///
/// `on_screen` is how many texels the primitive covers on screen and `in_image`
/// is the resolution of the mip level being offered -- so a texture drawn at its
/// native size selects level 0, and one drawn at a quarter size selects level 2.
/// The result is clamped into the mip chain rather than left to run off the end.
///
/// Round-half-away-from-zero, which is what the GS's internal rounding does; using
/// `f32::round` on the log2 directly would put a power of two one level too high
/// for every other case.
#[must_use]
pub fn mip_level(on_screen: f32, in_image: f32, max_level: u32) -> i32 {
// A ratio needs both sides positive and finite; anything else reads as a
// non-mipmapped level rather than a division by a bad operand.
let usable = |v: f32| v > 0.0 && v.is_finite();
if !usable(on_screen) || !usable(in_image) {
return 0;
}
let raw = (in_image / on_screen).log2();
// round half away from zero
let rounded = if raw >= 0.0 {
(raw + 0.5).floor()
} else {
(raw - 0.5).ceil()
};
(rounded as i64).clamp(0, max_level as i64) as i32
}
/// The GS alpha test: compare the fragment's alpha against a reference and
/// discard on one side of it.
///
/// The PS2's `ATE` register holds an enable, a reference 7-bit value, and an
/// `AFAIL` bit that selects *which* side fails. The bit's naming is the trap: it
/// reads as "fail if alpha is above the reference", and on the hardware it means
/// the opposite -- `AFAIL = 0` discards where alpha is **below** the reference.
///
/// Returns true when the fragment survives.
#[must_use]
pub fn passes_alpha_test(alpha: u8, reference: u8, fail_above: bool) -> bool {
// the opaque bit is not part of the comparison, or 0x80 would always pass
let a = alpha.min(ALPHA_ONE - 1);
let r = reference & 0x7F;
if fail_above {
a <= r
} else {
a >= r
}
}
/// The four GS primitive blend modes.
///
/// The PS2 names them `IIIB`, `IIIA`, `IIIB`, `IIIC` in its documentation, which
/// is both unhelpful and easy to misremember -- `IIIB` names two of them. What
/// each actually does is unambiguous, so that is what is here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BlendMode {
/// No blending: the fragment replaces the destination. The GS's default.
#[default]
Off,
/// Source only.
Source,
/// Destination only.
Destination,
/// Source plus destination.
SourcePlusDestination,
}
impl BlendMode {
/// Decode the GS primitive type byte.
///
/// Types above 3 do not exist on the hardware; they are treated as
/// [`BlendMode::Off`] rather than panicking, because the value comes from
/// asset data.
#[must_use]
pub fn from_primitive_type(t: u8) -> BlendMode {
match t & 3 {
1 => BlendMode::Source,
2 => BlendMode::Destination,
3 => BlendMode::SourcePlusDestination,
_ => BlendMode::Off,
}
}
/// Blend a 0..255 source over a 0..255 destination.
///
/// The GS computes in 7-bit and clamps at the top, so this returns the same
/// clamping behaviour rather than a true divide-and-rescale that could exceed
/// 255. Callers wanting the unclamped average should blend before widening.
#[must_use]
pub fn blend(self, src: u8, dst: u8) -> u8 {
let (s, d) = (src as u32, dst as u32);
let out = match self {
BlendMode::Off => s,
BlendMode::Source => s,
BlendMode::Destination => d,
BlendMode::SourcePlusDestination => s + d,
};
out.min(255) as u8
}
}
/// GS TEX0_1, the texture-setup register, decoded from a level texture entry.
///
/// The GS stores a texture by *exponents*, not by dimensions: TW and TH hold
/// `log2(width)` and `log2(height)`, which is why every retail texture in this
/// game is a power of two -- **all 1,595 across the 27 levels, measured**. That
/// is the measurand behind this struct: `tex0_of` returning a TW that is not an
/// exact log2 would mean the entry was not a texture this register can address.
///
/// TBP0 is a **256-byte** block number, and it is relative to the start of the
/// level's texture region, not to the level's data: **19 of the 27 levels have
/// a `textures_base` that is only 64-byte aligned** (mod 256 reads 0, 64, 128
/// or 192), so a `core_data`-relative base pointer would not be a whole number
/// of blocks on those levels. Every entry's own `data_offset` *is* 256-aligned,
/// which is what the region-relative reading requires -- the slots advance by
/// `w*h + (w/2)*(h/2)` bytes, a multiple of 256 for every power-of-two
/// dimension the game uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TEX0 {
/// Texture base pointer, in 256-byte blocks.
pub tbp0: i32,
/// Texture buffer width, in 64-texel blocks.
pub tbw: i32,
/// Pixel storage method. Every level texture is 8-bit indexed, so this is
/// the same constant everywhere; it is a field rather than a constant
/// because a renderer that emulates the GS needs it in the register.
pub psm: i32,
/// `log2(width)`.
pub tw: i32,
/// `log2(height)`.
pub th: i32,
}
/// The one PSM this game's level textures use: 8-bit indices, texel-swizzled.
pub const PSM_PSMT8: i32 = 0x14;
/// Build TEX0 for a level texture entry.
///
/// `data_offset` is the entry's offset **within the texture region**. It is
/// passed rather than the region's own base because the base pointer is
/// region-relative -- see the note on [`TEX0`]. Returns `None` when the
/// dimensions are not powers of two, or when the offset is not a whole
/// 256-byte block, which are the two conditions this register cannot express.
#[must_use]
pub fn tex0_of(data_offset: i32, width: i16, height: i16) -> Option<TEX0> {
let tw = log2_exact(width)?;
let th = log2_exact(height)?;
// TBP0 is a 256-byte block count, so an unaligned base is not a base.
if data_offset < 0 || data_offset % 256 != 0 {
return None;
}
Some(TEX0 {
tbp0: data_offset / 256,
tbw: (width as i32) / 64,
psm: PSM_PSMT8,
tw,
th,
})
}
/// `log2(x)` when `x` is a power of two, else `None`.
#[must_use]
pub fn log2_exact(x: i16) -> Option<i32> {
if x <= 0 || (x & (x - 1)) != 0 {
return None;
}
Some(x.trailing_zeros() as i32)
}
/// Bytes one level-texture slot occupies: level 0 plus one mip level.
///
/// Measured over all 1,595 textures on the 27 levels: the stride between
/// consecutive entries is `w*h + (w/2)*(h/2)` on every dimension class, where
/// the classes are 32, 64, 128 and 256 square. The game stores the base image
/// and one mip; `ty` reads 3 on all 1,595, so it is not the stored level count.
#[must_use]
pub fn texture_slot_bytes(width: i16, height: i16) -> i32 {
let w = width as i64;
let h = height as i64;
(w * h + (w / 2) * (h / 2)) as i32
}
/// The CLUT's byte offset in GS RAM: `palette * 0x100`.
///
/// The established reading (`gc_formats::texture`) is that the level texture's
/// CLUT lives at exactly that address. Restated here because a GS emulator asks
/// for it as TEXCLUT's CBP rather than as a lookup address.
#[must_use]
pub fn clut_base(palette: i16) -> i32 {
palette as i32 * 0x100
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alpha_80_is_fully_opaque() {
assert_eq!(alpha_to_8(0x80), 255, "0x80 must not read as half");
assert_eq!(alpha_to_8(0x00), 0);
assert_eq!(
alpha_to_8(0x7F),
253,
"0x7F is 127/128, not fully opaque -- the renderer's convention"
);
// 0x40 is exactly the midpoint. The renderer works in floats and leaves
// it at 0.5; widening to an integer has to round, and this rounds half
// away from zero.
assert_eq!(
alpha_to_8(0x40),
128,
"the midpoint rounds half away from zero"
);
assert!(alpha_to_8(0x01) > 0, "the low end must not vanish");
assert_eq!(
alpha_to_8(0xFF),
255,
"values above the range clamp to opaque"
);
}
#[test]
fn mip_level_is_zero_at_native_size() {
assert_eq!(mip_level(64.0, 64.0, 8), 0);
assert_eq!(mip_level(128.0, 128.0, 8), 0);
}
#[test]
fn mip_level_steps_by_one_per_halving() {
// quarter size -> two levels down
assert_eq!(mip_level(16.0, 64.0, 8), 2);
assert_eq!(mip_level(8.0, 64.0, 8), 3);
assert_eq!(mip_level(32.0, 64.0, 8), 1);
}
#[test]
fn mip_level_clamps_to_the_chain() {
assert_eq!(mip_level(1.0, 64.0, 8), 6);
assert_eq!(mip_level(0.25, 64.0, 8), 8, "clamped at the top level");
assert_eq!(mip_level(1024.0, 64.0, 8), 0, "clamped at the bottom");
}
#[test]
fn degenerate_ratios_pick_level_zero_rather_than_panicking() {
for (a, b) in [
(0.0, 64.0),
(64.0, 0.0),
(f32::NAN, 64.0),
(f32::INFINITY, 64.0),
] {
assert_eq!(mip_level(a, b, 8), 0, "({a}, {b}) should fall back to 0");
}
}
#[test]
fn alpha_test_discards_on_the_documented_side() {
// AFAIL = 0 discards BELOW the reference
assert!(passes_alpha_test(0x40, 0x40, false), "equal passes");
assert!(!passes_alpha_test(0x20, 0x40, false), "below fails");
assert!(passes_alpha_test(0x60, 0x40, false), "above passes");
// AFAIL = 1 discards ABOVE it
assert!(passes_alpha_test(0x40, 0x40, true));
assert!(
passes_alpha_test(0x20, 0x40, true),
"below passes when inverted"
);
assert!(
!passes_alpha_test(0x60, 0x40, true),
"above fails when inverted"
);
}
#[test]
fn the_opaque_bit_does_not_satisfy_the_alpha_test() {
// 0x80 is opaque, but it must not compare as 128 against a 0x40
// reference, or everything would pass.
assert!(
!passes_alpha_test(ALPHA_ONE, 0x40, true),
"the opaque bit leaked into the comparison"
);
}
#[test]
fn blend_modes_match_their_names() {
assert_eq!(BlendMode::from_primitive_type(0), BlendMode::Off);
assert_eq!(BlendMode::from_primitive_type(1), BlendMode::Source);
assert_eq!(BlendMode::from_primitive_type(2), BlendMode::Destination);
assert_eq!(
BlendMode::from_primitive_type(3),
BlendMode::SourcePlusDestination
);
assert_eq!(
BlendMode::from_primitive_type(200),
BlendMode::Off,
"out-of-range is Off"
);
assert_eq!(BlendMode::Off.blend(10, 250), 10);
assert_eq!(BlendMode::Destination.blend(10, 250), 250);
assert_eq!(
BlendMode::SourcePlusDestination.blend(200, 200),
255,
"clamps at the top"
);
}
#[test]
fn tex0_needs_powers_of_two() {
let t = tex0_of(0, 128, 128).expect("128 is a power of two");
assert_eq!(t.tw, 7, "log2(128)");
assert_eq!(t.th, 7);
assert_eq!(t.tbw, 2, "128 texels is two 64-texel blocks");
assert_eq!(t.psm, PSM_PSMT8);
assert_eq!(t.tbp0, 0);
assert!(tex0_of(0, 96, 128).is_none(), "96 is not a power of two");
}
#[test]
fn tex0_base_pointer_is_256_byte_blocks() {
let t = tex0_of(512, 64, 64).expect("aligned");
assert_eq!(t.tbp0, 2, "512 bytes is two blocks");
assert_eq!(t.tbw, 1);
assert!(tex0_of(255, 64, 64).is_none(), "255 is not a block");
}
#[test]
fn texture_slots_carry_one_mip_level() {
// Measured strides: 32 -> 1280, 64 -> 5120, 128 -> 20480, 256 -> 81920.
assert_eq!(texture_slot_bytes(32, 32), 1280);
assert_eq!(texture_slot_bytes(64, 64), 5120);
assert_eq!(texture_slot_bytes(128, 128), 20480);
assert_eq!(texture_slot_bytes(256, 256), 81920);
}
#[test]
fn the_clut_base_is_the_texture_module_s_address() {
assert_eq!(clut_base(8), 2048);
assert_eq!(clut_base(0), 0);
}
#[test]
fn clamp_keeps_the_far_edge() {
assert_eq!(clamp01(1.0), 1.0, "1.0 is the edge, not a wrap to 0");
assert_eq!(clamp01(1.5), 1.0);
assert_eq!(clamp01(-0.5), 0.0);
assert_eq!(clamp01(f32::NAN), 0.0);
}
}
+275
View File
@@ -0,0 +1,275 @@
//! The guard-band clip test, from the algorithm documented for R&C1 in
//! `docs/plan/tfrag_rac1.md` §3b.2.
//!
//! Attribution matters here: the algorithm is taken from the reference and
//! **verified against the game only in part**. The geometry it needs is parsed
//! and tested (`TfragGeometry::bsphere`, `clip_corners`, `draw_mode`); the camera
//! and frustum are per-frame game state that no savestate on hand provides
//! consecutively, so steps 1-4 are implemented from the reference and **have not
//! been compared against a real frame**.
//!
//! It is written as a pure function over explicit camera and frustum values so
//! that the moment those are located the test can be run for real, and so the
//! parts that can be checked now are checked now.
/// A point in the 12.4 fixed-point units the tfrag data uses.
pub type P3 = [i32; 3];
/// Near and far clip distances in the same units.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Frustum {
/// Near plane distance, `n` in the reference.
pub near: i32,
/// Far plane distance, `f` in the reference.
pub far: i32,
/// Horizontal and vertical tangent of the side planes. `(tan_x, tan_y)`.
pub tan: [i32; 2],
}
/// What the test decided to do with a patch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrawMode {
/// Frustum, no clipping program, normal LOD and morphing.
Normal,
/// Guard band hit: send to the clipping program at LOD 0 with no morphing.
ClipAtLod0,
/// Frustum hit: do not draw at all.
Cull,
}
/// Run the documented algorithm for one patch.
///
/// `radius` is `bsphere[3]`, `centre` is `bsphere[0..3]`, and `corners` is
/// `clip_corners`. `R` is the camera rotation: row-major, applied to a camera
/// space offset. The reference writes step 1 as `R * (bsphere - camera * 1024)`
/// -- the 1024 is the fixed-point scale, so `camera` is in world units and the
/// subtraction happens in fixed point.
///
/// Step 5's second volume is the reference's guard band: the same frustum with
/// its x and y **halved**, which in `tan` terms is a 4x guard band, and it is
/// the band that decides whether the clipping program is needed at all.
#[must_use]
pub fn guard_band(
centre: P3,
radius: i32,
corners: &[P3],
camera: P3,
r: [[i32; 3]; 3],
f: Frustum,
) -> DrawMode {
// 1. to camera space, fixed point.
let dx = centre[0] - camera[0];
let dy = centre[1] - camera[1];
let dz = centre[2] - camera[2];
let d = [
r[0][0] * dx + r[0][1] * dy + r[0][2] * dz,
r[1][0] * dx + r[1][1] * dy + r[1][2] * dz,
r[2][0] * dx + r[2][1] * dy + r[2][2] * dz,
];
// 2. near/far for the bounding sphere, truncated toward zero.
let far = d[2] + radius;
let near = d[2] - radius;
// 3. depth cull.
if near > 512_000 || far <= f.near {
return DrawMode::Cull;
}
// 3. side planes: tan*z - |x| -+ r*sqrt(1 + tan^2), sign per axis.
// `isqrt` is the reference's integer square root; both margins non-negative
// is step 4's "wholly inside".
let mut inside = [true; 2];
for (i, m) in [d[0].abs(), d[1].abs()].iter().enumerate() {
let t = f.tan[i];
let s = isqrt(1 + (t as i64) * (t as i64)) * radius;
inside[i] = t * d[2] + s >= *m;
}
// 4. wholly inside: both margins, and strictly within near/far.
if inside[0] && inside[1] && far < f.far && near >= f.near {
return DrawMode::Normal;
}
// 5/6. re-test the 8 corners: against the frustum, then the guard band.
if corners.is_empty() {
return DrawMode::Normal;
}
// The reference culls when *one* plane has every corner outside it. That is
// not "every corner is outside some plane" -- the first version conflated the
// two and culled patches that only straddle different planes.
//
// Per corner, a 4-bit mask of which planes it is outside of.
let masks: Vec<u8> = corners
.iter()
.map(|c| {
let mut m = 0u8;
if c[2] - radius < f.near {
m |= 1;
}
if c[2] + radius > f.far {
m |= 2;
}
for (i, x) in [c[0].abs(), c[1].abs()].iter().enumerate() {
let t = if i == 0 { f.tan[0] } else { f.tan[1] };
if t * c[2] + isqrt(1 + (t as i64) * (t as i64)) * radius < *x {
m |= 1 << (2 + i);
}
}
m
})
.collect();
// All corners outside one *and the same* plane.
let all_outside_one_plane = [1u8, 2, 4, 8].iter().any(|p| {
let p = *p;
!masks.is_empty() && masks.iter().all(|m| m & p != 0)
});
// And the same against the guard band: any corner outside it needs the
// clipping program.
let band = Frustum {
near: f.near,
far: f.far,
tan: [f.tan[0] / 4, f.tan[1] / 4],
};
let any_outside_band = corners.iter().any(|c| {
if c[2] - radius < band.near || c[2] + radius > band.far {
return true;
}
[c[0].abs(), c[1].abs()].iter().enumerate().any(|(i, x)| {
let t = band.tan[i];
t * c[2] + isqrt(1 + (t as i64) * (t as i64)) * radius < *x
})
});
if all_outside_one_plane {
return DrawMode::Cull;
}
if any_outside_band {
return DrawMode::ClipAtLod0;
}
DrawMode::Normal
}
/// Integer square root, floor. The reference truncates rather than rounds.
fn isqrt(v: i64) -> i32 {
if v <= 0 {
return 0;
}
let mut n = (v as f64).sqrt() as i64;
while n * n > v {
n -= 1;
}
while (n + 1) * (n + 1) <= v {
n += 1;
}
n as i32
}
#[cfg(test)]
mod tests {
use super::*;
const R_ID: [[i32; 3]; 3] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
fn frustum() -> Frustum {
Frustum {
near: 1000,
far: 200_000,
tan: [400, 400],
}
}
#[test]
fn a_patch_directly_ahead_is_drawn_normally() {
let m = guard_band(
[0, 0, 50_000],
1000,
&[
[-500, -500, 50_000],
[500, -500, 50_000],
[500, 500, 50_000],
[-500, 500, 50_000],
],
[0, 0, 0],
R_ID,
frustum(),
);
assert_eq!(m, DrawMode::Normal);
}
#[test]
fn a_patch_behind_the_camera_is_culled() {
let m = guard_band(
[0, 0, -50_000],
1000,
&[[0, 0, -50_000]],
[0, 0, 0],
R_ID,
frustum(),
);
assert_eq!(m, DrawMode::Cull);
}
#[test]
fn a_patch_beyond_the_far_plane_is_culled() {
let m = guard_band(
[0, 0, 900_000],
1000,
&[[0, 0, 900_000]],
[0, 0, 0],
R_ID,
frustum(),
);
assert_eq!(m, DrawMode::Cull);
}
#[test]
fn a_patch_on_the_side_uses_the_guard_band_not_an_immediate_cull() {
// Far off to the side: outside the side planes, but its corners are not
// all outside one plane, so it goes to the clipping program, not Cull.
// tan is 1024 (1.0 in 10.10), so at z = 1,000 the side plane sits at
// x = 1,024,000. This patch is at x = 5,000,000: well outside the side
// plane, but straddling near/far differently across its corners, so it
// must reach the guard band rather than cull outright.
let c = [
[5_000_000, 0, 1_000],
[5_000_400, 0, 1_000],
[5_000_000, 0, 1_400],
[5_000_000, 400, 1_000],
];
let m = guard_band([5_000_200, 0, 1_100], 100, &c, [0, 0, 0], R_ID, frustum());
assert_ne!(m, DrawMode::Normal, "a side patch must not draw normally");
}
#[test]
fn corners_all_outside_one_plane_cull() {
// Every corner beyond the far plane -> cull outright.
let c = [
[0, 0, 300_000],
[100, 0, 300_000],
[0, 100, 300_000],
[100, 100, 300_000],
];
let m = guard_band([50, 50, 300_000], 10, &c, [0, 0, 0], R_ID, frustum());
assert_eq!(m, DrawMode::Cull);
}
#[test]
fn isqrt_floors() {
assert_eq!(isqrt(0), 0);
assert_eq!(isqrt(1), 1);
assert_eq!(isqrt(15), 3, "floor(3.87)");
assert_eq!(isqrt(16), 4);
assert_eq!(isqrt(17), 4);
}
#[test]
fn the_rotation_moves_the_patch_into_camera_space() {
// 90 degrees about z: world +x should end up on the camera's +y.
let r = [[0, -1, 0], [1, 0, 0], [0, 0, 1]];
let m = guard_band([50_000, 0, 50_000], 1000, &[], [0, 0, 0], r, frustum());
// Directly ahead after rotation, so Normal rather than Cull.
assert_eq!(m, DrawMode::Normal);
}
}
+5
View File
@@ -2,14 +2,19 @@ pub mod buf;
pub mod chunks; pub mod chunks;
pub mod collision; pub mod collision;
pub mod global_wad; pub mod global_wad;
pub mod gs;
pub mod guard_band;
pub mod iso9660; pub mod iso9660;
pub mod level_core; pub mod level_core;
pub mod level_data; pub mod level_data;
pub mod level_wad; pub mod level_wad;
pub mod moby; pub mod moby;
pub mod occlusion; pub mod occlusion;
pub mod overlay;
pub mod overlay_reloc;
pub mod regions; pub mod regions;
pub mod shrub; pub mod shrub;
pub mod sky;
pub mod sound_bank; pub mod sound_bank;
pub mod texture; pub mod texture;
pub mod tfrag; pub mod tfrag;
+304
View File
@@ -236,3 +236,307 @@ mod tests {
assert!(g.is_empty()); assert!(g.is_empty());
} }
} }
/// The runtime moby instance: 256 bytes, every field named.
///
/// **Transcribed from `OpenRAC/rac2-gc-decomp`'s `include/structs.h`, not
/// derived here.** That project's disc SHA-256 matches ours exactly (both
/// `9db2e33e...b1ce5`, USA v1.01), and it states this layout as authentic and
/// 0x100 bytes.
///
/// Two things about its status, stated rather than glossed:
///
/// * This is a **layout claim from a third party**, and it is not verified
/// against disc data here. It cannot be: moby instances are runtime objects
/// built by the game, and nothing of that shape is in the container. The test
/// alongside asserts the offset table is internally well-formed -- ascending,
/// non-overlapping, summing to 0x100 -- which is what can be checked without
/// the bytes.
/// * The same project's **function addresses** proved unusable for collision,
/// landing in NOBITS memory. That was a different kind of claim (an address in
/// a program's layout, rather than a struct's field order) and it does not
/// impeach this one, but the distinction is worth keeping.
///
/// The field that matters most for the open `moby_anim` item is at `0x40`: the
/// animation block. `animSeq`, `animLayers`, `jointCnt` and `jointCache` together
/// say that a moby carries a *runtime* animation state -- a sequence pointer, a
/// layer list and a joint matrix cache. The asset those point at is still what
/// needs locating; the instance side is now named rather than inferred.
///
/// `oClass` at `0xBC` is the other one: it is the field the runtime dispatch uses,
/// and it is what links an instance to the `lvl.vtbl` handler tables.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MobyInstance {
/// `0x00`, 16 bytes: bounding sphere in world space.
pub b_sphere: [u8; 0x10],
/// `0x10`, 16 bytes: world position x, y, z, w.
pub pos: [u8; 0x10],
/// `0x20`: current moby state (`0xFE` free, `0xFF` tail).
pub state: u8,
/// `0x21`: collision / grouping index.
pub group: u8,
/// `0x22`: class sub-type.
pub m_class: u8,
/// `0x23`: opacity / blend alpha, default `0x80`.
pub alpha: u8,
/// `0x24`: pointer to the class definition.
pub p_class: u32,
/// `0x28`: next moby in the chain or update list.
pub p_chain: u32,
/// `0x2C`: collision damage value.
pub coll_damage: u8,
/// `0x2D`: death counter.
pub death_cnt: u8,
/// `0x2E`: occlusion index.
pub occl_index: u16,
/// `0x30`: update distance.
pub update_dist: u8,
/// `0x31`: drawn flag.
pub drawn: u8,
/// `0x32`: draw distance threshold.
pub draw_dist: u16,
/// `0x34`: mode flags (`0x40` = no pre-update).
pub mode_bits: u16,
/// `0x36`: secondary mode flags.
pub mode_bits2: u16,
/// `0x38`: light bitmask or colour.
pub lights: u32,
/// `0x40`: animation sequence pointer.
pub anim_seq: u32,
/// `0x44`: animation time parameter.
pub anim_seq_t: f32,
/// `0x48`: animation playback speed.
pub anim_speed: f32,
/// `0x4C`: animation interpolation scale.
pub anim_i_scale: i16,
/// `0x4E`: pose cache index.
pub pose_cache_entry_index: i16,
/// `0x50`: animation layer list.
pub anim_layers: u32,
/// `0x54`: active sequence id.
pub anim_seq_id: u8,
/// `0x55`: animation flags.
pub anim_flags: u8,
/// `0x56`: loop / layer sequence.
pub l_seq: u8,
/// `0x57`: joint count. Non-zero means the moby is skinned.
pub joint_cnt: u8,
/// `0x58`: joint matrix cache.
pub joint_cache: u32,
/// `0x5C`: IK / joint manipulator.
pub p_manipulator: u32,
/// `0x60`: glow colour, RGBA.
pub glow_rgba: u32,
/// `0x64`: LOD transition.
pub lod_trans: u8,
/// `0x65`: secondary LOD transition.
pub lod_trans2: u8,
/// `0x66`: metal surface flag.
pub metal: u8,
/// `0x67`: sub-state.
pub sub_state: u8,
/// `0x68`: previous state.
pub prev_state: u8,
/// `0x69`: state type.
pub state_type: u8,
/// `0x6A`: frames in the current state.
pub state_timer: u16,
/// `0x6C`: sound trigger.
pub sound_trigger: u8,
/// `0x6D`: sound desired.
pub sound_desired: u8,
/// `0x6E`: audio channel index.
pub sound_channel: u16,
/// `0x70`: actor scale.
pub scale: f32,
/// `0x74`: bangles / attachment bits.
pub bangles: u16,
/// `0x76`: shadow flag.
pub shadow: u8,
/// `0x77`: shadow texture index.
pub shadow_index: u8,
/// `0x78`: shadow ground plane Y.
pub shadow_plane: f32,
/// `0x7C`: shadow max distance.
pub shadow_range: f32,
/// `0x80`, 16 bytes: local bounding sphere.
pub l_sphere: [u8; 0x10],
/// `0x90`: network / multiplayer object.
pub net_object: u32,
/// `0x94`: update tick id.
pub update_id: u16,
/// `0x96`: scratchpad scratch variable.
pub spad0: u16,
/// `0x98`: collision mesh or pill data.
pub coll_data: u32,
/// `0x9C`: active collision bitmask.
pub coll_active: u32,
/// `0xA0`: collision check count.
pub coll_cnt: i32,
/// `0xA4`: PVS grid bounds, minimum X.
pub grid_min_x: u8,
/// `0xA5`: PVS grid bounds, minimum Y.
pub grid_min_y: u8,
/// `0xA6`: PVS grid bounds, maximum X.
pub grid_max_x: u8,
/// `0xA7`: PVS grid bounds, maximum Y.
pub grid_max_y: u8,
/// `0xA8`: per-tick update function.
pub p_update: u32,
/// `0xAC`: private moby state, `0x80` bytes per moby.
pub p_var: u32,
/// `0xB0`: mission id.
pub mission: u8,
/// `0xB1`: padding.
pub pad: u8,
/// `0xB2`: unique instance id within the level.
pub uid: i16,
/// `0xB4`: bolt drop reward.
pub bolts: i16,
/// `0xB6`: experience / nanotech reward.
pub xp: u16,
/// `0xB8`: parent moby pointer.
pub p_parent: u32,
/// `0xBC`: object class id -- the dispatch key into `lvl.vtbl`.
pub o_class: i16,
/// `0xBE`: trigger flags.
pub triggers: u8,
/// `0xBF`: death handler invoked flag.
pub standarddeathcalled: u8,
/// `0xC0`, 48 bytes: 3x4 orientation matrix.
pub r_mtx: [u8; 0x30],
/// `0xF0`, 16 bytes: Euler rotation, pitch / yaw / roll.
pub rot: [u8; 0x10],
}
/// Size of [`MobyInstance`], as the reference states it.
pub const MOBY_INSTANCE_SIZE: usize = 0x100;
/// The named offsets of [`MobyInstance`], in ascending order.
///
/// Used by the layout test, and useful on its own: it is the field map without
/// the payload types.
pub const MOBY_INSTANCE_FIELDS: &[(usize, &str)] = &[
(0x00, "b_sphere"),
(0x10, "pos"),
(0x20, "state"),
(0x21, "group"),
(0x22, "m_class"),
(0x23, "alpha"),
(0x24, "p_class"),
(0x28, "p_chain"),
(0x2C, "coll_damage"),
(0x2D, "death_cnt"),
(0x2E, "occl_index"),
(0x30, "update_dist"),
(0x31, "drawn"),
(0x32, "draw_dist"),
(0x34, "mode_bits"),
(0x36, "mode_bits2"),
(0x38, "lights"),
(0x40, "anim_seq"),
(0x44, "anim_seq_t"),
(0x48, "anim_speed"),
(0x4C, "anim_i_scale"),
(0x4E, "pose_cache_entry_index"),
(0x50, "anim_layers"),
(0x54, "anim_seq_id"),
(0x55, "anim_flags"),
(0x56, "l_seq"),
(0x57, "joint_cnt"),
(0x58, "joint_cache"),
(0x5C, "p_manipulator"),
(0x60, "glow_rgba"),
(0x64, "lod_trans"),
(0x65, "lod_trans2"),
(0x66, "metal"),
(0x67, "sub_state"),
(0x68, "prev_state"),
(0x69, "state_type"),
(0x6A, "state_timer"),
(0x6C, "sound_trigger"),
(0x6D, "sound_desired"),
(0x6E, "sound_channel"),
(0x70, "scale"),
(0x74, "bangles"),
(0x76, "shadow"),
(0x77, "shadow_index"),
(0x78, "shadow_plane"),
(0x7C, "shadow_range"),
(0x80, "l_sphere"),
(0x90, "net_object"),
(0x94, "update_id"),
(0x96, "spad0"),
(0x98, "coll_data"),
(0x9C, "coll_active"),
(0xA0, "coll_cnt"),
(0xA4, "grid_min_x"),
(0xA5, "grid_min_y"),
(0xA6, "grid_max_x"),
(0xA7, "grid_max_y"),
(0xA8, "p_update"),
(0xAC, "p_var"),
(0xB0, "mission"),
(0xB1, "pad"),
(0xB2, "uid"),
(0xB4, "bolts"),
(0xB6, "xp"),
(0xB8, "p_parent"),
(0xBC, "o_class"),
(0xBE, "triggers"),
(0xBF, "standarddeathcalled"),
(0xC0, "r_mtx"),
(0xF0, "rot"),
];
#[cfg(test)]
mod instance_layout_tests {
use super::{MOBY_INSTANCE_FIELDS, MOBY_INSTANCE_SIZE};
#[test]
fn offsets_ascend_and_stay_inside_the_struct() {
let mut prev = 0usize;
for &(off, name) in MOBY_INSTANCE_FIELDS {
assert!(
off >= prev,
"{name} at {off:#x} overlaps the field before it"
);
assert!(
off < MOBY_INSTANCE_SIZE,
"{name} at {off:#x} is past the end"
);
prev = off;
}
}
#[test]
fn the_struct_ends_exactly_at_its_stated_size() {
let last = MOBY_INSTANCE_FIELDS.last().unwrap();
assert_eq!(
last.0 + 0x10,
MOBY_INSTANCE_SIZE,
"last field {:?} overruns",
last.1
);
}
#[test]
fn the_dispatch_key_is_where_the_dispatch_tables_say_it_is() {
let o = MOBY_INSTANCE_FIELDS
.iter()
.find(|(_, n)| *n == "o_class")
.unwrap()
.0;
// a signed 16-bit at 0xBC leaves room for `triggers` and the death flag,
// which is what the reference says follows it
assert_eq!(o, 0xBC);
assert_eq!(
o + 2,
MOBY_INSTANCE_FIELDS
.iter()
.find(|(_, n)| *n == "triggers")
.unwrap()
.0
);
}
}
+147
View File
@@ -0,0 +1,147 @@
//! The per-level overlay: a 16-byte header, a large constant block, then
//! level-specific data.
//!
//! Spec: `docs/formats/core-data-regions.md`.
//!
//! `overlay.bin` was the least-characterised file in the level set -- no parser,
//! no located structure. What follows is what the disc actually shows, and it is
//! narrower than the name suggests.
//!
//! # Header
//!
//! Four words at `+0x00`:
//!
//! | field | levels | notes |
//! |---|---|---|
//! | [`OverlayHeader::field_00`] | `1735808` on 27/27 | the only constant word |
//! | [`OverlayHeader::field_04`] | 37,540..45,308 | level-specific, always a multiple of 4 |
//! | [`OverlayHeader::field_08`] | `1` on 27/27 | |
//! | [`OverlayHeader::field_0c`] | 2,763,912..3,027,336 | level-specific |
//!
//! **The word at `+0x0c` is not a file size.** It was briefly recorded as one
//! because it sat close to the file length on the first levels measured, but
//! across all 27 it is smaller than the file on 5 levels and larger on 22, and
//! the difference runs to −147,616 bytes on level 2. It is reported as
//! [`OverlayHeader::field_0c`] and nothing depends on it.
//!
//! # The constant block
//!
//! Bytes `+0x10..+0x1220` -- **4,624 bytes** -- are byte-identical on all 27
//! levels. The common prefix is only 4 bytes, because the header's middle two
//! words vary; everything from `+0x10` up to `+0x1220` does not.
//!
//! That block contains a fixed configuration at `+0x30` (`f32` 7.9844, the word
//! 13,590,528, then `u16` 100 and 70, then 25), and the start of the game's
//! debug string pool -- `"crap\n"` at `+0x18`, `"system misc"` at `+0x20`, then
//! strings such as `host0:r:/rc2/shock/wavetable.bin`.
//!
//! # The string pool is not a table
//!
//! The pool was first written up as a table of records. It is not. Dumping the
//! 32 bytes before each printable run shows they are overwhelmingly the tail of
//! the preceding string, and the 32-byte prefixes are 327 distinct out of 332 --
//! i.e. essentially all different, because they are just the previous string's
//! bytes. There is no per-record header. Counted on 16-byte alignment, level 0
//! yields 332 runs, level 1 yields 362 and level 2 yields 351, but those counts
//! include continuations and overstate the number of strings.
//!
//! What the pool is *for* is settled by its contents: `Camera_CollPrimTest
//! WARNING! - grid out of bounds! (RAR)` is the game's own string, which is
//! independent support for reading the collision payload's two leading `u16`s as
//! grid dimensions.
use crate::buf::{Buf, Error, ErrorKind, Result};
/// Size of the header in bytes.
pub const OVERLAY_HEADER_SIZE: usize = 0x10;
/// Start of the block that is byte-identical on every level.
pub const OVERLAY_CONSTANT_BLOCK_START: usize = 0x10;
/// End of the block that is byte-identical on every level.
pub const OVERLAY_CONSTANT_BLOCK_END: usize = 0x1220;
/// The overlay's fixed 16-byte header.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OverlayHeader {
/// The word at `+0x00`. Reads `1735808` on all 27 levels.
///
/// It is the **load address of the payload**: the overlay is loaded with
/// its 16-byte header stripped, so overlay offset `X` sits at RAM
/// `X + field_00 - OVERLAY_HEADER_SIZE`. See
/// [`OverlayHeader::ram_address`] for how this was established -- the
/// overlay's own profiler table carries absolute addresses pointing back
/// at its strings, and four of them agree on the base on all 27 levels.
pub field_00: u32,
/// The word at `+0x04`. Level-specific; 37,540 on level 24 through 45,308
/// on level 2, and a multiple of 4 on every level.
pub field_04: u32,
/// The word at `+0x08`. Reads `1` on all 27 levels.
pub field_08: u32,
/// The word at `+0x0c`. Level-specific.
///
/// Reported, never relied on. It is **not** the file length: see the module
/// docs.
pub field_0c: u32,
}
impl OverlayHeader {
/// Where the overlay's payload, and therefore offset `X`, sits in RAM:
/// `X + field_00 - OVERLAY_HEADER_SIZE`.
pub fn ram_address(&self, offset: usize) -> u64 {
offset as u64 + self.field_00 as u64 - OVERLAY_HEADER_SIZE as u64
}
/// Parse the header, bounds-checked against `region`.
pub fn parse(region: &[u8]) -> Result<Self> {
let mut r = Buf::new(region);
Ok(Self {
field_00: r.u32("overlay field_00")?,
field_04: r.u32("overlay field_04")?,
field_08: r.u32("overlay field_08")?,
field_0c: r.u32("overlay field_0c")?,
})
}
}
/// The byte-identical block shared by every level's overlay.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OverlayConstantBlock<'a> {
/// Bytes `+0x10..+0x1220`. Identical on all 27 levels.
pub bytes: &'a [u8],
}
/// The level-specific data following the constant block.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OverlayLevelData<'a> {
/// Everything from `+0x1220` to the end of the file.
pub bytes: &'a [u8],
}
/// Split an overlay into its header, constant block and level-specific data.
pub fn parse_overlay(
region: &[u8],
) -> Result<(
OverlayHeader,
OverlayConstantBlock<'_>,
OverlayLevelData<'_>,
)> {
let header = OverlayHeader::parse(region)?;
if region.len() < OVERLAY_CONSTANT_BLOCK_END {
return Err(Error {
offset: OVERLAY_CONSTANT_BLOCK_END,
context: "overlay constant block",
kind: ErrorKind::OutOfBounds {
needed: OVERLAY_CONSTANT_BLOCK_END,
available: region.len(),
},
});
}
let constant = &region[OVERLAY_CONSTANT_BLOCK_START..OVERLAY_CONSTANT_BLOCK_END];
let level = &region[OVERLAY_CONSTANT_BLOCK_END..];
Ok((
header,
OverlayConstantBlock { bytes: constant },
OverlayLevelData { bytes: level },
))
}
+707
View File
@@ -0,0 +1,707 @@
//! Relocation and code-identity masking for PS2 level overlays.
//!
//! Ported from ReRAC (`vendor/rerac/crates/rc-formats/src/level_overlay.rs`)
//! and `font.rs`. Every level overlay carries the same engine code linked at
//! different addresses, so the same function differs only in relocated fields.
//! Masking those fields makes code-identity comparison work across all 27.
//!
//! # Layout
//!
//! Overlays (`extracted/levels/NN/overlay.bin`) open with a 16-byte header,
//! then a list of sections `{u32 dest, u32 size, u32 kind, u32 entry}` (the
//! list ends when the `entry` point stops matching the first). `kind == 8`
//! marks BSS. The `.text` section is the one that holds engine code.
//!
//! # Masking
//!
//! `mask` hides exactly the relocated fields: `j`/`jal` targets, `lui`
//! immediates of main-RAM addresses (0x0010..0x0040, so float and
//! small-integer `lui`s stay compared), and the 16-bit immediates of the
//! instructions that complete such a `lui` (`%lo`, tracked per register,
//! also through `move` or addressing `$gp`). Two functions are "the same
//! code" when their masked words are equal over the reference function's
//! extent.
//!
//! # Relocation
//!
//! `Relocation` maps a reference (level-01) function or data address to its
//! copy in the target overlay: `func` by masked-code lookup, `data` by
//! finding a reference function that forms the address with `lui`/`%lo` and
//! reading the same instruction pair in that function's copy.
use crate::buf::{Buf, Error, ErrorKind, Result};
use std::collections::{BTreeSet, HashMap};
/// Index of the `lvl.vtbl` section in the overlay's section list (0-based).
pub const VTBL_SECTION: usize = 3;
/// Index of the `lvl.camvtbl` section.
pub const CAMVTBL_SECTION: usize = 4;
/// Index of the `.text` section.
pub const TEXT_SECTION: usize = 6;
/// `$gp` in every level (the boot ELF sets it once; the small data is addressed
/// from it). Confirmed across all 27 overlays.
pub const GP: u32 = 0x1AEFF0;
/// Longest function extent compared (words). A longer reference extent is cut
/// here.
pub const MAX_EXTENT: usize = 0x8000 / 4;
/// One section of a level overlay (`{u32 dest, u32 size, u32 kind, u32 entry}`
/// then `size` bytes; ends when `entry` stops matching the first). Mirrors
/// `vendor/rerac/crates/rc-formats/src/font.rs::OverlaySection`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OverlaySection {
/// Load (RAM) address of this section.
pub dest: u32,
/// ELF `sh_type`: 1 = PROGBITS, 8 = NOBITS (BSS).
pub kind: u32,
/// Entry point of the section; the table ends when this stops matching the
/// first section's entry.
pub entry: u32,
/// The section's bytes (the 16-byte header included, since it is the first
/// `size` bytes of the section in the table).
pub data: Vec<u8>,
}
/// Splits the raw `overlay` lump into its sections.
/// RAM address at which the overlay's `.lit` section is loaded.
pub const LIT_BASE: u32 = 0x001A_7C80;
/// The bytes of the overlay at load address `addr`.
///
/// 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`.
///
/// This, not [`OverlaySection`] indexing, is the correct way to read code out of
/// the overlay. `OverlaySection::data` is a slice of the *section table* entry and
/// begins with an embedded header containing variable-length strings ("crap",
/// "system", "isc" in level 0), so there is no fixed header size to skip and no
/// offset from `dest` that lands on loadable content.
///
/// Bounds-checked. Sixteen probes indexed `secs[n].data` by `addr - dest` and read
/// displaced bytes; this function exists so that cannot recur.
#[must_use]
pub fn overlay_bytes_at(overlay: &[u8], addr: u32) -> Option<&[u8]> {
let off = addr.checked_sub(LIT_BASE)? as usize;
overlay.get(off..)
}
pub fn parse_overlay_sections(overlay: &[u8]) -> Result<Vec<OverlaySection>> {
let b = Buf::new(overlay);
let mut out: Vec<OverlaySection> = Vec::new();
let mut pos = 0usize;
if overlay.len() < 16 {
return Err(Error {
offset: 0,
context: "overlay section table",
kind: ErrorKind::OutOfBounds {
needed: 16,
available: overlay.len(),
},
});
}
loop {
if pos + 16 > overlay.len() {
break;
}
let dest = b.peek_u32(pos, "overlay section dest")?;
let size = b.peek_u32(pos + 4, "overlay section size")? as usize;
let kind = b.peek_u32(pos + 8, "overlay section kind")?;
let entry = b.peek_u32(pos + 12, "overlay section entry")?;
if out.first().is_some_and(|f| f.entry != entry) {
break;
}
let data = b
.peek_bytes(pos + 16, size, "overlay section payload")?
.to_vec();
out.push(OverlaySection {
dest,
kind,
entry,
data,
});
pos += 16 + size;
}
if out.is_empty() {
return Err(Error {
offset: 0,
context: "overlay section table",
kind: ErrorKind::Invalid("overlay has no sections"),
});
}
Ok(out)
}
/// `n` bytes at EE address `addr` of the loaded overlay.
pub fn read_overlay(sections: &[OverlaySection], addr: u32, n: usize) -> Option<&[u8]> {
sections.iter().filter(|s| s.kind != 8).find_map(|s| {
let off = addr.checked_sub(s.dest)? as usize;
s.data.get(off..off.checked_add(n)?)
})
}
/// Code-word convenience: four bytes at `addr` as a little-endian `u32`.
pub fn read_u32(sections: &[OverlaySection], addr: u32) -> Option<u32> {
read_overlay(sections, addr, 4).map(|b| u32::from_le_bytes(b.try_into().unwrap()))
}
/// A parsed level overlay.
#[derive(Debug, Clone)]
pub struct LevelOverlay {
pub sections: Vec<OverlaySection>,
/// The `.text` words (little-endian), at `text_start`.
pub text: Vec<u32>,
/// The physical load address of `.text`.
pub text_start: u32,
/// Set of function starts, computed from `.text` (jal targets, vtbl
/// updates, frame set-ups, lui/%lo references).
pub starts: BTreeSet<u32>,
}
impl LevelOverlay {
/// Build from already-parsed sections.
pub fn from_sections(sections: Vec<OverlaySection>) -> Result<Option<LevelOverlay>> {
let s = &sections[TEXT_SECTION];
if s.kind != 1 {
return Ok(None);
}
if s.data.len() % 4 != 0 {
return Err(Error {
offset: s.data.len(),
context: "overlay .text size not a multiple of 4",
kind: ErrorKind::OutOfBounds {
needed: s.data.len(),
available: s.data.len(),
},
});
}
let words: Vec<u32> = s
.data
.chunks(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
.collect();
let (text_start, text_words) = (s.dest, words);
let end = text_start + (text_words.len() as u32) * 4;
let mut ov = LevelOverlay {
sections,
text: text_words,
text_start,
starts: BTreeSet::new(),
};
let t = &ov.text;
// .text function starts: every `jal` target inside .text.
for (p, &w) in t.iter().enumerate() {
if op(w) == 3 {
let a = (w & 0x03ff_ffff) << 2;
let a = text_start + (p as u32 + 1) * 4 + a;
if a >= text_start && a < end && a % 4 == 0 {
ov.starts.insert(a);
}
}
}
// lvl.vtbl update targets inside .text.
if let Some(v) = ov.sections.get(3) {
// `chunks_exact` rather than `as_chunks`: the latter is 1.88 and the
// workspace declares 1.85. Same twelve-byte records.
for e in v.data.chunks_exact(12) {
let u = u32::from_le_bytes(e[4..8].try_into().unwrap());
if u >= text_start && u < end && u % 4 == 0 {
ov.starts.insert(u);
}
}
}
// A frame set-up right after a return (`jr ra` + delay slot, then
// `addiu sp, sp, -n`).
for p in 2..t.len() {
let w = t[p];
if t[p - 2] == 0x03e0_0008 && w >> 16 == 0x27bd && w & 0x8000 != 0 {
ov.starts.insert(text_start + 4 * p as u32);
}
}
// lui/%lo references (callbacks handed to other code).
for (_, a) in address_refs(t) {
if a >= text_start && a < end && a % 4 == 0 {
ov.starts.insert(a);
}
}
Ok(Some(ov))
}
/// `n` words at `addr` of `.text`.
pub fn code(&self, addr: u32, words: usize) -> Option<&[u32]> {
let i = addr.checked_sub(self.text_start)?;
if i % 4 != 0 {
return None;
}
let i = (i / 4) as usize;
self.text.get(i..i.checked_add(words)?)
}
/// The code extent of the function at `addr`, in words: up to the next
/// known function start, at most `MAX_EXTENT` words and the end of `.text`.
pub fn extent(&self, addr: u32) -> Option<usize> {
if addr < self.text_start || addr >= self.text_end() {
return None;
}
let next = self
.starts
.range(addr + 4..)
.next()
.copied()
.unwrap_or(self.text_end());
Some(
(next
.min(addr.saturating_add(MAX_EXTENT as u32))
.min(self.text_end())
- addr) as usize
/ 4,
)
}
fn text_end(&self) -> u32 {
self.text_start + self.text.len() as u32 * 4
}
}
/// Op / rs / rt / rd field extractors (module doc).
const fn op(w: u32) -> u32 {
w >> 26
}
const fn rs(w: u32) -> usize {
((w >> 21) & 31) as usize
}
const fn rt(w: u32) -> usize {
((w >> 16) & 31) as usize
}
const fn rd(w: u32) -> usize {
((w >> 11) & 31) as usize
}
fn sext(w: u32) -> u32 {
(w & 0xffff) as u16 as i16 as i32 as u32
}
/// Branches (not relocated: relative offsets).
fn is_branch(o: u32) -> bool {
matches!(o, 1 | 4..=7 | 0x14..=0x17)
}
/// I-type instructions that write GPR `rt`.
fn writes_rt(o: u32) -> bool {
matches!(o, 8..=15 | 0x18..=0x1b | 0x1e | 0x20..=0x27 | 0x37)
}
/// `lui` of a main-RAM address (the only ones a relink changes).
fn lui_is_address(w: u32) -> bool {
(0x0010..0x0040).contains(&(w & 0xffff))
}
/// Per-register "holds a `lui` of an address" state while walking code in
/// address order. Calls and branches do not reset it (a `lui` in a branch's
/// delay slot is used at the branch target, after code that may call): a
/// register keeps its state until an instruction writes it.
#[derive(Clone, Copy, Default)]
struct HiState([Option<u32>; 32]);
impl HiState {
/// Masks `w` and updates the state; returns (masked word, the full address
/// `w` forms from a `lui`, if any).
fn step(&mut self, w: u32) -> (u32, Option<u32>) {
self.step_inner(w)
}
fn step_inner(&mut self, w: u32) -> (u32, Option<u32>) {
let o = op(w);
match o {
// j / jal: the target.
2 | 3 => (w & 0xfc00_0000, None),
15 => {
let r = rt(w);
if r == 0 {
return (w, None);
}
if lui_is_address(w) {
self.0[r] = Some(w & 0xffff);
(w & 0xffff_0000, None)
} else {
self.0[r] = None;
(w, None)
}
}
0 => {
let f = w & 63;
let (s, t, d) = (rs(w), rt(w), rd(w));
// move (addu / daddu / or with $zero) keeps the state; any other
// write clears it.
let moved = if matches!(f, 0x21 | 0x2d | 0x25) {
if t == 0 {
self.0[s]
} else if s == 0 {
self.0[t]
} else {
None
}
} else {
None
};
if d != 0 {
self.0[d] = moved;
}
(w, None)
}
_ if is_branch(o) => (w, None),
0x10..=0x13 => {
// Coprocessor moves to a GPR (mfc / dmfc / qmfc / cfc: rs 0..2)
// write rt.
if rs(w) <= 2 && rt(w) != 0 {
self.0[rt(w)] = None;
}
(w, None)
}
0x1c => {
// MMI: writes rd.
if rd(w) != 0 {
self.0[rd(w)] = None;
}
(w, None)
}
_ => {
let s = rs(w);
// `ori` completes a `lui` with the zero-extended immediate, the
// others sign-extend it.
let lo = if o == 13 { w & 0xffff } else { sext(w) };
let full = if s == 28 {
Some(GP.wrapping_add(sext(w)))
} else {
self.0[s].map(|hi| (hi << 16).wrapping_add(lo))
};
let masked = if full.is_some() { w & 0xffff_0000 } else { w };
if writes_rt(o) && rt(w) != 0 {
self.0[rt(w)] = None;
}
(masked, full)
}
}
}
}
/// The masked words of `words` (module doc), walked from a function start.
pub fn mask(words: &[u32]) -> Vec<u32> {
walk(words).into_iter().map(|(m, _)| m).collect()
}
/// The (word offset, address) of every `lui`/`%lo` pair completed in `words`,
/// and of every `$gp`-relative access.
pub fn address_refs(words: &[u32]) -> Vec<(usize, u32)> {
walk(words)
.into_iter()
.enumerate()
.filter_map(|(i, (_, a))| a.map(|a| (i, a)))
.collect()
}
/// The masked first four words (the relocator's index key; equal to the first
/// four of `mask` of any longer run).
pub fn key(w: &[u32]) -> [u32; 4] {
let m = walk(&w[..4]);
[m[0].0, m[1].0, m[2].0, m[3].0]
}
/// Walks `words` from a function start in address order; at a forward branch
/// target the state also takes the registers the branch brought in (after its
/// delay slot), so a `lui` in a delay slot reaches its use.
fn walk(words: &[u32]) -> Vec<(u32, Option<u32>)> {
let mut st = HiState::default();
let mut pending: HashMap<usize, HiState> = HashMap::new();
let mut branch_to: Option<usize> = None;
let mut out = Vec::with_capacity(words.len());
for (i, &w) in words.iter().enumerate() {
if let Some(p) = pending.remove(&i) {
for r in 0..32 {
if st.0[r].is_none() {
st.0[r] = p.0[r];
}
}
}
out.push(st.step(w));
if let Some(t) = branch_to.take() {
// The delay slot has run: the branch's state reaches its target.
let e = pending.entry(t).or_default();
for r in 0..32 {
if e.0[r].is_none() {
e.0[r] = st.0[r];
}
}
}
let o = op(w);
if is_branch(o) || (o == 0x11 && rs(w) == 8) {
let t = i as i64 + 1 + (w & 0xffff) as u16 as i16 as i64;
if t > i as i64 + 1 && (t as usize) < words.len() {
branch_to = Some(t as usize);
}
}
}
out
}
/// Reference → target address translation (module doc).
pub struct Relocation<'a> {
reference: &'a LevelOverlay,
target: &'a LevelOverlay,
identity: bool,
/// Target `.text` positions by the masked key of their first 4 words.
index: HashMap<[u32; 4], Vec<u32>>,
/// Reference data address → the (function start, word offset) that form it
/// (built on the first [`Relocation::data`]).
xrefs: std::cell::OnceCell<HashMap<u32, Vec<(u32, usize)>>>,
}
impl<'a> Relocation<'a> {
/// Identity when the target is the reference itself (same bytes).
pub fn new(reference: &'a LevelOverlay, target: &'a LevelOverlay) -> Relocation<'a> {
let identity = reference.text_start == target.text_start
&& reference.text == target.text
&& reference.sections == target.sections;
let mut r = Relocation {
reference,
target,
identity,
index: HashMap::new(),
xrefs: std::cell::OnceCell::new(),
};
if identity {
return r;
}
let t = &target.text;
for p in 0..t.len().saturating_sub(3) {
r.index
.entry(key(&t[p..p + 4]))
.or_default()
.push(target.text_start + 4 * p as u32);
}
r
}
/// Whether the target is the reference overlay itself.
pub fn is_identity(&self) -> bool {
self.identity
}
/// The reference function at `ref_fn`, masked over its extent.
fn ref_masked(&self, ref_fn: u32) -> Option<Vec<u32>> {
let n = self.reference.extent(ref_fn)?;
Some(mask(self.reference.code(ref_fn, n)?))
}
/// Whether the target code at `target_fn` is the reference function
/// `ref_fn`.
pub fn same_code(&self, ref_fn: u32, target_fn: u32) -> bool {
if self.identity {
return ref_fn == target_fn;
}
let Some(m) = self.ref_masked(ref_fn) else {
return false;
};
self.target
.code(target_fn, m.len())
.is_some_and(|c| mask(c) == m)
}
/// Every copy of the reference function `ref_fn` in the target's `.text`.
pub fn copies(&self, ref_fn: u32) -> Vec<u32> {
if self.identity {
return self
.reference
.code(ref_fn, 1)
.map(|_| vec![ref_fn])
.unwrap_or_default();
}
let Some(m) = self.ref_masked(ref_fn).filter(|m| m.len() >= 4) else {
return Vec::new();
};
let Some(cands) = self.index.get(&[m[0], m[1], m[2], m[3]]) else {
return Vec::new();
};
cands
.iter()
.copied()
.filter(|&c| self.target.code(c, m.len()).is_some_and(|w| mask(w) == m))
.collect()
}
/// The target's copy of the reference function `ref_fn` (None: not found,
/// or found more than once).
pub fn func(&self, ref_fn: u32) -> Option<u32> {
if self.identity {
return Some(ref_fn);
}
match self.copies(ref_fn).as_slice() {
[a] => Some(*a),
_ => None,
}
}
/// The target's address for the reference data address `ref_addr` (module
/// doc). None when no reference function forms it with `lui`/`%lo`, or none
/// of those functions has a copy in the target.
pub fn data(&self, ref_addr: u32) -> Option<u32> {
if self.identity {
return Some(ref_addr);
}
let xrefs = self.xrefs.get_or_init(|| {
let mut x: HashMap<u32, Vec<(u32, usize)>> = HashMap::new();
for &f in &self.reference.starts {
let Some(code) = self
.reference
.extent(f)
.and_then(|n| self.reference.code(f, n))
else {
continue;
};
for (off, a) in address_refs(code) {
x.entry(a).or_default().push((f, off));
}
}
x
});
let sites = xrefs.get(&ref_addr)?;
for &(f, off) in sites {
let Some(g) = self.func(f) else {
continue;
};
let n = self.reference.extent(f)?;
let refs = address_refs(self.target.code(g, n)?);
if let Some(&(_, a)) = refs.iter().find(|(o, _)| *o == off) {
return Some(a);
}
}
// No copy of a whole referencing function (its extent runs into level
// code): the code around one of the references, from its function start
// up to 16 words past it, found once in the target.
for &(f, off) in sites {
let n = (off + 16).min(self.reference.extent(f)?);
let m = mask(self.reference.code(f, n)?);
if m.len() < 4 {
continue;
}
let Some(cands) = self.index.get(&[m[0], m[1], m[2], m[3]]) else {
continue;
};
let hits: Vec<u32> = cands
.iter()
.copied()
.filter(|&c| self.target.code(c, n).is_some_and(|w| mask(w) == m))
.collect();
if let [g] = hits.as_slice() {
if let Some(&(_, a)) = address_refs(self.target.code(*g, n)?)
.iter()
.find(|(o, _)| *o == off)
{
return Some(a);
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn masking_follows_lui_through_moves_and_keeps_constants() {
// lui v0,0x17 ; addiu s1,v0,0x42c0 ; move s2,v0 ; addiu s1,s2,0x42c0 ; lui at,0x3f80 ; lw t0,8(a0) ; lw t1,-0x10(gp)
let w = [
0x3c02_0017,
0x2451_42c0,
0x0040_902d,
0x2651_42c0,
0x3c01_3f80,
0x8c88_0008,
0x8f89_fff0,
];
let m = mask(&w);
assert_eq!(
m,
vec![
0x3c02_0000,
0x2451_0000,
0x0040_902d,
0x2651_0000,
0x3c01_3f80,
0x8c88_0008,
0x8f89_0000
]
);
assert_eq!(
address_refs(&w),
vec![(1, 0x1742c0), (3, 0x1742c0), (6, GP - 0x10)]
);
// lui a0,0x1b ; addiu a0,a0,-0x1000 → 0x1af000
assert_eq!(
address_refs(&[0x3c04_001b, 0x2484_f000]),
vec![(1, 0x1a_f000)]
);
}
/// A level-01 engine function, from the overlay's own `lvl.vtbl` update
/// table, must relocate to exactly one copy on every other level's overlay.
#[test]
fn mask_finds_one_copy_of_a_level_01_function_on_all_27_overlays() {
let data = std::env::var_os("GC_EXTRACTED")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default())
.join("Documents/gc-extracted")
});
if !data.exists() {
eprintln!("skipped: unpack the disc first");
return;
}
// Parse all 27 overlays and build their LevelOverlay.
let mut overlays = Vec::with_capacity(27);
for lv in 0..27usize {
let ov = std::fs::read(data.join(format!("level{lv}/overlay.bin")))
.unwrap_or_else(|_| panic!("level{lv}/overlay.bin"));
let secs =
parse_overlay_sections(&ov).unwrap_or_else(|_| panic!("level{lv} section parse"));
let lvl = LevelOverlay::from_sections(secs).expect("LevelOverlay::from_sections");
overlays.push(lvl.unwrap_or_else(|| panic!("level {lv}: no .text section")));
}
// Reference function: a level-01 engine function, found as the first
// function start in level 0's .text whose masked extent is at least 4
// words (short functions would index with fewer than 4 masked words).
let ref_lv = &overlays[0];
let mut ref_fn = 0;
let mut m = Vec::new();
for &fn_start in &ref_lv.starts {
let ext = ref_lv
.extent(fn_start)
.expect("extent for a function start");
let c = ref_lv
.code(fn_start, ext)
.expect("code for a function start");
let masked = mask(c);
if masked.len() >= 4 {
ref_fn = fn_start;
m = masked;
break;
}
}
assert!(
!m.is_empty(),
"no function with masked extent >= 4 in level 0 .text"
);
eprintln!("ref_fn 0x{ref_fn:x} masked to {} words", m.len());
// On every other overlay, its relocated copy must be found exactly once.
for (i, target) in overlays.iter().enumerate().skip(1) {
let copies = Relocation::new(ref_lv, target).copies(ref_fn);
assert_eq!(
copies.len(),
1,
"level {i}: ref_fn 0x{ref_fn:x} found {} copies (expected 1), first={:x?}",
copies.len(),
copies.first().copied()
);
}
eprintln!("mask: ref_fn 0x{ref_fn:x} found exactly once on all 26 other overlays");
}
}
+151 -22
View File
@@ -10,7 +10,7 @@
//! distributions -- were silently wrong, and nothing errored. //! distributions -- were silently wrong, and nothing errored.
//! //!
//! So bounds are computed once, here, from successor boundaries, and every //! So bounds are computed once, here, from successor boundaries, and every
//! caller uses these rather than inventing its own. Where a bound is not yet //! caller uses these rather than inventing their own. Where a bound is not yet
//! known it is `None`, not a guess: [`Region::end`] being `None` means //! known it is `None`, not a guess: [`Region::end`] being `None` means
//! "unresolved", and [`core_layout`] refuses to close a region whose successor //! "unresolved", and [`core_layout`] refuses to close a region whose successor
//! it cannot find. //! it cannot find.
@@ -24,14 +24,24 @@
//! | sky | sky | collision | //! | sky | sky | collision |
//! | collision | collision | first moby class | //! | collision | collision | first moby class |
//! | moby | first moby | first tie class | //! | moby | first moby | first tie class |
//! | tie | first tie | first shrub class | //! | tie | first tie | first shrub class, |
//! | shrub | first shrub | **unresolved** | //! | | | else the chunk container |
//! | shrub | first shrub | the chunk container |
//! | chunks | the chunk | lump length − 0x38A10 |
//! | | container | |
//! //!
//! The shrub end is the open item. Texture texels sit *before* moby, not after //! The chunk container's start is located by signature rather than read from
//! shrub, so they cannot bound it: on level 0 the texel range is 3539328 to //! the core index: the class table that precedes it carries no successor
//! 5326208 while the last shrub class starts at 16044288. The ~925 KB after //! offset, so the boundary is found in `core_data` itself -- see
//! the last shrub class is real, non-zero data that is not accounted for yet. //! [`crate::chunks::chunk_region_start`]. Every level has one: on the 24 levels
//! with shrub it sits exactly two bytes after the last shrub body, and on the
//! three levels without shrub it closes tie instead.
//!
//! Texture texels do **not** bound anything: they sit before moby, not after
//! shrub. On level 0 the texel range is 3539328 to 5326208 while the last
//! shrub class starts at 16044288.
use crate::chunks::chunk_region_start;
use crate::level_core::{ArrayRange, LevelCoreHeader}; use crate::level_core::{ArrayRange, LevelCoreHeader};
/// Bytes at the end of `core_data` that are identical across all 27 levels. /// Bytes at the end of `core_data` that are identical across all 27 levels.
@@ -85,12 +95,40 @@ pub fn first_geometry(index: &[u8], range: ArrayRange, stride: usize) -> Option<
best best
} }
/// The highest geometry offset in a class table, ignoring absent entries.
///
/// This is the *last* class in the region, which is where the search for the
/// successor region starts. Starting there rather than at the region's head
/// matters: the search is for the first chunk signature after this offset, so
/// beginning it inside the region's own class bodies would risk matching one.
pub fn last_geometry(index: &[u8], range: ArrayRange, stride: usize) -> Option<usize> {
let count = range.count.max(0) as usize;
let base = range.offset.max(0) as usize;
let mut best: Option<usize> = None;
for i in 0..count {
let at = base + i * stride;
let chunk = index.get(at..at + 4)?;
let off = i32::from_le_bytes(chunk.try_into().expect("4 bytes"));
if off > 0 {
best = Some(best.map_or(off as usize, |b: usize| b.max(off as usize)));
}
}
best
}
/// Compute the region's bounds for one level. /// Compute the region's bounds for one level.
pub fn core_layout(index: &[u8], h: &LevelCoreHeader, core_len: usize) -> Vec<Region> { ///
/// `core` is the level's `core_data` lump. It is needed for one thing only: the
/// class regions end at the chunk container, whose start is located by
/// signature in `core` because the class table that precedes it carries no
/// successor offset.
pub fn core_layout(index: &[u8], core: &[u8], h: &LevelCoreHeader) -> Vec<Region> {
let at = |v: i32| -> Option<usize> { (v > 0).then_some(v as usize) }; let at = |v: i32| -> Option<usize> { (v > 0).then_some(v as usize) };
let first_moby = first_geometry(index, h.moby_classes, 0x20); let first_moby = first_geometry(index, h.moby_classes, 0x20);
let first_tie = first_geometry(index, h.tie_classes, 0x20); let first_tie = first_geometry(index, h.tie_classes, 0x20);
let last_tie = last_geometry(index, h.tie_classes, 0x20);
let first_shrub = first_geometry(index, h.shrub_classes, 0x30); let first_shrub = first_geometry(index, h.shrub_classes, 0x30);
let last_shrub = last_geometry(index, h.shrub_classes, 0x30);
// Each region ends where the next one begins. `chained` keeps `None` // Each region ends where the next one begins. `chained` keeps `None`
// propagating rather than silently closing a region against a gap. // propagating rather than silently closing a region against a gap.
@@ -103,10 +141,20 @@ pub fn core_layout(index: &[u8], h: &LevelCoreHeader, core_len: usize) -> Vec<Re
}) })
} }
// The chunk container follows the last of the shrub and tie classes.
// Searching from the last *class* rather than from the region start keeps
// the signature search out of the class bodies themselves.
let container = match (last_shrub, last_tie) {
(Some(shrub), _) => chunk_region_start(core, shrub),
(None, Some(tie)) => chunk_region_start(core, tie),
(None, None) => None,
};
// The final region is bounded by the constant trailer rather than by // The final region is bounded by the constant trailer rather than by
// `core_data.len()`, which would let it absorb the trailer and whatever // `core_data.len()`, which would let it absorb the trailer and whatever
// tail follows the last named region. // tail follows the last named region.
let tail_end = core_len let tail_end = core
.len()
.checked_sub(CORE_DATA_TRAILER_BYTES) .checked_sub(CORE_DATA_TRAILER_BYTES)
.filter(|&e| e > 0); .filter(|&e| e > 0);
@@ -115,9 +163,12 @@ pub fn core_layout(index: &[u8], h: &LevelCoreHeader, core_len: usize) -> Vec<Re
chained("occlusion", at(h.occlusion), at(h.sky)), chained("occlusion", at(h.occlusion), at(h.sky)),
chained("sky", at(h.sky), at(h.collision)), chained("sky", at(h.sky), at(h.collision)),
chained("collision", at(h.collision), first_moby), chained("collision", at(h.collision), first_moby),
chained("moby", first_moby, first_tie), // A region with no successor region ends at the chunk container, which
chained("tie", first_tie, first_shrub), // is what follows the last class on every level.
chained("shrub", first_shrub, tail_end), chained("moby", first_moby, first_tie.or(container)),
chained("tie", first_tie, first_shrub.or(container)),
chained("shrub", first_shrub, container),
chained("chunks", container, tail_end),
]; ];
regions.into_iter().flatten().collect() regions.into_iter().flatten().collect()
@@ -148,7 +199,39 @@ mod tests {
fn layout(v: &[u8]) -> Vec<Region> { fn layout(v: &[u8]) -> Vec<Region> {
let h = crate::level_core::LevelCoreHeader::parse(v).unwrap(); let h = crate::level_core::LevelCoreHeader::parse(v).unwrap();
core_layout(v, &h, 1 << 24) core_layout(v, &core(v), &h)
}
/// A zeroed lump, optionally carrying a real chunk-container signature just
/// past the last class -- the layout locates the container in `core`, so a
/// core of plain zeros locates nothing.
fn core(v: &[u8]) -> Vec<u8> {
let h = crate::level_core::LevelCoreHeader::parse(v).unwrap();
let mut c = vec![0u8; 1 << 24];
let last_class = [
last_geometry(v, h.tie_classes, 0x20),
last_geometry(v, h.shrub_classes, 0x30),
]
.into_iter()
.flatten()
.max();
if let Some(last) = last_class {
plant_chunk(&mut c, last + 100);
}
c
}
/// The marker pair plus a chunk signature at `at`, as the disc carries it.
fn plant_chunk(core: &mut [u8], at: usize) {
core[at - 2..at].copy_from_slice(&crate::chunks::CHUNK_REGION_MARKER);
core[at + 14] = 4;
core[at + 15] = 0xff;
for k in 0..4 {
let word = (crate::chunks::CHUNK_TAG as u32) << 24;
let at =
at + crate::chunks::CHUNK_HEADER_BYTES + crate::chunks::CHUNK_GAP_BYTES + k * 4;
core[at..at + 4].copy_from_slice(&word.to_le_bytes());
}
} }
#[test] #[test]
@@ -170,7 +253,8 @@ mod tests {
"collision", "collision",
"moby", "moby",
"tie", "tie",
"shrub" "shrub",
"chunks"
] ]
); );
assert_eq!(regions[0].start, 0); assert_eq!(regions[0].start, 0);
@@ -203,7 +287,7 @@ mod tests {
} }
#[test] #[test]
fn the_last_region_ends_at_the_constant_trailer() { fn the_class_regions_end_at_the_chunk_container() {
let core_len = 1 << 24; let core_len = 1 << 24;
let v = index( let v = index(
100, 100,
@@ -212,29 +296,74 @@ mod tests {
&[(200, &[1000]), (400, &[2000]), (600, &[3000])], &[(200, &[1000]), (400, &[2000]), (600, &[3000])],
); );
let regions = layout(&v); let regions = layout(&v);
// The container sits 100 bytes past the last shrub class, which is
// where the search for it starts.
let container = 3000 + 100;
let shrub = regions.iter().find(|r| r.name == "shrub").unwrap(); let shrub = regions.iter().find(|r| r.name == "shrub").unwrap();
assert_eq!(shrub.start, 3000); assert_eq!(shrub.start, 3000);
assert_eq!(shrub.end, Some(container));
let chunks = regions.iter().find(|r| r.name == "chunks").unwrap();
assert_eq!(chunks.start, container);
// Not core_data.len(): the trailer is constant across levels and does // Not core_data.len(): the trailer is constant across levels and does
// not belong to any region. // not belong to any region.
assert_eq!(shrub.end, Some(core_len - CORE_DATA_TRAILER_BYTES)); assert_eq!(chunks.end, Some(core_len - CORE_DATA_TRAILER_BYTES));
assert!(shrub.end.unwrap() < core_len); assert!(chunks.end.unwrap() < core_len);
// Tie still ends at shrub, not at the container.
let tie = regions.iter().find(|r| r.name == "tie").unwrap(); let tie = regions.iter().find(|r| r.name == "tie").unwrap();
assert_eq!(tie.end, Some(3000)); assert_eq!(tie.end, Some(3000));
} }
#[test] #[test]
fn a_lump_shorter_than_the_trailer_leaves_the_end_unresolved() { fn tie_ends_at_the_container_when_there_is_no_shrub() {
let mut v = index( // Third class table absent: tie is the last class region, so it is tie
// the container closes.
let v = index(100, 200, 300, &[(200, &[1000]), (400, &[2000])]);
let h = crate::level_core::LevelCoreHeader::parse(&v).unwrap();
let core = core(&v);
let container = 2000 + 100;
let regions = core_layout(&v, &core, &h);
let tie = regions.iter().find(|r| r.name == "tie").unwrap();
assert_eq!(tie.end, Some(container));
assert!(regions.iter().all(|r| r.name != "shrub"));
let chunks = regions.iter().find(|r| r.name == "chunks").unwrap();
assert_eq!(chunks.start, container);
}
#[test]
fn an_unlocated_container_leaves_the_class_end_unresolved() {
// A core of plain zeros carries no signature, so shrub cannot be
// closed -- the end stays visible rather than clamped to the lump's
// tail, and the chunk region does not exist at all.
let v = index(
100, 100,
200, 200,
300, 300,
&[(200, &[1000]), (400, &[2000]), (600, &[3000])], &[(200, &[1000]), (400, &[2000]), (600, &[3000])],
); );
let h = crate::level_core::LevelCoreHeader::parse(&v).unwrap(); let h = crate::level_core::LevelCoreHeader::parse(&v).unwrap();
let regions = core_layout(&v, &h, CORE_DATA_TRAILER_BYTES - 1); let regions = core_layout(&v, &[0u8; 4 << 20], &h);
let shrub = regions.iter().find(|r| r.name == "shrub").unwrap(); let shrub = regions.iter().find(|r| r.name == "shrub").unwrap();
assert_eq!(shrub.end, None, "must not clamp a backwards bound"); assert_eq!(shrub.end, None);
let _ = &mut v; assert!(regions.iter().all(|r| r.name != "chunks"));
}
#[test]
fn a_lump_shorter_than_the_trailer_leaves_the_end_unresolved() {
let v = index(
100,
200,
300,
&[(200, &[1000]), (400, &[2000]), (600, &[3000])],
);
let h = crate::level_core::LevelCoreHeader::parse(&v).unwrap();
// Shorter than the trailer, so the container starts but cannot end.
let mut core = vec![0u8; CORE_DATA_TRAILER_BYTES - 1];
plant_chunk(&mut core, 3000 + 100);
let regions = core_layout(&v, &core, &h);
let shrub = regions.iter().find(|r| r.name == "shrub").unwrap();
assert_eq!(shrub.end, Some(3100));
let chunks = regions.iter().find(|r| r.name == "chunks").unwrap();
assert_eq!(chunks.end, None, "must not clamp a backwards bound");
} }
#[test] #[test]
+649 -106
View File
@@ -1,4 +1,4 @@
//! Shrub geometry: the class header. //! Shrub geometry: the class header, the normal palette, and the billboard.
//! //!
//! Spec: `docs/plan/level-assets.md`. //! Spec: `docs/plan/level-assets.md`.
//! //!
@@ -8,113 +8,439 @@
//! rule `vertex_offset + 16 * vertex_count == next_part`, which holds for //! rule `vertex_offset + 16 * vertex_count == next_part`, which holds for
//! 43,467 of 43,467 moby parts, holds for **zero** of them. //! 43,467 of 43,467 moby parts, holds for **zero** of them.
//! //!
//! What the shrub header *does* have is two fields that self-identify exactly, //! The class blob is header, palette, optional billboard, in that order. The
//! on all 358 spans: //! reference for the shape is R&C1's shrub class (documented in
//! `vendor/rerac/docs/formats/shrub_sky_rac1.md`, whose offsets this header
//! follows: `o_class` at 0x24, a count at 0x28, the data offset at 0x2c), but
//! every field below is either **measured on all 358 spans** or marked as not
//! established. Where a field is only parallel to R&C1 and not independently
//! verified here, the doc comment says so.
//!
//! What self-identifies exactly, on all 358 spans:
//! //!
//! - `+0x24` equals the owning class entry's own `o_class`. //! - `+0x24` equals the owning class entry's own `o_class`.
//! - `+0x2c` is a data offset that lands inside the span. //! - `+0x2c` is a data offset that lands inside the span.
//! - `+0x1c` (billboard offset) is non-zero on exactly the 25 classes whose
//! core-index entry carries billboard texture fields, and zero on the other
//! 333 -- two independent places, one answer.
//! //!
//! Those two are what this parses. The body past the data offset is not //! The class entry size differs here too: shrub entries are 0x30 bytes, not
//! decoded: the run of bytes there does not tile at any stride that divides the //! 0x20, because they carry billboard texture fields the other two tables do
//! tail consistently with the count at `+0x28`. //! not have.
//!
//! Note the class entry size differs here too: shrub entries are 0x30 bytes,
//! not 0x20, because they carry billboard texture fields the other two tables
//! do not have.
use crate::buf::{Buf, Error, ErrorKind, Result}; use crate::buf::{Buf, Error, ErrorKind, Result};
/// The measured size of a shrub class entry. /// The measured size of a shrub class entry.
pub const SHRUB_CLASS_ENTRY_BYTES: usize = 0x30; pub const SHRUB_CLASS_ENTRY_BYTES: usize = 0x30;
/// Normals in the palette. Not a maximum: the body's zero padding follows the
/// twenty-fourth entry, so a body of 26, 28 or 30 records holds 24 normals and
/// 2, 4 or 6 zero records.
pub const SHRUB_NORMAL_COUNT: usize = 24;
/// One palette entry: a unit normal as three `s16`, then a zero `s16`.
///
/// The three components are a fraction of 32767 -- entry `i` of every body
/// satisfies `x^2 + y^2 + z^2` within rounding of `32767^2` on all 358 spans.
/// The fourth word is zero on all 9,600 records on the disc, which is what
/// makes the record 8 bytes rather than 6.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShrubNormal {
pub x: i16,
pub y: i16,
pub z: i16,
}
impl ShrubNormal {
/// Parse one 8-byte palette record.
pub fn parse(bytes: &[u8]) -> Result<ShrubNormal> {
let mut b = Buf::new(bytes);
let x = b.i16("shrub normal x")?;
let y = b.i16("shrub normal y")?;
let z = b.i16("shrub normal z")?;
let pad = b.i16("shrub normal pad")?;
if pad != 0 {
return Err(Error {
offset: 6,
context: "shrub normal pad",
kind: ErrorKind::Invalid("shrub normal pad is not zero"),
});
}
Ok(ShrubNormal { x, y, z })
}
/// Whether the entry is a unit normal, within the format's quantisation.
///
/// The palette stores a unit vector scaled by 32768, so an axis-aligned
/// normal quantises to `(32768, 0, 0)` -- stored as the `s16` `-32768`,
/// because the positive axis clamps instead. The squared length therefore
/// lands within `32768^2 - 32767^2 = 65535` of `32767^2`, and that bound
/// is tight: the largest deviation on the disc is exactly 65,535.
///
/// The margin is what makes this a test rather than a tautology. A random
/// `s16` triple has a squared length spread about a *billion* either side
/// of `32767^2`, so roughly one entry in fifteen thousand would pass by
/// chance; 8,592 of 8,592 real entries pass.
pub fn is_unit(&self) -> bool {
let n = (self.x as i64).pow(2) + (self.y as i64).pow(2) + (self.z as i64).pow(2);
(n - 32767i64.pow(2)).abs() <= 65_535
}
/// The normal as a unit vector in `[0, 1]`-ish float terms.
pub fn unit(self) -> [f32; 3] {
[
self.x as f32 / 32767.0,
self.y as f32 / 32767.0,
self.z as f32 / 32767.0,
]
}
}
/// The decoded part of a shrub class header. /// The decoded part of a shrub class header.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShrubHeader { pub struct ShrubHeader {
/// Four floats at `+0x00..+0x10`. All finite on all 358 spans, but they do /// Four floats at `+0x00..+0x10`: a bounding sphere read as
/// not behave like a `min.xyz / max.xyz` bounding box -- that ordering only /// centre (x, y, z) and radius (w), following R&C1's header at the same
/// holds on 146 of 358. Not named because it is not established. /// offsets. What is measured here rather than assumed: all four are
pub bounds: [f32; 4], /// finite, the radius is positive on all 358 spans (1.72..41.57), and the
/// A positive float at `+0x10`, 3.0 to 20.0 across the disc. /// centres sit near the class's own origin (-18..15, -23..23, -16..20).
/// The earlier reading of this field as a `min.xyz / max.xyz` box was
/// wrong -- that ordering holds on only 146 of 358 spans -- because it was
/// the wrong shape, not because the bytes were.
pub sphere: [f32; 4],
/// A positive float at `+0x10`, 3.0 to 20.0 across the disc. R&C1's
/// parallel at this offset is `mip_distance`; not independently
/// established.
pub size: f32, pub size: f32,
/// A float at `+0x14`. Exactly 0.0 on 342 of the disc's 358 spans; the /// A `u16` at `+0x14`: zero on 342 spans, 4 on the other 16. R&C1's
/// other 16 hold the bit pattern `04000000`, a denormal of about 2.8e-45. /// `mode_bits` is at the same offset. Not established.
pub mode_bits: u16,
/// A `u16` at `+0x16`, zero on all 358 spans. R&C1's field here is
/// `instance_count`, runtime scratch that retail files leave zero.
/// ///
/// The Emotion Engine has no denormals, so hardware reads all 358 of these /// On the 16 spans where `mode_bits` is 4 the whole word at `+0x14` is an
/// as zero. That is the only reading the game itself ever sees, and it is /// `f32` denormal, so the Emotion Engine -- which has no denormals -- reads
/// why this is safe to treat as a flag rather than a magnitude -- see /// zero for the pair either way. That is the sharpest available statement
/// `AGENTS.md` on reproducing the FPU model rather than correcting it. /// of what the hardware does with this field.
pub reserved: f32, pub scratch: u16,
/// A `u32` at `+0x18`, zero on all 358 spans. R&C1's `instances_pointer`
/// is runtime scratch at the same offset.
pub reserved: u32,
/// Offset of the [`ShrubBillboard`] record from the start of the span.
/// Zero means no billboard; a positive value lands inside the span on all
/// 25 spans that have one.
///
/// This is the strongest self-identification in the header after
/// `o_class`: it is non-zero on exactly the classes whose core-index
/// entry carries billboard texture fields (25 of 358 each way), and the
/// two answers are produced by different bytes in different files.
pub billboard_offset: u32,
/// A positive `f32` at `+0x20` (0.0044..1.8 on the disc). R&C1's
/// `scale` is the position dequantisation factor at the same offset.
/// Not independently established.
pub scale: f32,
/// The class this geometry belongs to; equals the class entry's /// The class this geometry belongs to; equals the class entry's
/// `o_class` on all 358 spans on the disc. /// `o_class` on all 358 spans on the disc.
pub o_class: u32, pub o_class: u32,
/// A count. Meaning not established. /// A count. It is the **packet count** -- the length of the table at
/// `+0x40` -- which the packet tiling proves (see [`Shrub::packets`]).
/// Ranges 1..55 on the disc; unrelated to the palette's length.
pub count: u32, pub count: u32,
/// Offset of the geometry data from the start of the span. Lands inside /// Offset of the geometry data from the start of the span. Lands inside
/// the span on all 358 spans on the disc. /// the span on all 358 spans on the disc.
pub data_offset: u32, pub data_offset: u32,
} }
/// Bytes per body record. /// The billboard record at `billboard_offset`, 0x40 bytes.
/// ///
/// Each record is 6 varying bytes followed by 2 constant ones. Found by /// The register block matches R&C1's layout field for field -- TEX1 at `+0x10`
/// grouping sibling spans and byte-diffing them rather than by stride-fitting, /// with `K` and `MMIN`, TEX0 at `+0x20`, MIPTBP1_1 at `+0x30`, each carrying
/// which failed three times: the varying payload is **not** sized by the /// its own register address word (0x14, 0x06, 0x34) -- and the three floats
/// `count` field, and was a constant 144 bytes per group whether `count` was /// in front of them are the one part that does not carry over: they are far
/// 1, 2, 3, 5 or 6. /// too large and too varied to be R&C1's `width`/`height`/`z_ofs`, and they
pub const SHRUB_BODY_RECORD_BYTES: usize = 8; /// repeat byte-identically across levels, which dimensions would not. They
/// are handed back unnamed rather than guessed at.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShrubBillboard {
/// Fade distance at `+0x00` (5..1000 on the disc). Following R&C1, the
/// loader takes `trunc(fade) & 0xff`, which makes 512 read as 0 --
/// billboard only at any distance.
pub fade_distance: f32,
/// Three floats at `+0x04..+0x10`. Meaning not established.
pub unknown: [f32; 3],
/// `TEX1_1` at `+0x10`: the signed LOD bias K in 1/16 units, retail
/// -233..-168 on the disc.
pub k: i16,
/// `TEX1_1` minification filter, 4 on every disc billboard.
pub mmin: u32,
/// `TEX0_1.data_lo` at `+0x20`: a small index (1..4 on the disc). In R&C1
/// this is an index into the class's texture list.
pub tex0: u32,
/// The GIF register address words carried behind the three registers,
/// 0x14 / 0x06 / 0x34 on every disc billboard. Exposed rather than
/// asserted on: they corroborate the layout, but a billboard that carries
/// a different register set is still a billboard.
pub tex1_addr: u32,
/// See [`ShrubBillboard::tex1_addr`].
pub tex0_addr: u32,
/// See [`ShrubBillboard::tex1_addr`].
pub miptbp_addr: u32,
}
/// The two constant bytes closing each body record. /// A shrub span: its header, palette and optional billboard.
pub const SHRUB_RECORD_TAIL: [u8; 2] = [0, 0];
/// A shrub span: its header plus the bytes the header points at.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct Shrub<'a> { pub struct Shrub<'a> {
pub header: ShrubHeader, pub header: ShrubHeader,
/// The whole span, starting at `+0x24` with the class number. /// The whole span, starting at `+0x24` with the class number.
pub raw: &'a [u8], pub raw: &'a [u8],
/// The bytes at `data_offset`, running to `span_end`. Not yet decoded. /// The bytes at `data_offset`, running to `span_end`. This is the palette
/// plus its zero padding.
pub data: &'a [u8], pub data: &'a [u8],
/// Whether `span_end` came from the next class rather than from the end of /// Whether `span_end` came from the next class or the located chunk
/// the lump. False for the last class in each level, whose span contains /// container rather than from the end of the lump. False only for a span
/// whatever follows shrub. /// bounded by a caller that could not locate either.
pub span_end_is_a_successor: bool, pub span_end_is_a_successor: bool,
} }
impl<'a> Shrub<'a> { /// The 16-byte header at the front of a mesh packet.
/// The body's 8-byte records. ///
/// Measured on **every packet on the disc** -- 2,056 of them, across the 334
/// successor-bounded spans -- the header is almost entirely constant:
///
/// | bytes | value | packets |
/// |---|---|---|
/// | 0-3 | `04 04 00 01` | 2056 / 2056 |
/// | 4-7 | zero | 2056 / 2056 |
/// | 8 | zero | 2056 / 2056 |
/// | 11 | `0x05` | 2056 / 2056 |
///
/// and every packet's size is a multiple of 16 (2056 / 2056).
///
/// **What the loader does to it.** Read back out of the running game's memory,
/// the same records have byte 8 holding a **count** (0x0b to 0x29 observed),
/// byte 11 holding one of four tag values, and bytes 12-15 holding an absolute
/// EE RAM pointer where the disc has a span-relative offset. So bytes 8-15 are
/// rewritten wholesale on load, and the count this header does *not* carry on
/// disc is the one the `+0x40` table already supplies.
///
/// That is why the disc header is worth asserting and the RAM one is not: the
/// disc form is a fixed signature, the RAM form is populated state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShrubPacketHeader {
/// The constant leading magic, `04 04 00 01`.
pub magic: [u8; 4],
/// The tag byte at 11. `0x05` on every packet on the disc; rewritten on
/// load to one of `0x00`, `0x20`, `0x30`, `0xbc`.
pub tag: u8,
}
/// The magic every mesh packet opens with, on every span on the disc.
pub const SHRUB_PACKET_MAGIC: [u8; 4] = [0x04, 0x04, 0x00, 0x01];
/// The tag byte every mesh packet carries on the disc, before the loader
/// replaces it.
pub const SHRUB_PACKET_TAG: u8 = 0x05;
impl ShrubPacketHeader {
/// Parse a packet header, bounds-checked against `bytes`.
/// ///
/// The payload of each record is six bytes whose encoding is **not** /// Returns `None` unless the magic matches, bytes 4-8 are zero and the tag
/// established. It is not half-floats -- decoding as a `half3` gives /// is [`SHRUB_PACKET_TAG`] -- the three invariants that hold on 2,056 of
/// values like 245.25 and -168.375 -- and a VIF reading of it was /// 2,056 packets. Rejecting anything else is deliberate: a packet that does
/// retracted after a control put it on the random baseline. Handed back /// not match is not one this parser understands, and passing it back
/// raw rather than guessed at. /// half-understood is how a wrong reading gets treated as a finding.
pub fn parse(bytes: &[u8]) -> Option<ShrubPacketHeader> {
if bytes.len() < 16 || bytes[..4] != SHRUB_PACKET_MAGIC {
return None;
}
if bytes[4..9].iter().any(|&b| b != 0) {
return None;
}
if bytes[11] != SHRUB_PACKET_TAG {
return None;
}
Some(ShrubPacketHeader {
magic: SHRUB_PACKET_MAGIC,
tag: bytes[11],
})
}
}
/// One mesh packet: where it sits in the span and how long it is.
///
/// The offsets are relative to the start of the span, like every other
/// pointer in a class blob.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShrubPacket {
pub offset: usize,
pub size: usize,
}
impl<'a> Shrub<'a> {
/// The palette's 8-byte records, raw.
pub fn body_records(&self) -> impl Iterator<Item = &'a [u8]> { pub fn body_records(&self) -> impl Iterator<Item = &'a [u8]> {
self.data.chunks_exact(SHRUB_BODY_RECORD_BYTES) self.data.chunks_exact(8)
} }
/// How many whole records the body holds. /// How many whole records the body holds.
pub fn body_record_count(&self) -> usize { pub fn body_record_count(&self) -> usize {
self.data.len() / SHRUB_BODY_RECORD_BYTES self.data.len() / 8
} }
/// Whether every whole record ends in the constant pair. /// The mesh packets, as the packet table at `+0x40` names them.
/// ///
/// This is how a class's body is delimited: on all 24 levels that have /// The table is `count` entries of `(offset, size)`, both relative to the
/// shrub geometry, the last shrub class's body ends exactly where its /// span start. What makes it a table rather than a guess is that the
/// trailing `00 00` pair stops, always on a record boundary. /// entries **tile the blob**: the first packet starts at
/// `align16(0x40 + count * 8)` -- the table, rounded up to 16 -- every
/// entry is exactly `size` past the previous one, and the last ends at the
/// palette, or exactly 0x40 short of it on the spans that carry a
/// billboard record in between. That holds on **358 of 358** spans on the
/// disc, and it is what pins the field meanings: `count` is the packet
/// count, and `data_offset` is the palette's offset.
///
/// Returns `None` when the entries do not tile, rather than handing back
/// a partial list: a table that stops making sense means this is not
/// laid out the way this thinks it is.
pub fn packets(&self) -> Option<Vec<ShrubPacket>> {
let count = self.header.count as usize;
// A count that cannot be read out of the span is not a count.
if self.raw.len() < 0x40 + count.saturating_mul(8) {
return None;
}
let mut out = Vec::with_capacity(count);
let mut cursor = (0x40 + count.saturating_mul(8) + 0xf) & !0xf;
for i in 0..count {
let at = 0x40 + i * 8;
let entry = self.raw.get(at..at + 8)?;
let offset = u32::from_le_bytes(entry[..4].try_into().ok()?) as usize;
let size = u32::from_le_bytes(entry[4..].try_into().ok()?) as usize;
if offset != cursor {
return None;
}
cursor += size;
out.push(ShrubPacket { offset, size });
}
// The packets end where the palette begins, leaving room for the
// billboard record when the class has one.
let palette = self.header.data_offset as usize;
let expected_end = if self.header.billboard_offset > 0 {
palette.checked_sub(0x40)?
} else {
palette
};
(cursor == expected_end).then_some(out)
}
/// The mesh packets' headers, parsed.
///
/// Yields `None` for any packet whose header does not match the disc
/// invariants, so a caller can tell a bad packet from a good one rather
/// than receiving a default.
pub fn packet_headers(&self) -> impl Iterator<Item = Option<ShrubPacketHeader>> + 'a {
self.packets()
.unwrap_or_default()
.into_iter()
.map(move |p| {
self.raw
.get(p.offset..p.offset + 16)
.and_then(ShrubPacketHeader::parse)
})
.collect::<Vec<_>>()
.into_iter()
}
/// The palette: the first [`SHRUB_NORMAL_COUNT`] records, parsed.
///
/// Returns fewer only if the body is shorter than the palette, which does
/// not happen on the disc -- every one of the 358 spans carries all 24.
pub fn normals(&self) -> impl Iterator<Item = ShrubNormal> + 'a {
self.data
.chunks_exact(8)
.take(SHRUB_NORMAL_COUNT)
.filter_map(move |r| ShrubNormal::parse(r).ok())
}
/// Records past the palette. Zero on every disc span: the palette is
/// padded out with zero records rather than with live normals.
pub fn padding_record_count(&self) -> usize {
self.body_record_count().saturating_sub(SHRUB_NORMAL_COUNT)
}
/// Whether every whole record's trailing pair is zero.
///
/// The palette's pad word and the padding records are both zero, so a
/// body that satisfies this is whole records; it is how a class's body is
/// delimited, and it holds on all 358 disc spans now that each is bounded
/// by its successor.
pub fn body_is_well_formed(&self) -> bool { pub fn body_is_well_formed(&self) -> bool {
let records: Vec<&[u8]> = self.body_records().collect(); let records: Vec<&[u8]> = self.body_records().collect();
!records.is_empty() && records.iter().all(|r| r[6..8] == SHRUB_RECORD_TAIL) !records.is_empty() && records.iter().all(|r| r[6..8] == [0, 0])
}
/// The billboard record, when the header names one.
///
/// The offset is checked against `span_end` by
/// [`ShrubHeader::region`] rather than trusted: a non-zero offset that
/// runs past the span is a mis-set field, not a billboard.
pub fn billboard(&self) -> Result<Option<ShrubBillboard>> {
if self.header.billboard_offset == 0 {
return Ok(None);
}
let at = self.header.billboard_offset as usize;
// `raw` runs from the span start, so the record sits at raw[at..].
let bytes = self.raw.get(at..at + 0x40).ok_or(Error {
offset: at,
context: "shrub billboard",
kind: ErrorKind::OutOfBounds {
needed: 0x40,
available: self.raw.len().saturating_sub(at),
},
})?;
let mut b = Buf::new(bytes);
let fade_distance = b.f32("shrub billboard fade")?;
let unknown = [
b.f32("shrub billboard unknown 0")?,
b.f32("shrub billboard unknown 1")?,
b.f32("shrub billboard unknown 2")?,
];
// TEX1_1: K, a zero high half, then MMIN, then the register address
// word and a zero pad -- a 16-byte GifAdData16 per register.
let k = b.i16("shrub billboard K")?;
b.skip(2, "shrub billboard K high")?;
let mmin = b.u32("shrub billboard mmin")?;
let tex1_addr = b.u32("shrub billboard tex1 address")?;
b.skip(4, "shrub billboard tex1 pad")?;
let tex0 = b.u32("shrub billboard tex0 index")?;
b.skip(4, "shrub billboard tex0 data hi")?;
let tex0_addr = b.u32("shrub billboard tex0 address")?;
// MIPTBP1_1: two zero data words, then the register address word and a
// pad. The mip base pointers are filled in at level load, as they are
// for every ad-gif in this format.
b.skip(4, "shrub billboard tex0 pad")?;
b.skip(8, "shrub billboard miptbp data")?;
let miptbp_addr = b.u32("shrub billboard miptbp address")?;
Ok(Some(ShrubBillboard {
fade_distance,
unknown,
k,
mmin,
tex0,
tex1_addr,
tex0_addr,
miptbp_addr,
}))
} }
} }
impl ShrubHeader { impl ShrubHeader {
/// Parse a shrub span starting at `offset` within `core_data`. /// Parse a shrub span starting at `offset` within `core`.
/// ///
/// `o_class` is passed in so the self-identifying field can be checked /// `o_class` is passed in so the self-identifying field can be checked
/// rather than merely reported: a span whose `+0x24` disagrees with its /// rather than merely reported: a span whose `+0x24` disagrees with its
/// own class entry is a different thing entirely, and quietly returning /// own class entry is a different thing entirely, and quietly returning
/// the mismatched value would hide that. /// the mismatched value would hide that.
///
/// `span_end` bounds the class span within `core`. It matters: without it /// `span_end` bounds the class span within `core`. It matters: without it
/// `data` runs to the end of the lump and swallows whatever follows, which /// `data` runs to the end of the lump and swallows whatever follows, which
/// is how a per-class check ends up measuring its neighbours. /// is how a per-class check ends up measuring its neighbours.
@@ -137,41 +463,75 @@ impl ShrubHeader {
}, },
})?; })?;
let mut fb = Buf::new(&head[..16]); let mut fb = Buf::new(&head[..16]);
let bounds = [ let sphere = [
fb.f32("shrub bounds 0")?, fb.f32("shrub sphere x")?,
fb.f32("shrub bounds 1")?, fb.f32("shrub sphere y")?,
fb.f32("shrub bounds 2")?, fb.f32("shrub sphere z")?,
fb.f32("shrub bounds 3")?, fb.f32("shrub sphere radius")?,
]; ];
let mut fs = Buf::new(&head[0x10..0x18]); let mut fs = Buf::new(&head[0x10..0x18]);
let size = fs.f32("shrub size")?; let size = fs.f32("shrub size")?;
let reserved = fs.f32("shrub reserved")?; let mode_bits = fs.u16("shrub mode bits")?;
if !bounds.iter().all(|v| v.is_finite()) || !size.is_finite() || !reserved.is_finite() { let scratch = fs.u16("shrub scratch")?;
if !sphere.iter().all(|v| v.is_finite()) || !size.is_finite() {
return Err(Error { return Err(Error {
offset: start, offset: start,
context: "shrub header holds a non-finite float", context: "shrub header holds a non-finite float",
kind: ErrorKind::Invalid("shrub header float"), kind: ErrorKind::Invalid("shrub header float"),
}); });
} }
// Zero, or the denormal the PS2 would flush to zero anyway. Anything // A radius is a radius: non-positive means the header is not laid out
// else means the header is not laid out the way this thinks it is. // the way this thinks it is.
if reserved != 0.0 && !reserved.is_subnormal() { if sphere[3] <= 0.0 {
return Err(Error { return Err(Error {
offset: start + 0x14, offset: start + 0x0c,
context: "shrub reserved word is neither zero nor denormal", context: "shrub sphere radius",
kind: ErrorKind::Invalid("shrub reserved"), kind: ErrorKind::Invalid("shrub sphere radius is not positive"),
});
}
// One invariant across all 358 spans: the scratch word at +0x16 is
// zero everywhere. A non-zero value means this is not a shrub header.
// `mode_bits` at +0x14 is *not* asserted -- it is 4 on 16 of the 358
// spans -- so the field is reported rather than validated.
if scratch != 0 {
return Err(Error {
offset: start + 0x16,
context: "shrub scratch",
kind: ErrorKind::Invalid("shrub scratch is not zero"),
});
}
let reserved = u32::from_le_bytes(head[0x18..0x1c].try_into().expect("4 bytes at +0x18"));
if reserved != 0 {
return Err(Error {
offset: start + 0x18,
context: "shrub reserved word",
kind: ErrorKind::Invalid("shrub reserved word is not zero"),
}); });
} }
let mut b = Buf::new(&head[0x24..]); let mut b = Buf::new(&head[0x1c..]);
let span_class = b.u32("shrub o_class")?; let billboard_offset = b.u32("shrub billboard offset")?;
let scale = b.f32("shrub scale")?;
if !scale.is_finite() || scale <= 0.0 {
return Err(Error {
offset: start + 0x20,
context: "shrub scale",
kind: ErrorKind::Invalid("shrub scale is not positive"),
});
}
let mut bc = Buf::new(&head[0x24..]);
let span_class = bc.u32("shrub o_class")?;
let header = ShrubHeader { let header = ShrubHeader {
bounds: [0.0; 4], sphere: [0.0; 4],
size: 0.0, size: 0.0,
reserved: 0.0, mode_bits: 0,
scratch,
reserved,
billboard_offset,
scale,
o_class: span_class, o_class: span_class,
count: b.u32("shrub count")?, count: bc.u32("shrub count")?,
data_offset: b.u32("shrub data offset")?, data_offset: bc.u32("shrub data offset")?,
}; };
if header.o_class != o_class.max(0) as u32 { if header.o_class != o_class.max(0) as u32 {
@@ -182,8 +542,17 @@ impl ShrubHeader {
}); });
} }
let at = header.data_offset as usize; // A billboard that is not inside the span is a mis-set offset.
let stop = span_end.clamp(start, core.len()); let stop = span_end.clamp(start, core.len());
if billboard_offset > 0 && start + billboard_offset as usize + 0x40 > stop {
return Err(Error {
offset: start + 0x1c,
context: "shrub billboard offset",
kind: ErrorKind::Invalid("shrub billboard offset runs past the span"),
});
}
let at = header.data_offset as usize;
let data = core.get(start + at..stop).ok_or(Error { let data = core.get(start + at..stop).ok_or(Error {
offset: start + at, offset: start + at,
context: "shrub data offset runs past the lump", context: "shrub data offset runs past the lump",
@@ -195,9 +564,13 @@ impl ShrubHeader {
Ok(Some(Shrub { Ok(Some(Shrub {
span_end_is_a_successor: true, span_end_is_a_successor: true,
header: ShrubHeader { header: ShrubHeader {
bounds, sphere,
size, size,
mode_bits,
scratch,
reserved, reserved,
billboard_offset,
scale,
o_class: header.o_class, o_class: header.o_class,
count: header.count, count: header.count,
data_offset: header.data_offset, data_offset: header.data_offset,
@@ -216,16 +589,41 @@ mod tests {
/// span cannot start there. /// span cannot start there.
const BASE: usize = 0x10; const BASE: usize = 0x10;
/// A span whose `+0x24` names class 1072 and whose data starts at 0x30. /// A unit direction per palette slot: a spiral over the sphere, so every
fn span() -> Vec<u8> { /// entry is a different unit vector rather than a repeated one.
let mut v = vec![0u8; BASE + 0x40]; fn fixture_normal(i: usize) -> [f32; 3] {
let t = i as f32 / SHRUB_NORMAL_COUNT as f32 * std::f32::consts::TAU;
let z = (i % 5) as f32 / 2.0 - 1.0;
let r = (1.0 - z * z).max(0.0).sqrt();
[r * t.cos(), r * t.sin(), z]
}
/// A span naming class 1072, data at 0x30, a positive radius and scale,
/// and a 24-entry palette of unit normals. `tail` extra bytes follow the
/// palette inside the span: zero ones read as padding records.
fn span_with_tail(tail: usize) -> Vec<u8> {
let mut v = vec![0u8; BASE + 0x30 + SHRUB_NORMAL_COUNT * 8 + tail];
v[BASE + 0x0c..BASE + 0x10].copy_from_slice(&12.5f32.to_le_bytes());
v[BASE + 0x10..BASE + 0x14].copy_from_slice(&6.0f32.to_le_bytes());
v[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.25f32.to_le_bytes());
v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes()); v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes());
v[BASE + 0x28..BASE + 0x2c].copy_from_slice(&5u32.to_le_bytes()); v[BASE + 0x28..BASE + 0x2c].copy_from_slice(&5u32.to_le_bytes());
v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&0x30u32.to_le_bytes()); v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&0x30u32.to_le_bytes());
v[BASE + 0x30..].fill(0xab); for (i, n) in (0..SHRUB_NORMAL_COUNT).map(fixture_normal).enumerate() {
let at = BASE + 0x30 + i * 8;
for (c, &v_c) in n.iter().enumerate() {
let q = (v_c * 32767.0).round() as i16;
v[at + c * 2..at + c * 2 + 2].copy_from_slice(&q.to_le_bytes());
}
}
v v
} }
/// A span with two zero padding records after the palette.
fn span() -> Vec<u8> {
span_with_tail(16)
}
#[test] #[test]
fn reads_the_self_identifying_header() { fn reads_the_self_identifying_header() {
let c = span(); let c = span();
@@ -235,7 +633,168 @@ mod tests {
assert_eq!(s.header.o_class, 1072); assert_eq!(s.header.o_class, 1072);
assert_eq!(s.header.count, 5); assert_eq!(s.header.count, 5);
assert_eq!(s.header.data_offset, 0x30); assert_eq!(s.header.data_offset, 0x30);
assert!(s.data.iter().all(|&b| b == 0xab)); assert_eq!(s.header.billboard_offset, 0);
assert_eq!(s.header.scale, 0.25);
assert_eq!(s.header.sphere[3], 12.5);
assert!(s.billboard().unwrap().is_none());
}
#[test]
fn the_body_is_24_unit_normals_plus_zero_padding() {
let c = span();
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
let normals: Vec<ShrubNormal> = s.normals().collect();
assert_eq!(normals.len(), SHRUB_NORMAL_COUNT);
assert!(normals.iter().all(|n| n.is_unit()), "palette is not unit");
// The fixture's two trailing zero records are padding, not normals.
assert_eq!(s.padding_record_count(), 2);
assert_eq!(s.body_record_count(), SHRUB_NORMAL_COUNT + 2);
assert!(s.body_is_well_formed());
// The palette repeats no direction, and the padding is not in it.
assert!(!s.normals().any(|n| n == ShrubNormal { x: 0, y: 0, z: 0 }));
}
#[test]
fn a_body_of_zeros_is_not_a_palette_of_normals() {
let mut v = vec![0u8; BASE + 0x40];
v[BASE + 0x0c..BASE + 0x10].copy_from_slice(&12.5f32.to_le_bytes());
v[BASE + 0x10..BASE + 0x14].copy_from_slice(&6.0f32.to_le_bytes());
v[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.25f32.to_le_bytes());
v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes());
v[BASE + 0x28..BASE + 0x2c].copy_from_slice(&5u32.to_le_bytes());
v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&0x30u32.to_le_bytes());
let s = ShrubHeader::region(&v, BASE as i32, 1072, v.len())
.unwrap()
.expect("present");
// Every record is whole and zero-tailed, but no entry is a normal.
assert!(s.body_is_well_formed());
assert!(!s.normals().any(|n| n.is_unit()));
}
/// A span laid out the way a disc blob is: header, packet table, packets,
/// then the palette at the end.
fn span_with_packets(palette_at: usize) -> Vec<u8> {
let mut v = vec![0u8; BASE + palette_at + SHRUB_NORMAL_COUNT * 8];
v[BASE + 0x0c..BASE + 0x10].copy_from_slice(&12.5f32.to_le_bytes());
v[BASE + 0x10..BASE + 0x14].copy_from_slice(&6.0f32.to_le_bytes());
v[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.25f32.to_le_bytes());
v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes());
v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&(palette_at as u32).to_le_bytes());
for (i, n) in (0..SHRUB_NORMAL_COUNT).map(fixture_normal).enumerate() {
let at = BASE + palette_at + i * 8;
for (c, v_c) in n.iter().enumerate() {
let q = (v_c * 32767.0).round() as i16;
v[at + c * 2..at + c * 2 + 2].copy_from_slice(&q.to_le_bytes());
}
}
v
}
#[test]
fn the_packets_tile_the_blob() {
// Three packets tiling from align16(0x40 + 3*8) = 0x60 to the palette.
let palette_at = 0x300usize;
let mut c = span_with_packets(palette_at);
c[BASE + 0x28..BASE + 0x2c].copy_from_slice(&3u32.to_le_bytes());
let entries = [
(0x60usize, 0x40usize),
(0xa0, 0x30),
(0xd0, palette_at - 0xd0),
];
for (i, (o, sz)) in entries.iter().enumerate() {
let at = BASE + 0x40 + i * 8;
c[at..at + 4].copy_from_slice(&(*o as u32).to_le_bytes());
c[at + 4..at + 8].copy_from_slice(&(*sz as u32).to_le_bytes());
}
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
let packets = s.packets().expect("the entries tile");
assert_eq!(packets.len(), 3);
assert_eq!(packets[0].size, 0x40);
assert_eq!(packets[2].size, palette_at - 0xd0);
// The palette is untouched by the packets sitting before it.
assert_eq!(s.normals().count(), SHRUB_NORMAL_COUNT);
assert!(s.normals().all(|n| n.is_unit()));
}
#[test]
fn a_billboard_record_sits_between_the_packets_and_the_palette() {
// With a billboard, the packets end 0x40 short of the palette.
let palette_at = 0x300usize;
let mut c = span_with_packets(palette_at);
c[BASE + 0x28..BASE + 0x2c].copy_from_slice(&1u32.to_le_bytes());
// One entry: the table ends at 0x48, aligned up to 0x50.
let size = palette_at - 0x40 - 0x50;
c[BASE + 0x40..BASE + 0x44].copy_from_slice(&0x50u32.to_le_bytes());
c[BASE + 0x44..BASE + 0x48].copy_from_slice(&(size as u32).to_le_bytes());
// A billboard offset inside the span, in its last 0x40 bytes.
c[BASE + 0x1c..BASE + 0x20].copy_from_slice(&((palette_at - 0x40) as u32).to_le_bytes());
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
let packets = s.packets().expect("the entry stops at the billboard");
assert_eq!(packets.len(), 1);
assert_eq!(packets[0].size, palette_at - 0x40 - 0x50);
}
#[test]
fn a_packet_table_that_does_not_tile_is_rejected() {
let palette_at = 0x300usize;
let mut c = span_with_packets(palette_at);
c[BASE + 0x28..BASE + 0x2c].copy_from_slice(&2u32.to_le_bytes());
// The second entry starts mid-packet, not where the first ended.
c[BASE + 0x40..BASE + 0x44].copy_from_slice(&0x60u32.to_le_bytes());
c[BASE + 0x44..BASE + 0x48].copy_from_slice(&0x40u32.to_le_bytes());
c[BASE + 0x48..BASE + 0x4c].copy_from_slice(&0x61u32.to_le_bytes());
c[BASE + 0x4c..BASE + 0x50].copy_from_slice(&0x40u32.to_le_bytes());
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
assert!(s.packets().is_none());
}
#[test]
fn a_billboard_record_is_read_and_checked() {
let mut c = span_with_tail(0x40 + 8);
// The billboard sits directly after the palette.
let bb = BASE + 0x30 + SHRUB_NORMAL_COUNT * 8;
c[BASE + 0x1c..BASE + 0x20].copy_from_slice(&((bb - BASE) as u32).to_le_bytes());
c[bb..bb + 4].copy_from_slice(&50.0f32.to_le_bytes());
// TEX1: K = -217, MMIN = 4, address 0x14.
c[bb + 0x10..bb + 0x12].copy_from_slice(&(-217i16).to_le_bytes());
c[bb + 0x14..bb + 0x18].copy_from_slice(&4u32.to_le_bytes());
c[bb + 0x18..bb + 0x1c].copy_from_slice(&0x14u32.to_le_bytes());
// TEX0: data_lo = 1, address 0x06.
c[bb + 0x20..bb + 0x24].copy_from_slice(&1u32.to_le_bytes());
c[bb + 0x28..bb + 0x2c].copy_from_slice(&0x06u32.to_le_bytes());
// MIPTBP1_1: address 0x34.
c[bb + 0x38..bb + 0x3c].copy_from_slice(&0x34u32.to_le_bytes());
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
let b = s.billboard().unwrap().expect("billboard present");
assert_eq!(b.fade_distance, 50.0);
assert_eq!(b.k, -217);
assert_eq!(b.mmin, 4);
assert_eq!(b.tex0, 1);
assert_eq!(b.tex1_addr, 0x14);
assert_eq!(b.tex0_addr, 0x06);
assert_eq!(b.miptbp_addr, 0x34);
// The palette is untouched by the billboard sitting after it.
assert_eq!(s.normals().count(), SHRUB_NORMAL_COUNT);
assert!(s.normals().all(|n| n.is_unit()));
}
#[test]
fn a_billboard_offset_past_the_span_is_rejected() {
let mut c = span_with_tail(0x40);
// Far past the span's end.
c[BASE + 0x1c..BASE + 0x20].copy_from_slice(&9000u32.to_le_bytes());
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
} }
#[test] #[test]
@@ -255,37 +814,21 @@ mod tests {
} }
#[test] #[test]
fn body_records_split_into_eights_and_check_their_tail() { fn a_nonpositive_radius_or_scale_is_rejected() {
let c = span(); let mut c = span();
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len()) c[BASE + 0x0c..BASE + 0x10].copy_from_slice(&0.0f32.to_le_bytes());
.unwrap() assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
.expect("present");
// The fixture fills 0xab, so every record's tail is wrong. let mut c = span();
assert!(!s.body_is_well_formed()); c[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.0f32.to_le_bytes());
assert_eq!(s.body_record_count(), (0x40 - 0x30) / 8); assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
// Fix the tails and it is well formed.
let mut c2 = span();
for i in 0..((0x40 - 0x30) / 8) {
c2[BASE + 0x30 + i * 8 + 6] = 0;
c2[BASE + 0x30 + i * 8 + 7] = 0;
}
let s2 = ShrubHeader::region(&c2, BASE as i32, 1072, c2.len())
.unwrap()
.expect("present");
assert!(s2.body_is_well_formed());
assert_eq!(s2.body_records().count(), s2.body_record_count());
} }
#[test] #[test]
fn a_body_shorter_than_one_record_is_not_well_formed() { fn a_nonzero_reserved_word_is_rejected() {
let mut c = span(); let mut c = span();
// Point the data at the last four bytes of the span. c[BASE + 0x18..BASE + 0x1c].copy_from_slice(&0x40u32.to_le_bytes());
c[BASE + 0x2c..BASE + 0x30].copy_from_slice(&(0x3cu32).to_le_bytes()); assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
assert_eq!(s.body_record_count(), 0);
assert!(!s.body_is_well_formed());
} }
#[test] #[test]
+165
View File
@@ -0,0 +1,165 @@
//! The sky block: its header and its sub-objects.
//!
//! Spec: `docs/formats/core-data-regions.md`.
//!
//! The sky region runs from the core index's `sky` offset to the next region.
//! Its first 0x20 bytes are a header, and the sub-objects are named by a table
//! of 32-bit offsets starting at `+0x20`.
//!
//! **The sub-object count is not at `+0x0c`.** That field reads 5 on level 0
//! and the table there holds 6 entries, 3 on level 1 against 4 entries, 7 on
//! level 3 against 8 -- and across the disc `offsets.len() == count + 1` holds
//! on only **10 of 27** levels, with level 2 reading 196,617. The field is
//! reported as [`SkyHeader::field_0c`] and nothing depends on it. The offset
//! table is what actually enumerates the sub-objects, and it is self-limiting:
//! entries run while they stay strictly inside the region.
//!
//! Each sub-object opens with a count, twelve zero bytes, a four-float bounding
//! sphere, then its texture dimensions. The sphere is the same shape as the
//! shrub class header's: three floats and a radius, with the fourth positive
//! where the first three can be either sign.
use crate::buf::{Buf, Error, ErrorKind, Result};
/// Where the sub-object offset table starts within the sky region.
pub const SKY_OFFSET_TABLE_OFFSET: usize = 0x20;
/// Most sub-objects any level's table holds.
const MAX_SUB_OBJECTS: usize = 64;
/// The sky region's fixed header.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SkyHeader {
/// Bytes `+0x04..+0x0c`. Meaning not established.
pub field_04: [u32; 2],
/// The word at `+0x0c`.
///
/// Reported, never relied on. It does **not** equal the sub-object count:
/// see the module docs. Level 2 reads 196,617 against 4 sub-objects.
pub field_0c: u32,
/// The word at `+0x10`. Constant `64` on level 0.
pub field_10: u32,
/// The word at `+0x14`. Constant `192` on level 0.
pub field_14: u32,
/// The word at `+0x18`. Constant `64` on level 0.
pub field_18: u32,
}
impl SkyHeader {
/// Parse the sky header, bounds-checked against `bytes`.
pub fn parse(bytes: &[u8]) -> Result<SkyHeader> {
let mut b = Buf::new(bytes);
b.skip(4, "sky header")?;
Ok(SkyHeader {
field_04: [b.u32("sky +0x04")?, b.u32("sky +0x08")?],
field_0c: b.u32("sky +0x0c")?,
field_10: b.u32("sky +0x10")?,
field_14: b.u32("sky +0x14")?,
field_18: b.u32("sky +0x18")?,
})
}
}
/// One sky sub-object.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SkySubObject<'a> {
/// The leading count. `1` to `38` on level 0.
pub count: u32,
/// Bytes `+0x04..+0x10`. Zero on every sub-object measured.
pub reserved: [u8; 12],
/// The bounding sphere: three floats and a radius at `+0x10`.
///
/// The fourth float is positive wherever the first three are negative on
/// level 0, which is the reading that makes it a radius. Shrub uses the
/// same shape and the same reasoning.
pub sphere: [f32; 4],
/// A memory offset at `+0x20`, distinct per sub-object.
pub texture_offset: u32,
/// Texture width at `+0x24`. `29`, `14`, `15`, `26`, `81` on level 0.
pub width: u16,
/// Texture height at `+0x26`.
pub height: u16,
/// The sub-object's bytes.
pub data: &'a [u8],
}
impl<'a> SkySubObject<'a> {
/// Parse one sub-object, bounds-checked against `bytes`.
pub fn parse(bytes: &'a [u8]) -> Result<SkySubObject<'a>> {
if bytes.len() < 0x2c {
return Err(Error {
offset: 0,
context: "sky sub-object is shorter than its header",
kind: ErrorKind::OutOfBounds {
needed: 0x2c,
available: bytes.len(),
},
});
}
let mut b = Buf::new(bytes);
let count = b.u32("sky sub-object count")?;
let mut reserved = [0u8; 12];
reserved.copy_from_slice(b.read_bytes(12, "sky sub-object reserved")?);
let sphere = [
b.f32("sky sphere x")?,
b.f32("sky sphere y")?,
b.f32("sky sphere z")?,
b.f32("sky sphere radius")?,
];
let texture_offset = b.u32("sky texture offset")?;
let width = b.u16("sky texture width")?;
let height = b.u16("sky texture height")?;
Ok(SkySubObject {
count,
reserved,
sphere,
texture_offset,
width,
height,
data: bytes,
})
}
}
/// A parsed sky region: its header and its sub-objects.
pub struct Sky<'a> {
pub header: SkyHeader,
pub sub_objects: Vec<SkySubObject<'a>>,
}
impl<'a> Sky<'a> {
/// Parse the sky region at `bytes`, whose length bounds it.
pub fn parse(bytes: &'a [u8]) -> Result<Sky<'a>> {
let header = SkyHeader::parse(bytes)?;
let mut offsets = Vec::new();
let mut b = Buf::new(bytes);
b.seek(SKY_OFFSET_TABLE_OFFSET);
// Entries run while they stay strictly inside the region. This is the
// enumeration; the word at +0x0c is not.
for _ in 0..MAX_SUB_OBJECTS {
let at = b.pos();
if at + 4 > bytes.len() {
break;
}
let v = b.u32("sky sub-object offset")?;
if v == 0 || v as usize >= bytes.len() {
break;
}
offsets.push(v as usize);
}
let mut sub_objects = Vec::with_capacity(offsets.len());
for (i, &start) in offsets.iter().enumerate() {
let end = offsets.get(i + 1).copied().unwrap_or(bytes.len());
let end = end.min(bytes.len());
let Some(seg) = bytes.get(start..end) else {
continue;
};
sub_objects.push(SkySubObject::parse(seg)?);
}
Ok(Sky {
header,
sub_objects,
})
}
}
+100
View File
@@ -68,6 +68,106 @@ impl SoundBankHeader {
} }
} }
/// The 24-byte head that opens the `SBlk` region.
///
/// Measured across all 27 levels: the first twelve bytes are **identical
/// everywhere** -- `"SBlk"`, then `1`, then `4`, then `"DAW\0"` -- and the word
/// at `+0x10` is zero on every level. The word at `+0x14` is the only part that
/// varies, and it varies per level with its **low sixteen bits zero in all 27**,
/// leaving values `0x0092..=0x0103` (146..=259).
///
/// That word tracks the length of the `SBlk` body at close to 100 bytes per
/// unit, but it is **not** a record count with a fixed stride: the ratio runs
/// 91.2 to 110.8 across the 27 levels, and no stride in 0..80 makes
/// `body = stride * word + constant` hold exactly on all of them. So it is
/// correlated with the body length and is not identified. Recorded rather than
/// named, because "a count that nearly works" is exactly the claim that later
/// gets asserted by someone who did not run this test.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SBlkHeader {
/// Always `"SBlk"`.
pub magic: [u8; 4],
/// Always `1`.
pub field_4: u32,
/// Always `4`.
pub field_8: u32,
/// Always `"DAW\0"`.
pub format_tag: [u8; 4],
/// Always `0`.
pub field_10: u32,
/// Varies per level; low sixteen bits always zero, `0x0092..=0x0103`.
pub field_14: u32,
}
impl SBlkHeader {
/// Parse the 24-byte head.
pub fn parse(bytes: &[u8]) -> Result<SBlkHeader> {
// Read in byte order. The struct literal names `format_tag` after two
// words, but a `let` bound for it would consume bytes 4..8 and shift
// every field after it -- which it did, once.
let mut b = Buf::new(bytes);
let magic: [u8; 4] = b.read_bytes(4, "SBlk magic")?.try_into().expect("4 bytes");
let field_4 = b.u32("SBlk field_4")?;
let field_8 = b.u32("SBlk field_8")?;
let format_tag: [u8; 4] = b
.read_bytes(4, "SBlk format tag")?
.try_into()
.expect("4 bytes");
Ok(SBlkHeader {
magic,
field_4,
field_8,
format_tag,
field_10: b.u32("SBlk field_10")?,
field_14: b.u32("SBlk field_14")?,
})
}
/// True when every constant this header carries holds.
///
/// Each is universal on all 27 levels. They are tested individually so a
/// real deviation says which one moved.
pub fn constants_hold(&self) -> bool {
&self.magic == b"SBlk"
&& self.field_4 == 1
&& self.field_8 == 4
&& &self.format_tag == b"DAW\0"
&& self.field_10 == 0
}
}
/// The first twelve words of the `SBlk` body, after its 24-byte head.
///
/// Words 4 and 5 both hold the **DAW sample-data size** -- byte for byte, on all
/// 27 levels. Words 1, 3, 6, 7, 8, 9, 10 and 11 are the same on every level
/// (60, 0, 0, 0, 0, 127, 1, 0). Words 0 and 2 vary per level and are not
/// identified.
///
/// These sizes fall inside `[daw_offset, daw_offset + daw_size)`, so a test of
/// the form "is this word in the DAW region?" passes for them and reads as a
/// pointer. It is not one; equality with `daw_size` is the claim.
///
/// Asserted in `sound_bank_sblk_body_head_carries_the_daw_size_twice`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SBlkBodyHead {
pub word_0: u32,
pub word_1: u32,
pub word_2: u32,
pub word_3: u32,
/// Always the DAW sample-data size.
pub word_4: u32,
/// Always the same value as [`Self::word_4`].
pub word_5: u32,
pub word_6: u32,
pub word_7: u32,
pub word_8: u32,
/// Always 127.
pub word_9: u32,
/// Always 1.
pub word_10: u32,
pub word_11: u32,
}
/// Split a sound extent into its two regions. /// Split a sound extent into its two regions.
/// ///
/// Bounds are checked before slicing; a level whose head points outside its own /// Bounds are checked before slicing; a level whose head points outside its own
+22 -8
View File
@@ -18,6 +18,7 @@
//! anything above it clamps to opaque. //! anything above it clamps to opaque.
use crate::buf::{Error, ErrorKind, Result}; use crate::buf::{Error, ErrorKind, Result};
use crate::gs;
use crate::level_core::TextureEntry; use crate::level_core::TextureEntry;
/// PS2 CSM1 CLUT order to linear: within each 32-entry group, swap the two /// PS2 CSM1 CLUT order to linear: within each 32-entry group, swap the two
@@ -30,13 +31,20 @@ pub fn clut_index(i: u32) -> u32 {
} }
} }
/// PS2 alpha 0..=0x80 to 0..=0xff. `0x80` is opaque; above that clamps. /// PS2 alpha `0x00..=0x80` to `0x00..=0xff`. `0x80` is opaque; above clamps.
///
/// Delegates to [`crate::gs::alpha_to_8`] rather than computing here. This used
/// to be `if a < 0x80 { a * 2 } else { 0xff }`, which is `a / 128 * 256` -- one
/// too high at the top of the range, so `0x7F` decoded to 254 where the renderer's
/// own alpha path and gs::alpha_to_8 both give 253. Three expressions of one rule,
/// two of them right, and the wrong one was the one decoding actual pixels.
///
/// The visual difference is at most 1/255 on a single palette entry and nothing
/// anyone would ever see. That is exactly why it survived: the bug class here is
/// not "big wrong", it is "right to within a rounding step", in the code path
/// that nobody instruments.
pub fn scale_alpha(a: u8) -> u8 { pub fn scale_alpha(a: u8) -> u8 {
if a < 0x80 { gs::alpha_to_8(a)
a * 2
} else {
0xff
}
} }
/// A decoded RGBA8 image. /// A decoded RGBA8 image.
@@ -265,12 +273,18 @@ mod tests {
} }
#[test] #[test]
fn alpha_is_scaled_from_eight_bit_halves() { fn alpha_runs_zero_to_0x80_and_scales_to_0xff() {
assert_eq!(scale_alpha(0), 0); assert_eq!(scale_alpha(0), 0);
assert_eq!(scale_alpha(0x40), 0x80); assert_eq!(scale_alpha(0x40), 0x80);
assert_eq!(scale_alpha(0x7f), 0xfe); // 0x7F is 127/128, which is 253 and not 0xFE. This test was called
// `alpha_is_scaled_from_eight_bit_halves` and asserted 0xfe, pinning the
// `a * 2` implementation -- it had the bug in its name as well as its
// assertion. The rule is /128 * 255, not a doubling.
assert_eq!(scale_alpha(0x7f), 0xfd);
assert_eq!(scale_alpha(0x80), 0xff); assert_eq!(scale_alpha(0x80), 0xff);
assert_eq!(scale_alpha(0xff), 0xff); assert_eq!(scale_alpha(0xff), 0xff);
// A doubling cannot produce this, and the disc contains it.
assert_eq!(scale_alpha(0x7e), 0xfb);
} }
/// A 2x1 image whose two texels select distinct CLUT entries. /// A 2x1 image whose two texels select distinct CLUT entries.
+83 -6
View File
@@ -40,8 +40,13 @@
//! minification filter, LOD bias), the per-vertex colours and the per-vertex //! minification filter, LOD bias), the per-vertex colours and the per-vertex
//! light and normal records. //! light and normal records.
//! //!
//! Not yet recovered: the LOD switch and morph rules in `TfragProc`, and the //! What is *not* claimed: that these rules match the original. The LOD switch
//! texel data the texture table points into. See `docs/plan/tfrag.md`. //! distances, the morph weight and the collapse edge are all **derived from the
//! data** -- tiers are classified from the four V4_8 parent arrays, and the
//! weight runs from zero to one across each tier's band -- but they have not
//! been checked against PCSX2 the way the lighting pass was. That check is the
//! outstanding item, and it needs a frame captured at a known view distance
//! rather than more disc-side measurement.
use crate::buf::{Buf, Error, ErrorKind, Result}; use crate::buf::{Buf, Error, ErrorKind, Result};
@@ -143,10 +148,21 @@ pub struct TfragHeader {
pub base_only: u8, pub base_only: u8,
pub texture_count: u8, pub texture_count: u8,
pub rgba_size: u8, pub rgba_size: u8,
/// `+0x2e`: offset of the mini-sphere array, `msphere_count` entries of
/// four `f32` -- xyz and a radius. What lives at that offset is verified on
/// the disc: all 833 of level 0's arrays are four finite floats per entry
/// with a non-negative radius.
pub msphere_ofs: u16, pub msphere_ofs: u16,
pub light_ofs: u16, pub light_ofs: u16,
pub light_end_ofs: u16, pub light_end_ofs: u16,
pub cube_ofs: u16, /// `+0x2c`, paired with `msphere_ofs`: how many `Vec4f` entries the
/// mini-sphere array holds.
pub msphere_count: u8,
/// `+0x38`: offset of the 8-corner clip cube. **Not the same slot as
/// `msphere_ofs`.** R&C1 documents both offsets with the same name's worth
/// of confusion -- `msphere_ofs` at `+0x2e` and this at `+0x38` -- and the
/// earlier reading here put the mini-sphere array in the clip-cube slot.
pub clip_cube_ofs: u16,
/// Occlusion / visibility index for this tfrag. /// Occlusion / visibility index for this tfrag.
pub occl_index: u16, pub occl_index: u16,
/// Total vertex count. `u8`, so at most 255 positions per tfrag. /// Total vertex count. `u8`, so at most 255 positions per tfrag.
@@ -183,7 +199,7 @@ impl TfragHeader {
let rgba_size = b.u8("tfrag rgba_size")?; let rgba_size = b.u8("tfrag rgba_size")?;
let _rgba_verts_loc = b.u8("tfrag rgba_verts_loc")?; let _rgba_verts_loc = b.u8("tfrag rgba_verts_loc")?;
let _occl_index_stash = b.u8("tfrag occl_index_stash")?; let _occl_index_stash = b.u8("tfrag occl_index_stash")?;
let _msphere_count = b.u8("tfrag msphere_count")?; let msphere_count = b.u8("tfrag msphere_count")?;
let _flags = b.u8("tfrag flags")?; let _flags = b.u8("tfrag flags")?;
let msphere_ofs = b.u16("tfrag msphere_ofs")?; let msphere_ofs = b.u16("tfrag msphere_ofs")?;
let light_ofs = b.u16("tfrag light_ofs")?; let light_ofs = b.u16("tfrag light_ofs")?;
@@ -191,7 +207,7 @@ impl TfragHeader {
let _dir_lights_one = b.u8("tfrag dir_lights_one")?; let _dir_lights_one = b.u8("tfrag dir_lights_one")?;
let _dir_lights_upd = b.u8("tfrag dir_lights_upd")?; let _dir_lights_upd = b.u8("tfrag dir_lights_upd")?;
let _point_lights = b.u16("tfrag point_lights")?; let _point_lights = b.u16("tfrag point_lights")?;
let cube_ofs = b.u16("tfrag cube_ofs")?; let clip_cube_ofs = b.u16("tfrag clip_cube_ofs")?;
let occl_index = b.u16("tfrag occl_index")?; let occl_index = b.u16("tfrag occl_index")?;
let vert_count = b.u8("tfrag vert_count")?; let vert_count = b.u8("tfrag vert_count")?;
let tri_count = b.u8("tfrag tri_count")?; let tri_count = b.u8("tfrag tri_count")?;
@@ -216,7 +232,8 @@ impl TfragHeader {
msphere_ofs, msphere_ofs,
light_ofs, light_ofs,
light_end_ofs, light_end_ofs,
cube_ofs, msphere_count,
clip_cube_ofs,
occl_index, occl_index,
vert_count, vert_count,
tri_count, tri_count,
@@ -683,6 +700,25 @@ pub struct TfragGeometry {
pub rgba: Vec<TfragRgba>, pub rgba: Vec<TfragRgba>,
/// Per-vertex light and normal records, indexed by position index. /// Per-vertex light and normal records, indexed by position index.
pub lights: Vec<TfragLight>, pub lights: Vec<TfragLight>,
/// Byte ranges of the V3_16 position unpacks, relative to the start of the
/// block the lists were walked in. Two walks of the same header over two
/// block copies agree on these, so a per-byte diff can be classified as
/// "inside a position unpack" or not.
pub position_ranges: Vec<(usize, usize)>,
/// Byte ranges of every other unpack's payload in the same walks. Kept
/// alongside `position_ranges` so a per-byte diff can be classified as
/// "inside a position unpack", "inside another unpack", or "in a VIF
/// command word" without a second walk.
pub other_ranges: Vec<(usize, usize)>,
/// The 8 clip-cube corners, read as four `s16` at `clip_cube_ofs` and
/// scaled by 64. The scale is R&C1's documented one and is unverified
/// against the game; what is verified here is only that the eight points
/// land at a median distance of 1.000 times the bounding sphere's radius
/// from their own centroid.
pub clip_corners: Vec<[i32; 3]>,
/// `msphere_count` entries of four `f32` from `msphere_ofs`. Purpose
/// unknown; the array is verified to exist and to be well formed.
pub mini_spheres: Vec<[f32; 4]>,
} }
impl TfragGeometry { impl TfragGeometry {
@@ -789,6 +825,12 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
last_strow = read_i32x4(payload, 0); last_strow = read_i32x4(payload, 0);
} }
} else if v.is_unpack() { } else if v.is_unpack() {
let payload_range = (pos + 4, pos + v.packet_size());
if (v.vn(), v.vl()) == (2, 1) {
g.position_ranges.push(payload_range);
} else {
g.other_ranges.push(payload_range);
}
let addr = v.vu_address(); let addr = v.vu_address();
match (v.vn(), v.vl()) { match (v.vn(), v.vl()) {
(3, 1) if v.usn() && addr == 0 => { (3, 1) if v.usn() && addr == 0 => {
@@ -832,6 +874,7 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
2 2
}; };
g.positions_tier[tier] = v.count(); g.positions_tier[tier] = v.count();
g.position_ranges.push((pos + 4, pos + v.packet_size()));
for p3 in read_i16x3(payload, v.count())? { for p3 in read_i16x3(payload, v.count())? {
g.positions.push([p3[0] as i32, p3[1] as i32, p3[2] as i32]); g.positions.push([p3[0] as i32, p3[1] as i32, p3[2] as i32]);
} }
@@ -931,6 +974,40 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
kind: ErrorKind::Invalid("tfrag position count does not match its header"), kind: ErrorKind::Invalid("tfrag position count does not match its header"),
}); });
} }
// The 8-corner clip cube at `+0x38`: eight reads of four `s16`, scaled by
// 64. Trailing data rather than a fixed array is the reason the count is
// hardcoded -- the slot is 0x40 bytes of the patch's own block.
if header.clip_cube_ofs != 0 {
let at = header.clip_cube_ofs as usize;
if let Some(bytes) = block.get(at..at + 8 * 8) {
for c in 0..8 {
let o = c * 8;
let w =
|k: usize| i16::from_le_bytes([bytes[o + k * 2], bytes[o + k * 2 + 1]]) as i32;
g.clip_corners.push([w(0) * 64, w(1) * 64, w(2) * 64]);
}
}
}
// The mini-sphere array at `+0x2e`, `+0x2c` entries. The radius is read and
// kept even though nothing interprets it: an array whose entries are four
// finite floats with a non-negative fourth is the only verified property,
// and dropping the field would lose it.
if header.msphere_ofs != 0 {
let at = header.msphere_ofs as usize;
let n = header.msphere_count as usize;
if let Some(bytes) = block.get(at..at + n * 16) {
for k in 0..n {
let o = k * 16;
let w = |j: usize| {
f32::from_le_bytes(bytes[o + j * 4..o + j * 4 + 4].try_into().unwrap())
};
g.mini_spheres.push([w(0), w(1), w(2), w(3)]);
}
}
}
Ok(g) Ok(g)
} }
+45 -6
View File
@@ -151,19 +151,34 @@ const MAX_LIGHTS: usize = 64;
impl LightBank { impl LightBank {
/// Parse the bank from a level's `gameplay` extent. /// Parse the bank from a level's `gameplay` extent.
/// ///
/// The header at +0x04 is an offset to a count followed by the sets; a /// The extent opens with a table of section offsets in bytes; the word at
/// negative count means the level has no lights. /// **+0x04** is the directional-light section's offset. That section is an
/// `i32` set count, twelve bytes of pad, then the sets. This was read the
/// other way round — offset at +0x00, count at +0x04 — until the PCSX2
/// comparison showed the pass running on denormal garbage and reporting
/// every vertex lit; see `docs/plan/pcsx2-verification.md`.
pub fn parse(gameplay: &[u8]) -> Result<LightBank> { pub fn parse(gameplay: &[u8]) -> Result<LightBank> {
let mut b = Buf::new(gameplay); let mut b = Buf::new(gameplay);
b.skip(4, "gameplay extent header")?;
let ofs = b.u32("light bank offset")? as usize; let ofs = b.u32("light bank offset")? as usize;
let count = b.i32("directional light count")?; if ofs + 0x10 > gameplay.len() {
return Err(Error {
offset: ofs,
context: "light bank offset runs past the gameplay extent",
kind: ErrorKind::OutOfBounds {
needed: 0x10,
available: gameplay.len().saturating_sub(ofs),
},
});
}
let count = i32::from_le_bytes(gameplay[ofs..ofs + 4].try_into().unwrap());
if count < 0 { if count < 0 {
return Ok(LightBank::default()); return Ok(LightBank::default());
} }
let n = (count as usize).min(MAX_LIGHTS); let n = (count as usize).min(MAX_LIGHTS);
let mut sets = Vec::with_capacity(n); let mut sets = Vec::with_capacity(n);
for _ in 0..n { for i in 0..n {
sets.push(read_set(gameplay, ofs + 0x10 + sets.len() * 0x40)?); sets.push(read_set(gameplay, ofs + 0x10 + i * 0x40)?);
} }
Ok(LightBank { Ok(LightBank {
sets, sets,
@@ -264,8 +279,32 @@ mod tests {
#[test] #[test]
fn a_level_with_no_lights_parses_as_empty() { fn a_level_with_no_lights_parses_as_empty() {
let mut g = vec![0u8; 32]; let mut g = vec![0u8; 32];
g[0x04..0x08].copy_from_slice(&(-1i32).to_le_bytes()); g[0x04..0x08].copy_from_slice(&8u32.to_le_bytes());
g[0x08..0x0c].copy_from_slice(&(-1i32).to_le_bytes());
let b = LightBank::parse(&g).expect("should parse"); let b = LightBank::parse(&g).expect("should parse");
assert!(b.sets.is_empty()); assert!(b.sets.is_empty());
} }
#[test]
fn the_bank_offset_is_at_plus_four_not_zero() {
// A synthetic extent laid out the way the game writes it: an offset at
// +0x04, a count at that offset, then the sets at +0x10.
let mut g = vec![0u8; 0x100];
let at = 0x40usize;
g[0x04..0x08].copy_from_slice(&(at as u32).to_le_bytes());
g[at..at + 4].copy_from_slice(&1i32.to_le_bytes());
let set = |g: &mut [u8], o: usize, v: [f32; 4]| {
for (i, f) in v.iter().enumerate() {
g[o + i * 4..o + i * 4 + 4].copy_from_slice(&f.to_bits().to_le_bytes());
}
};
set(&mut g, at + 0x10, [1.0, 2.0, 3.0, 4.0]);
set(&mut g, at + 0x20, [0.0, 0.0, -1.0, 0.0]);
let b = LightBank::parse(&g).expect("should parse");
assert_eq!(b.sets.len(), 1);
assert_eq!(b.sets[0].color_a, [1.0, 2.0, 3.0, 4.0]);
assert_eq!(b.sets[0].dir_a, [0.0, 0.0, -1.0, 0.0]);
// The old reading — offset at +0, count at +4 — must not be what this
// does: at +0 the extent is 0, so it would find an empty bank.
}
} }
+524 -12
View File
@@ -18,26 +18,61 @@
//! the remainder would repeat the pattern this project keeps running into: a //! the remainder would repeat the pattern this project keeps running into: a
//! field order that produces plausible output and is wrong. //! field order that produces plausible output and is wrong.
//! //!
//! Evidence for what is decoded, over 320 classes across five levels: //! Evidence for what is decoded, over 1,864 classes on all 27 levels:
//! //!
//! * `o_class` at `0x44` equals the core index's class number: **320 of 320**. //! * `o_class` at `0x44` equals the core index's class number: **1,864 of 1,864**.
//! A coincidence is not a plausible explanation for that. //! A coincidence is not a plausible explanation for that.
//! * the LOD distances are ordered `0 < near < mid < far`: 315 of 320. The //! * the LOD distances are ordered `0 < near < mid < far` on most classes, but
//! five exceptions are not yet explained and are worth chasing. //! **80 of 1,864** break it in an exact pattern: `mid_dist` is exactly
//! `8192.0` and `far_dist` is exactly `8190.0` in every one. Those classes
//! have `far < mid`, so `lod_for_depth` can never return 2 (the `far` test is
//! reached only after the `mid` test). Effectively two LOD tiers, not three.
//! See `docs/plan/level-assets.md` for the test.
use crate::buf::{Buf, Result}; use crate::buf::{Buf, Error, ErrorKind, Result};
use crate::level_core::ClassEntry;
/// Bytes of a tie class header that RAC2 and RAC1 agree on. /// Bytes of a tie class header that RAC2 and RAC1 agree on.
pub const TIE_CLASS_HEADER_MATCHED: usize = 0x48; pub const TIE_CLASS_HEADER_MATCHED: usize = 0x48;
/// Value `TieClassHeader::packets[0]` takes on every one of the 1,864 classes.
///
/// It is a constant, not a block offset: the other two `packets` words vary
/// widely and land at different addresses in `core_data` on every class, while
/// this one is 256 everywhere. Read as an offset it would name a point just
/// past the header, which is exactly the kind of field that gets mistaken for
/// a pointer.
pub const TIE_PACKETS0: i32 = 256;
/// A tie class header, decoded as far as the two games agree. /// A tie class header, decoded as far as the two games agree.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct TieClassHeader { pub struct TieClassHeader {
/// `0x00`: packet data offsets for LOD 0 (highest detail), 1 and 2. /// `0x00`: packet data offsets for LOD 0 (highest detail), 1 and 2.
/// ///
/// Meaning confirmed only by being small, ordered and inside the lump; the /// [`TIE_PACKETS0`] is constant across all 1,864 classes and is reported
/// packet structure itself is not decoded yet. /// separately. `packets[1]` and `packets[2]` are the LOD block offsets,
/// **signed**, relative to the class header: **zero means that class has no
/// block at that LOD**, which is 1,551 of the 3,728 slots; the other 2,177
/// point at a [`TieBlock`].
///
/// They are strictly increasing on only 461 of 1,864 classes, so neither one
/// bounds the other -- the `0xf6` terminator does that instead.
pub packets: [i32; 3], pub packets: [i32; 3],
/// `0x0c`: the record table's length of each LOD, as three `u8` counts
/// followed by one unidentified byte.
///
/// The word was read as R&C1's 64-light-slot *normals offset* and skipped,
/// which was the right thing to do with the wrong information: the byte at
/// `+0x0c` is not an offset at all. It is the **record count of the LOD 0
/// block**, `+0x0d` of LOD 1, `+0x0e` of LOD 2, verified on **1,864 of
/// 1,864** classes on all 27 levels. R&C1 carries the same quadruple at
/// `+0x20`; R&C2 has it here.
///
/// `+0x0f` is not the shader (ad-gif) total, which disagrees on 1,827 of
/// the classes measured; it is left unnamed rather than guessed at.
pub packet_counts: [u8; 3],
/// `0x0f`: unidentified. Not the shader total.
pub unknown_0f: u8,
/// `0x10`, `0x14`, `0x18`: LOD switch distances in world units. /// `0x10`, `0x14`, `0x18`: LOD switch distances in world units.
pub near_dist: f32, pub near_dist: f32,
pub mid_dist: f32, pub mid_dist: f32,
@@ -57,18 +92,39 @@ impl TieClassHeader {
for slot in packets.iter_mut() { for slot in packets.iter_mut() {
*slot = b.i32("tie packet offset")?; *slot = b.i32("tie packet offset")?;
} }
// 0x0c: the 64 light-slot normals offset. Read past, not decoded. // 0x0c: the three LOD record counts and one unidentified byte.
b.skip(4, "tie normals offset")?; let packet_counts = [
b.u8("tie LOD0 record count")?,
b.u8("tie LOD1 record count")?,
b.u8("tie LOD2 record count")?,
];
let unknown_0f = b.u8("tie class word 0x0f")?;
let near_dist = b.f32("tie near distance")?; let near_dist = b.f32("tie near distance")?;
let mid_dist = b.f32("tie mid distance")?; let mid_dist = b.f32("tie mid distance")?;
let far_dist = b.f32("tie far distance")?; let far_dist = b.f32("tie far distance")?;
// 0x1c..0x44: the word TieProc reuses as a per-frame counter, the LOD // 0x1c..0x44. Profiled over all 1,864 classes by
// packet counts, the texture count, ad-gifs, the bounding sphere and // `tie_class_header_tail_words`; three words are now characterised and
// the class scale. None of it matches RAC2 yet. // the rest are deliberately skipped rather than guessed at.
// 0x20, 0x30 reserved -- zero on 1,864 of 1,864
// 0x3c six values: 0 (1,384 classes), 16 (435), 48 (21),
// 32 (18), 2 (3), 4 (3). NOT the ad-gif count: mean
// bfc_distance is flat across every bucket.
// 0x40 a float -- 0.00208 to 10.84, no negatives. The bounding
// sphere radius or the class scale; not distinguished.
// 0x1c and 0x34 are base-relative offsets: base + offset lands inside
// core_data on 1,864 of 1,864 classes, and always *past* the class's own
// tie packet blocks, so they address per-class data stored elsewhere.
// The bytes there are structured -- repeating small-integer records,
// occasional `ff 00 ff ff` -- but are not VIF or GIF DMA tags and are
// not identified. See tie_class_tail_offsets_point_past_the_tie_packets.
// 0x24, 0x28, 0x2c, 0x38 are high-entropy and unidentified; 0x24 is
// demonstrably not an offset (17 of 1,864 land in range).
b.skip(0x44 - 0x1c, "tie class fields not decoded yet")?; b.skip(0x44 - 0x1c, "tie class fields not decoded yet")?;
let o_class = b.i32("tie class number")?; let o_class = b.i32("tie class number")?;
Ok(TieClassHeader { Ok(TieClassHeader {
packets, packets,
packet_counts,
unknown_0f,
near_dist, near_dist,
mid_dist, mid_dist,
far_dist, far_dist,
@@ -91,6 +147,398 @@ impl TieClassHeader {
} }
} }
/// Size of a tie LOD block's own header.
pub const TIE_BLOCK_HEADER_SIZE: usize = 0x10;
/// The four most common values of [`TieBlockHeader::packed_texture`].
///
/// Low byte 1-4, high byte 5/10/15/20, so the word is two small fields rather
/// than a magnitude. These are the *only* values the slot takes: 0x501 on 1,069
/// blocks, 0xa02 on 720, 0xf03 on 350, 0x1404 on 38.
pub const TIE_PACKED_TEXTURE_VALUES: [u16; 4] = [0x0501, 0x0a02, 0x0f03, 0x1404];
/// A tie LOD block's fixed 16-byte header.
///
/// Every figure here is measured over the **922** blocks where "the next
/// `packets` offset ends this block" holds, across all 27 levels. The other
/// 1,403 classes are unmeasured, not measured-and-different.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TieBlockHeader {
/// `+0x00`: the byte offset from the start of the block to the vertex
/// data -- that is, the length of the record table that follows this
/// header. A multiple of 16 on all 2,177 blocks (42 distinct values, never
/// zero), because the table holds 16-byte records: one block with this
/// word at 0x10 has one record, one at 0x50 has five.
///
/// This was read as an element *count* for a while, and the reading failed
/// in a way worth recording: the block's payload length divided by it is a
/// whole number on 0 of 922 blocks, at any record size and any header up
/// to 512 bytes. A count of what is in the payload was never going to fit,
/// because the word is not about the payload -- it is where the payload
/// starts.
///
/// Confirmed against the running game: each record's leading word is
/// relocated at load to `table_address + word`, and the table's length is
/// this word, on every block checked in RAM (16 of 16 on level 1).
pub data_offset: u16,
/// `+0x02`: zero on all 2,177 blocks.
pub reserved_02: u16,
/// `+0x04`: one of exactly [`TIE_PACKED_TEXTURE_VALUES`] on all 2,177 blocks.
pub packed_texture: u16,
/// `+0x06`: 16 distinct values, never zero.
pub field_06: u16,
/// `+0x08`: 180 distinct values, never zero.
pub field_08: u16,
/// `+0x0a`: 61 distinct values, never zero.
pub field_0a: u16,
/// `+0x0c`: 125 distinct values, never zero, clustered at 0x043f to 0x0443
/// (~1,087 to 1,091). **Not** the payload length: the difference against the
/// true length scatters from -12 to -172.
pub field_0c: u16,
/// `+0x0e`: 11 distinct values, zero on 2,162 of 2,177 -- the only field
/// here that is not universal.
pub reserved_0e: u16,
}
impl TieBlockHeader {
/// Parse the block header, bounds-checked against `block`.
pub fn parse(block: &[u8]) -> Result<Self> {
let mut b = Buf::new(block);
Ok(Self {
data_offset: b.u16("tie block data offset")?,
reserved_02: b.u16("tie block reserved")?,
packed_texture: b.u16("tie block packed texture")?,
field_06: b.u16("tie block field_06")?,
field_08: b.u16("tie block field_08")?,
field_0a: b.u16("tie block field_0a")?,
field_0c: b.u16("tie block field_0c")?,
reserved_0e: b.u16("tie block reserved_0e")?,
})
}
/// True when every invariant measured across all 27 levels holds.
///
/// Only the two that are universal: the data offset is a non-zero multiple
/// of 16 and `reserved_02` is zero, both on all 2,177 blocks.
pub fn invariants_hold(&self) -> bool {
self.data_offset % 16 == 0 && self.data_offset != 0 && self.reserved_02 == 0
}
/// The number of 16-byte records in this block's table.
pub fn record_count(&self) -> usize {
self.data_offset as usize / TIE_RECORD_SIZE
}
/// The block's records, as byte slices of [`TIE_RECORD_SIZE`].
///
/// Bounds-checked against `block`, which is the block starting at its
/// header. **The header is the first record**: the table runs from the
/// block start to `data_offset`, so a block with `data_offset == 0x50`
/// holds five records, the first of which is the header itself and whose
/// pointer names where the payload begins. A header whose table runs past
/// the block yields nothing rather than a short slice.
pub fn records<'b>(&self, block: &'b [u8]) -> impl Iterator<Item = &'b [u8]> + 'b {
block
.get(..self.record_count() * TIE_RECORD_SIZE)
.unwrap_or(&[])
.chunks_exact(TIE_RECORD_SIZE)
}
}
/// One 16-byte tie block record.
///
/// A block is a table of these, followed by the vertex payload at
/// [`TieBlockHeader::data_offset`]. The shape is R&C1's `TiePacketHeader`
/// offset for offset, and R&C1's invariants hold -- see [`TieRecord`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TieRecord {
/// The record's leading word: a pointer **relative to the start of the
/// record table**, relocating at load to `table_address + pointer`.
///
/// Confirmed on every block checked in PCSX2's RAM: the word becomes
/// `table + word` with the other twelve bytes untouched.
pub pointer: u32,
/// `+0x04`: AD-GIF (material) blocks this packet references; 1..4 on disc.
pub shader_count: u8,
/// `+0x05`: equals `5 * shader_count` on every record measured.
///
/// **This is not the length of the control region**, despite what the field
/// is usually described as. It runs 5..20 quadwords and the control region is
/// `vert_ofs`, 3..8, so for two or more shaders the value exceeds the region
/// on *every* record -- 9,315 of 15,219 pooled, which is a number describing
/// nothing; partitioned by `shader_count` it is 100% for 2, 3 and 4 and 32%
/// for 1, which is exactly the share whose `vert_ofs` is below 5.
///
/// So the AD-GIF is **not stored in the tie packet**. What this value means
/// is not established; the `5 * shader_count` relation is solid and the
/// "quadwords of ad-gif data" reading of it is not. See
/// `tie_bfc_distance_is_not_the_control_region_length`.
pub bfc_distance: u8,
/// `+0x06`: 4..18 on disc.
pub control_count: u8,
/// `+0x07`: 1..5 on disc, never above `control_count`.
pub control_size: u8,
/// `+0x08`: **quadwords of control data before the vertices**, 3..8 on disc
/// (`3`: 5 records, `4`: 3,364, `5`: 4,571, `6`: 3,469, `7`: 1,632,
/// `8`: 1 of 15,219). Peak at 5.
///
/// **Not a vertex offset.** The name is the one thing about this record that
/// is wrong: the value is the *length* of the leading region, not where the
/// vertices start within some larger array. The vertex data begins at this
/// many quadwords into the packet. Renamed in intent, not in the field name,
/// because the on-disc order is fixed and `vert_ofs` is what every existing
/// test and doc says.
pub vert_ofs: u8,
/// `+0x09`: quadwords of vertex data; 4..60 on disc.
pub vert_size: u8,
/// `+0x0a`: equals `vert_size` on every record measured.
pub color_ofs: u8,
/// `+0x0b`: zero on every record measured.
pub color_count: u8,
/// `+0x0c`: quadword offset of the scissor data — and **exactly
/// `vert_ofs + vert_size`, on 15,219 of 15,219 records across all 27
/// levels**, including every record whose `strip_count` is non-zero.
///
/// It carries no information the other two fields do not. It is the end of
/// the vertex region, stored twice. The universality is itself the useful
/// part: it is the check that showed the packet really does partition as
/// control / vertices / scissor, since a derived quantity only lands on the
/// same value everywhere if those three regions are laid out the way the
/// length formula already assumed.
pub scissor_ofs: u8,
/// `+0x0d`: 1..5 on disc.
pub scissor_size: u8,
/// `+0x0e`: zero on every record measured.
pub strip_count: u8,
/// `+0x0f`: zero on every record measured.
pub strip_vertex_count: u8,
}
impl TieRecord {
/// Parse one 16-byte record.
pub fn parse(bytes: &[u8]) -> Result<TieRecord> {
let mut b = Buf::new(bytes);
Ok(TieRecord {
pointer: b.u32("tie packet data offset")?,
shader_count: b.u8("tie packet shader count")?,
bfc_distance: b.u8("tie packet bfc distance")?,
control_count: b.u8("tie packet control count")?,
control_size: b.u8("tie packet control size")?,
vert_ofs: b.u8("tie packet vertex offset")?,
vert_size: b.u8("tie packet vertex size")?,
color_ofs: b.u8("tie packet colour offset")?,
color_count: b.u8("tie packet colour count")?,
scissor_ofs: b.u8("tie packet scissor offset")?,
scissor_size: b.u8("tie packet scissor size")?,
strip_count: b.u8("tie packet strip count")?,
strip_vertex_count: b.u8("tie packet strip vertex count")?,
})
}
/// The twelve bytes after the pointer, verbatim.
///
/// The comparison against the running game checks these are copied
/// unchanged, so they are worth having as one value rather than a
/// field-by-field walk.
pub fn trailing_bytes(&self) -> [u8; 12] {
[
self.shader_count,
self.bfc_distance,
self.control_count,
self.control_size,
self.vert_ofs,
self.vert_size,
self.color_ofs,
self.color_count,
self.scissor_ofs,
self.scissor_size,
self.strip_count,
self.strip_vertex_count,
]
}
/// True when every invariant that holds on **every** record measured holds.
///
/// `bfc_distance == 5 * shader_count` is R&C1's documented rule for this
/// field and it holds on all 15,219 records of all 2,177 blocks on all 27
/// levels; that is what identifies the record as this struct rather than a
/// resemblance. The three orderings are also universal.
///
/// Relations that hold on most records but not all are deliberately *not*
/// here -- a check that fails on a real record is worse than no check --
/// and the disc test counts them instead. `color_ofs == vert_size` is the
/// largest of those: 12,640 of 15,219.
pub fn invariants_hold(&self) -> bool {
self.bfc_distance == 5 * self.shader_count
&& self.vert_ofs <= self.vert_size
&& self.scissor_size <= self.scissor_ofs
&& self.control_size <= self.control_count
// Universal on 15,219 of 15,219 records, so unlike the first four --
// which are the large-partition rule plus three smaller ones -- this
// one can be a hard check. See `scissor_ofs`.
&& u16::from(self.scissor_ofs) == u16::from(self.vert_ofs) + u16::from(self.vert_size)
}
/// Quadwords of packet data: `vert_ofs + vert_size + scissor_size`.
///
/// Holds on **12,927 of 12,927** record pairs whose `strip_count` is zero,
/// and on **0 of 115** whose it is not -- the partition is exact, so the
/// formula is safe to use when `strip_count == 0` and knows nothing
/// otherwise. R&C1's `strip_count` is the strip count, and strips are
/// variable-length data, so a non-zero count most likely adds a record
/// count's worth of per-strip bytes this cannot see.
pub fn packet_quadwords(&self) -> usize {
self.vert_ofs as usize + self.vert_size as usize + self.scissor_size as usize
}
/// Whether [`Self::packet_quadwords`] is known to describe this record.
///
/// `strip_count == 0` on every record the formula has been verified on;
/// the 115 records where it is not decode to a fourth term of 0..19
/// quadwords that is not `strip_count` and is not established.
pub fn packet_quadwords_are_known(&self) -> bool {
self.strip_count == 0
}
}
/// Byte that terminates a tie LOD block's payload.
pub const TIE_BLOCK_TERMINATOR: u8 = 0xf6;
/// Bytes in one tie block record.
pub const TIE_RECORD_SIZE: usize = 0x10;
/// A tie LOD block: its header, its payload, and the zero padding that follows
/// the terminator.
///
/// **The blocks are self-delimiting.** Each ends with [`TIE_BLOCK_TERMINATOR`]
/// followed by zeros, on **2,177 of 2,177** blocks across all 27 levels. That is
/// what makes them parseable without trusting `packets[1]` to bound the block,
/// which it only does on 461 of 1,864 classes.
///
/// The terminator is not a coincidence of byte frequency: `0xf6` occurs at 0.83x
/// the uniform rate in payloads, i.e. slightly *less* often than chance, while
/// the last non-zero byte is `0xf6` on 909 of 922 blocks in the first subset
/// tested against **17 of 922** for the same blocks cut at a random point.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TieBlock<'a> {
/// The 16-byte header.
pub header: TieBlockHeader,
/// Bytes from `0x10` up to and including the terminator.
///
/// **This is short.** The `0xf6` the walk stops at lies inside the last
/// packet's data, an even 2 to 16 bytes before that packet's computed end,
/// so the block's real extent runs past it. `tie_block_payload_divides_
/// into_contiguous_packets` measures this; finding a terminator packet
/// data cannot imitate is its own task.
pub payload: &'a [u8],
/// Zero bytes between the terminator and the next block.
pub padding: &'a [u8],
}
/// Walk `region` from `offset` to the first `0xf6` that is followed by a zero,
/// and return the block there.
///
/// Returns `None` if no terminator is found before the end of the region.
pub fn parse_tie_block(region: &[u8], offset: usize) -> Option<TieBlock<'_>> {
if offset.checked_add(TIE_BLOCK_HEADER_SIZE)? > region.len() {
return None;
}
let header = TieBlockHeader::parse(&region[offset..]).ok()?;
let start = offset + TIE_BLOCK_HEADER_SIZE;
let mut i = start;
while i + 1 < region.len() {
if region[i] == TIE_BLOCK_TERMINATOR && region[i + 1] == 0 {
let after = i + 1;
let mut e = after;
while e < region.len() && region[e] == 0 {
e += 1;
}
return Some(TieBlock {
header,
payload: &region[start..after],
padding: &region[after..e],
});
}
i += 1;
}
None
}
/// A tie class resolved through both of its indirections.
///
/// The chain is two steps and both are easy to get wrong, so it is worth
/// stating plainly:
///
/// 1. The class **table** lives in the level's `core_index.bin`, not in
/// `core_data.bin`. On level 0 the tables chain as moby at `192`,
/// 238 x 32 bytes later tie at `7808`, 74 x 32 later shrub at `10176`,
/// which closes. Reading the same table out of `core_data.bin` yields
/// plausible-looking garbage rather than an error.
/// 2. [`ClassEntry::offset_in_asset_wad`] addresses `core_data.bin`, despite the
/// field's name and despite `core_data.wad` existing alongside it.
///
/// With both handled, all **1,864** tie classes on the disc resolve to a header
/// whose `o_class` matches the class entry's own.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ResolvedTieClass {
/// The index entry this came from.
pub entry: ClassEntry,
/// Its index within the level's tie table.
pub index: usize,
/// The class header, at `offset_in_asset_wad` in `core_data`.
pub header: TieClassHeader,
}
/// Resolve one tie class. `class_index` counts from zero.
///
/// `core_index` is the level's `core_index.bin`; `core_data` is the
/// decompressed `core_data.bin`. Both are bounds-checked.
pub fn resolve_tie_class(
core_index: &[u8],
core_data: &[u8],
classes: crate::level_core::ArrayRange,
class_index: usize,
) -> Result<ResolvedTieClass> {
const ENTRY: usize = 32;
let start = classes.offset as usize + class_index * ENTRY;
if classes.offset < 0 || start.checked_add(ENTRY).is_none() || start + ENTRY > core_index.len()
{
return Err(Error {
offset: start,
context: "tie class table entry",
kind: ErrorKind::OutOfBounds {
needed: ENTRY,
available: core_index.len().saturating_sub(start),
},
});
}
if class_index >= classes.count as usize {
return Err(Error {
offset: start,
context: "tie class index",
kind: ErrorKind::Invalid("class index past the table count"),
});
}
let entry = ClassEntry::parse(&core_index[start..start + ENTRY])?;
let base = entry.offset_in_asset_wad as usize;
if base < TIE_CLASS_HEADER_MATCHED || base + TIE_CLASS_HEADER_MATCHED > core_data.len() {
return Err(Error {
offset: base,
context: "tie class header",
kind: ErrorKind::OutOfBounds {
needed: TIE_CLASS_HEADER_MATCHED,
available: core_data.len().saturating_sub(base),
},
});
}
let header = TieClassHeader::parse(&core_data[base..base + TIE_CLASS_HEADER_MATCHED])?;
Ok(ResolvedTieClass {
entry,
index: class_index,
header,
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -133,4 +581,68 @@ mod tests {
assert_eq!(h.lod_for_depth(30.0), Some(2)); assert_eq!(h.lod_for_depth(30.0), Some(2));
assert_eq!(h.lod_for_depth(50.0), None); assert_eq!(h.lod_for_depth(50.0), None);
} }
/// A block whose data offset names a four-record table, the first of
/// which is the header itself.
fn block() -> Vec<u8> {
let mut v = Vec::new();
// data_offset 0x40 -- four records -- reserved 0, packed, then the
// remaining fields.
v.extend_from_slice(&0x40u16.to_le_bytes());
v.extend_from_slice(&0u16.to_le_bytes());
v.extend_from_slice(&0x0501u16.to_le_bytes());
v.extend_from_slice(&0x1234u16.to_le_bytes());
v.extend_from_slice(&0x5678u16.to_le_bytes());
v.extend_from_slice(&0x9abcu16.to_le_bytes());
v.extend_from_slice(&0x0442u16.to_le_bytes());
v.extend_from_slice(&0u16.to_le_bytes());
assert_eq!(v.len(), TIE_BLOCK_HEADER_SIZE);
// Three packet headers, each a data pointer and R&C1's byte fields.
for k in 0..3u32 {
v.extend_from_slice(&(0x40 + k * 0x20).to_le_bytes());
v.extend_from_slice(&[
1, 5, // shader_count, bfc_distance = 5 * shader_count
7, 2, // control_count, control_size
4, 0x20, // vert_ofs, vert_size
0x20, 0, // color_ofs (= vert_size), color_count
0x24, 0x20, // scissor_ofs (= vert_ofs + vert_size), scissor_size
0, 0, // strip_count, strip_vertex_count
]);
}
v.extend_from_slice(&[0xa5u8; 8]);
v
}
#[test]
fn the_data_offset_bounds_the_record_table() {
let b = block();
let h = TieBlockHeader::parse(&b).unwrap();
assert!(h.invariants_hold());
assert_eq!(h.record_count(), 4);
let records: Vec<_> = h.records(&b).collect();
assert_eq!(records.len(), 4);
// The first record is the header itself, pointing at the payload.
let first = TieRecord::parse(records[0]).unwrap();
assert_eq!(first.pointer, 0x40);
// The fields follow R&C1's packet-header byte layout.
let second = TieRecord::parse(records[1]).unwrap();
assert_eq!(second.shader_count, 1);
assert_eq!(second.bfc_distance, 5);
// scissor_ofs is vert_ofs + vert_size: 4 + 0x20 = 0x24.
assert_eq!(second.scissor_ofs, 0x24);
assert!(second.invariants_hold());
}
#[test]
fn a_table_running_past_the_block_yields_nothing() {
let mut b = block();
// Claim a table far longer than the block.
b[0..2].copy_from_slice(&0x400u16.to_le_bytes());
let h = TieBlockHeader::parse(&b).unwrap();
assert_eq!(h.record_count(), 0x40);
assert_eq!(h.records(&b).count(), 0, "must not slice past the block");
// A 0x400 offset is still a multiple of 16, so the invariant holds even
// though the table cannot exist.
assert!(h.invariants_hold());
}
} }
File diff suppressed because it is too large Load Diff
+2071 -7
View File
File diff suppressed because it is too large Load Diff
+948
View File
@@ -0,0 +1,948 @@
# PCSX2 verification (`gc-trace`)
Status as of **2026-10-11**. The tfrag lighting magnitudes are verified; this
is the file that records how, so the next comparison starts from the
harness instead of from scratch.
Tool: `tools/trace` (`gc-trace`). It never ships, no shipped crate depends on
it, and it reads the game's own memory out of a running emulator, a
savestate, or a raw dump.
## What exists now
```text
gc-trace pine-info # protocol handshake
gc-trace dump-ee <src> [--out FILE] # 32 MiB EE RAM image
gc-trace find <src> --bytes HEX # content search (the locators use it too)
gc-trace read <src> --addr HEX --len N
gc-trace info <src> # savestate metadata
gc-trace compare-tfrag-light <unpacked-dir> [--level N] <src>
gc-trace verify-morph <unpacked-dir> [--level N] <src>
```
`<src>` is `--pine [slot]`, `--state FILE` / `--state latest`, or `--ee FILE`.
It may sit anywhere on the command line.
The comparison:
1. **Locates the tfrags block in RAM by content** -- the key is tfrag 0's
bounding sphere, sixteen bytes unique in 32 MiB. A candidate is accepted
only when the block header matches, **every** header matches the disc on
the invariant bytes (everything but the relocated `data` pointer and the
runtime occlusion index), and every `data` pointer is the relocated form
`table + disc offset`. Anything less is a plausible wrong read.
2. **Locates the light bank the same way**, by its exact section bytes.
3. **Runs the pass** over the same disc data with
`gc_engine::tfrag_light::light_tfrag`.
4. **Diffs per channel, per vertex**, bucketed by vertex class, with the first
few mismatching vertices' *terms* (`gc_engine::tfrag_light::
light_vertex_terms`) printed so a mismatch can be attributed.
## verify-morph: the morph is not in the tfrag data block (2026-10-14)
`verify-morph` answers a different question than `compare-tfrag-light`. It
walks every patch's packet lists **twice** -- once over the disc copy, once over
the RAM copy -- and compares the parsed positions, then diffs the raw bytes of
each patch and classifies every differing byte against the walk's own unpack
ranges.
The result on level 0, live over PINE: 40,600 positions, all identical. The
game does rewrite the blocks -- 166,465 bytes -- and not one lands inside a
position unpack. So the LOD blend is not written back into the geometry.
That sharpens the standing note in the roadmap. "Reading a weight back out of
RAM does not work" was read as the positions being regenerated. They are not
regenerated in *value*: the loader rebuilds the packet framing (116,981
command-word bytes, 70% of the differences) and some other unpack payloads
(49,484), and leaves the vertices alone. The blend happens at draw time in the
VU stream. Nothing was there to read, which is a different finding from there
being a correspondence problem.
## Result: levels 0 and 1, verified 2026-10-11
Level 0 (Aranos), three inputs -- the live emulator over PINE and two PCSX2
savestates from that level, all agreeing byte for byte:
```text
level0: tfrags block at 0x848000, table at 0x848040, 833 tfrags
headers matching the disc on invariant bytes: 833/833
data pointers relocated as table + disc offset: 833/833
light bank in RAM at 0x50ab20, byte-identical to the disc's
vertices compared: 40600 (162400 colour bytes)
equal: 162400 (100.00%)
runtime-set: 3130 vertices, 3130 fully equal
single-set: 37470 vertices, 37470 fully equal
```
Level 1, from savestate slot 2 and cross-checked against the live emulator:
```text
level1: tfrags block at 0x84c000, table at 0x84c040, 401 tfrags
headers matching the disc on invariant bytes: 401/401
data pointers relocated as table + disc offset: 401/401
light bank in RAM at 0x50f4a0, byte-identical to the disc's
vertices compared: 26470 (105880 colour bytes)
equal: 105880 (100.00%)
runtime-set: 129 vertices, 129 fully equal
single-set: 26341 vertices, 26341 fully equal
```
Level 1 matters more than one more tally: it is a different planet with a
different geometry extent, a different light bank and a different
runtime-set population, and it agrees on every byte. The two fixes below are
therefore not level-0 coincidences.
What this establishes:
- **The tfrag locator and the pointer model are right.** 833 of 833 headers
match on invariant bytes and 833 of 833 `data` pointers are relocated
exactly `table + disc offset`, so the block found in RAM is the level's and
the geometry read out of it is the geometry the game drew.
- **The light bank framing is right.** The section found in RAM is
byte-identical to the disc's: offset at `+0x04`, an `i32` set count, twelve
bytes of pad, then 0x40-byte sets. The previous reading -- offset at
`+0x00`, count at `+0x04` -- was wrong, and this is what settled it.
- **The lighting magnitudes match exactly**: every one of the 162,400 colour
bytes the game wrote equals ours. This was the open item in
`docs/roadmap.md` and `docs/plan/level-assets.md` ("Lighting magnitudes are
unverified: they need comparison against reference frames").
## Two bugs the comparison found
Both produced plausible output and neither was visible in a screenshot.
**1. The normal's z was not negated.** `vertex_normal_bits` negated x and y
and left z positive, while the renderer's `NormalTable::normal` negates all
three. The result: `dA = -0.707` where the game had `+0.707`, so
`max(dA, dA·wA)` picked the wrong branch and every vertex lit near its base
colour -- dark, but not *wrong* dark. Level 0 agreed on 95.88% of colour
bytes with the bug and 100.00% without it.
**2. A record with no light set drew transparent black.** `select 0x000f`
names set 15; the level's directional bank has 5 sets, so the pass had no set
to light with and yielded `[0,0,0,0]`. The game writes the record's **base
colour**: 3,130 of level 0's 40,600 vertices do this. The vertex is unlit, not
invisible.
## Finding the next thing
- **Compare another level.** All three inputs available are level 0; another
level needs a savestate taken in it (F1 in-game, or `gc-trace
pine-savestate SLOT`). The harness takes `--level N` already.
- **Point lights.** The runtime-set cluster is set 15 and above; those slots
are filled by gameplay, so they cannot be verified from a level-load image.
Comparing them needs a frame with point lights in it.
- **Blended sets.** Both levels' vertices are all single-set (`select &
0xff00 == 0`), so the blend path -- the only path `rsqrt` touches -- is
untested against the game.
- **Distil, then test.** A comparison is not a regression. When a fact from
here becomes load-bearing, distil it into a numbers-only fixture under
`tools/trace/tests/` and test against that; no test may read a savestate, a
dump or a live emulator.
## The test setup
PCSX2 is installed and working on this machine, so every comparison above can
be re-run rather than being a one-off observation.
| What | Where |
|---|---|
| Emulator | `~/Desktop/pcsx2/pcsx2-v2.8.2-linux-appimage-x64-Qt.AppImage` — the directory name is **lowercase** on disk |
| Game disc | `~/Desktop/pcsx2/games/Ratchet & Clank - Going Commando.iso`; verified target `SCUS_972.68` v1.01 NTSC ([`docs/disc-identification.md`](../disc-identification.md)) |
| Emulator config | `~/.config/PCSX2/` (`sstates`, `bios`, `memcards`, `inis`, `logs`) |
| BIOS | extracted into `~/.config/PCSX2/bios/` |
| Savestates | `~/.config/PCSX2/sstates/SCUS-97268 (38996035).NN.p2s`, `NN` = slot |
| Unpacked disc | `~/Documents/gc-extracted/`, all 27 levels |
| PINE socket | `$XDG_RUNTIME_DIR/pcsx2.sock` (`/run/user/1000/pcsx2.sock`), slot 28011 |
The emulator boots the disc with `-fastboot`; a level takes about a minute
from a cold start to reach. To capture a state manually, press F1 in-game (it
lands in the slot's `.p2s`); to capture from a script, `gc-trace
pine-savestate SLOT` asks the emulator to write one.
The disc integration tests need the image, not the emulator:
```sh
GC_DISC="$HOME/Desktop/pcsx2/games/Ratchet & Clank - Going Commando.iso" \
cargo test --workspace
```
## Running it
```sh
cargo run -q -p gc-trace -- pine-info
cargo run -q -p gc-trace -- compare-tfrag-light ~/Documents/gc-extracted --level 0 --pine
cargo run -q -p gc-trace -- compare-tfrag-light ~/Documents/gc-extracted --level 1 \
--state ~/.config/PCSX2/sstates/SCUS-97268\ \(38996035\).02.p2s
# the tie record relocation, one class and LOD at a time
cargo run -q -p gc-trace -- verify-tie-records ~/Documents/gc-extracted 0 0 1 \
--state ~/.config/PCSX2/sstates/SCUS-97268\ \(38996035\).01.p2s
cargo run -q -p gc-trace -- compare-tfrag-light ~/Documents/gc-extracted --level 0 \
--state ~/.config/PCSX2/sstates/<name>.p2s
```
Exit status is 0 when every byte matches, 2 on mismatches, 1 on error, so it
can be run as a check. PINE must be enabled in the emulator (Settings →
Advanced); the socket is `$XDG_RUNTIME_DIR/pcsx2.sock` on Linux.
**Nothing here redistributes game data.** The disc, the BIOS and the
savestates are the user's own; the repository holds the tool and the numbers.
## Shrub packet headers are rewritten in RAM: the loader relocates them
Answering the "what is the tag byte" question needed an anchor, and searching
RAM from scratch was the wrong way to get one -- it finds coincidental matches
(`04 04 00 01` matches unrelated data at `0x1ac988`) and no way to tell them
apart. The savestates are the anchor: slot `.01` is level 0, and searching one
gives candidates that can be *checked* rather than guessed at.
**Range measured:** savestate slot 1,
`~/.config/PCSX2/sstates/SCUS-97268 (38996035).01.p2s`, records at `0x22a6b8`
onward, read with `gc-trace read --state`.
**Result: 40+ records beginning `04 04 00 01 00 00 00 00` at `0x30` spacing**,
which is the packet framing identified on disc, and the loader has rewritten
the second half of each header:
disc: 04 04 00 01 | 00 00 00 00 | 00 00 00 05 | 00 80 0e 6c
RAM: 04 04 00 01 | 00 00 00 00 | 13 00 00 30 | 70 6a 93 00
Bytes 0-7 are unchanged. Bytes 8-11 and 12-15 are not:
- on disc, bytes 12-13 are the constant `00 80` and byte 15 the constant `6c`;
- in RAM, bytes 12-15 hold what is plainly a pointer -- `0x00936a70`,
`0x009376f0`, `0x00937850`, `0x00939c50` in consecutive records, all inside
EE RAM, and ascending across the table exactly as a walk of consecutive
objects would be.
Byte 11 becomes `0x30` in every record seen, against a constant `0x05` on
disc, and bytes 8-9 hold a varying small value (`0x13`, `0x16`, `0x24`).
**What this means.** The packet table at `+0x40` is the thing the loader walks:
it holds span-relative offsets on disc and absolute RAM addresses after
loading, which is what the previous pass found and what these 40+ records show
directly. Any header field read past byte 8 on disc is describing something the
loader replaces.
**Consequence for the tag bytes.** The values at record bytes 7 and 15 measured
on disc are *not* stable identifiers -- at least byte 15 sits in a field the
loader overwrites with a pointer. The four-value and two-value vocabularies are
real (0x30, 0x6d, 0x80 and 0 appear against 1636 records of otherwise-uniform
bytes), but byte 15 being a pointer in RAM means its disc values are being read
as data by something, not stored as a tag.
**Not settled:** whether byte 7's vocabulary is also rewritten, and whether
`0x6d` marks a payload that the loader expands. That needs the same records
compared one by one against their disc spans, which is the next step and is
now cheap because the anchor exists.
## Following the pointers: packet payloads differ by type
The header pointers found above are live, so they can be dereferenced. Two
consecutive records from the level 0 table at `0x22a6b8` were followed.
**Range measured:** savestate slot 1, reading `0x00936a70` (from the record at
`0x22a6b8`) and `0x009376f0` (from the record at `0x22a6e8`), 64 bytes each.
**Result: the two payloads have nothing in common.**
`0x00936a70` -- fixed-point vertex data:
8d 8c 80 80 89 87 7c 80 8d 8c 80 80 89 87 7c 80
4e 4e 4c 80 4e 4e 4c 80 4e 4e 4c 80 4e 4e 4c 80 (repeating)
Every high byte is `0x80` or `0x7c`, and the pattern repeats on a 8-byte
boundary. Read as `s16` these are large negatives in a narrow band around
-32500. That is consistent with Q16.15 fixed-point rather than `f32`, and with
a stride of 4 `s16` per vertex.
`0x009376f0` -- a tagged sub-list, not vertex data:
00 00 00 30 cd 00 00 00 cd 00 00 00 cd 00 00 00
cd 00 00 00 01 00 00 05 22 c1 01 6e 00 00 00 00
Structurally different: small integers, a repeating `0xcd`, and `0x30` tags in
the high byte. Nothing here is position data.
**What this supports.** Two records 0x30 apart in the same table hold payloads
of entirely different kinds. That is direct evidence for the type reading of
the header: the table is a tagged collection where the tag selects what the
payload is, not a uniform array of one thing.
It also rules out the alternative I could not previously exclude -- that the
16-byte tiling was a single uniform record format and the varying bytes were
all fields of it. Two adjacent records with unrelated payloads is not that.
**Still open.** The mapping from tag value to payload kind. The `0x30` that
appears in record bytes 8-11 in RAM also appears at the head of the second
payload, which is suggestive but is one coincidence so far and should not be
written up as a link until it is seen in more than one record.
## The tag values map onto three payload kinds
Following the pointers out of the level 0 table, all nine that carry the `0x30`
tag at byte 11 and an EE pointer at 12..15 were dereferenced and read 32 bytes
each. They fall into exactly three classes, three payloads each, with no
overlap.
**A -- Q16.15 fixed-point vertex data** (3 of 9)
00936a70 8d 8c 80 80 89 87 7c 80 8d 8c 80 80 89 87 7c 80
4e 4e 4c 80 4e 4e 4c 80 4e 4e 4c 80 4e 4e 4c 80
0093cde0 8a 8a 7f 80 92 90 84 80 8a 8a 7f 80 92 90 84 80
4e 4e 4c 80 4e 4e 4c 80 4e 4e 4c 80 4e 4e 4c 80
009cf4f0 6e 6f 68 80 6e 6f 68 80 6e 6f 68 80 6e 6f 68 80
54 54 51 80 54 54 51 80 54 54 51 80 54 54 51 80
Every high byte is `0x80` or `0x7c`, each payload repeats on an 8-byte
boundary, and the values differ between payloads while the `4e 4e 4c 80` tail
is shared by two of them.
**B -- a `0x30` sub-list with a repeated count** (3 of 9)
009376f0 00 00 00 30 cd 00 00 00 cd 00 00 00 cd 00 00 00
cd 00 00 00 01 00 00 05 22 c1 01 6e 00 00 00 00
00998880 00 00 00 30 c0 00 00 00 c0 00 00 00 c0 00 00 00
c0 00 00 00 01 00 00 05 11 c1 01 6e 00 00 00 00
009d2da0 00 00 00 30 c8 00 00 00 c8 00 00 00 c8 00 00 00
c8 00 00 00 01 00 00 05 1d c1 01 6e 00 00 00 00
Structurally identical apart from the repeated word: `0xcd`, `0xc0`, `0xc8`.
All three carry `01 00 00 05` at offset 16 and a `.. c1 01 6e` at 20.
**C -- an index/attribute list** (3 of 9)
00939700 00 c0 05 6d 3e 00 00 00 01 00 00 00 12 00 00 00
18 00 c0 00 fe 00 fe 00 ff 00 ff 00 11 01 17 01
009c1540 00 c0 05 6d 36 00 00 00 01 00 00 00 08 00 00 00
13 00 93 00 c9 00 c9 00 ca 00 ca 00 d2 00 d5 00
Both open `00 c0 05 6d`, then a count, then a run of small ascending `u16`.
**The mapping.** The tag vocabulary measured on disc -- `0x00`, `0x30`,
`0x6d`, `0x80` at record byte 7 -- is not decorative:
| tag | payload |
|---|---|
| `0x30` | class B, the `00 00 00 30` sub-list |
| `0x6d` | class C, the `00 c0 05 6d` attribute list |
| `0x00` | class A, the fixed-point vertex data |
`0x30` heads the class B payloads and `0x6d` heads the class C payloads, which
is the link flagged as a single coincidence two entries back. It is now seen in
three payloads each, so it is a rule rather than an accident.
`0x80` did not appear among these nine. Its meaning is still open -- it is the
fourth value in the vocabulary and there is no sample of it here.
**A correction.** The record spacing in this table is not uniformly `0x30`. The
`find` output shows `0x30`, `0x10`, `0x30`, `0x30` between consecutive packet
headers, and only 9 of the 5,632 bytes scanned carry both the `0x30` tag and an
EE pointer. The earlier "records at 0x30 spacing" was inferred from a truncated
list of hits and does not hold.
## Correction: the record tag does not select the payload
The previous entry mapped the disc tag vocabulary onto the three payload
classes. A wider scan says that is wrong.
**Range measured:** savestate slot 1, RAM `0x22a000..0x24037f` -- 91,008 bytes,
20 times the span scanned before.
**Result: 19 packet headers, and all 19 carry byte 11 = `0x30`.** There are no
`0x6d` or `0x80` tags anywhere in this range. Reading six of their payloads:
rec 0022a6b8 tag 30 -> 00936a70 8d 8c 80 80 89 87 7c 80 ... (fixed point)
rec 0022a6e8 tag 30 -> 009376f0 00 00 00 30 cd 00 00 00 ... (sub-list)
rec 0022a6f8 tag 30 -> 00937850 92 90 84 80 94 94 86 80 ... (fixed point)
rec 0022a728 tag 30 -> 00939c50 00 00 00 30 c0 00 00 00 ... (sub-list)
rec 0022a738 tag 30 -> 00939e90 4c 4c 4b 80 4c 4c 4b 80 ... (fixed point)
rec 0022a768 tag 30 -> 0093cc70 00 00 00 30 78 00 00 00 ... (sub-list)
Records of identical tag alternate between two payload kinds. **The tag does
not select the payload.**
**What went wrong.** The previous entry took the `0x30` and `0x6d` that *head
the payloads* to be the disc-side tag vocabulary, and matched them to the
payload classes by that correspondence. The disc vocabulary measured at record
byte 7 -- `0x00`, `0x30`, `0x6d`, `0x80` -- and the RAM record byte 11 were
treated as the same field. They are not: the payload magics are a property of
the payload, and record byte 11 is a different thing that happens to be
constantly `0x30` here.
Three payloads each made the coincidence look like a rule. It was a
correspondence between two vocabularies that overlap in two values.
**What is actually established about the RAM header**, per position:
| byte | value |
|---|---|
| 0-3 | `04 04 00 01`, constant |
| 4-7 | zero, constant |
| 8 | a **count**, `0x0b` to `0x29` across the 19 records |
| 9-10 | zero, constant |
| 11 | `0x30`, constant on all 19 in this range |
| 12-15 | an EE RAM pointer |
Byte 8 is the finding that survives. It is a small count that varies per
record, and it is the one field here that is doing arithmetic rather than
tagging.
**Still open:** what `0x30` at byte 11 means, what discriminates the payload
kinds given that the record tag does not, and the meaning of `0x80`, which has
no sample in 91 KB of RAM.
## The table interleaves two record magics
Scanning the same 91 KB for other four-byte record magics beside `04 04 00 01`
turns up a second one at the same density: **`02 01 00 01`, 10 occurrences**
against `04 04 00 01`'s 19.
**Range measured:** savestate slot 1, RAM `0x22a000..0x24037f`.
**Result: the two magics interleave.** Every `02 01 00 01` sits between two
`04 04 00 01` records:
0x22a6b8 04 04 00 01 13 00 00 30 70 6a 93 00
0x22a6c8 02 01 00 01 19 c0 4c 6e 00 00 10 00 00 00 00 00
0x22a6e8 04 04 00 01 16 00 00 30 f0 76 93 00
0x22a6f8 04 04 00 01 16 00 00 30 50 78 93 00
0x22a708 02 01 00 01 1e c0 58 6e 00 00 10 00 00 00 00 00
0x22a728 04 04 00 01 24 00 00 30 50 9c 93 00
0x22a738 04 04 00 01 16 00 00 30 50 9e 93 00
0x22a748 02 01 00 01 19 c0 54 6e 00 00 10 00 00 00 00 00
The `02 01 00 01` records are 16 bytes like the others but carry **no pointer
** -- bytes 12-15 are zero in all ten. Their fields are a count at 4-5
(`0x14`, `0x19`, `0x1e`), a constant `0xc0` at 6, a varying byte at 7
(`0x30`, `0x4c`, `0x54`, `0x58`), `0x6e` at 8-9, and `10 00` at 10-11.
**The `0x6e` link.** These records carry `0x6e` at bytes 8-9, and the sub-list
payloads found earlier carry `.. c1 01 6e` at offset 20-23. That is a second
correspondence between this record type and the sub-list payloads, independent
of the one retracted in the previous entry -- and this one is a shared *value*
rather than a shared *position*.
**What this does not settle.** It still does not say what discriminates the two
payload kinds among the `04 04 00 01` records, because those all carry the same
magic, the same `0x30` at byte 11 and differ only in the count at byte 8. The
`02 01 00 01` records sit between them rather than replacing them, so they are
probably a companion structure, not the missing discriminator.
Still open: what distinguishes a fixed-point payload from a sub-list payload
when the records carrying them look identical in bytes 0-3 and 11. The most
likely place to look is the count at byte 8 against the payload's own length.
## The count at byte 8 sizes the sub-list payload: exactly 16 bytes per record
The previous entry left one test: compare the count at byte 8 against the
payload's own extent. Pointer spacing gives the extent, since the packets are
laid out in ascending order in EE RAM.
**Range measured:** savestate slot 1, the 17 packets in RAM
`0x22a000..0x24037f`, each with its record address, byte-8 count and pointer,
sorted by pointer so the next packet is the next object in memory.
**Result: eight packets sit exactly `count * 16` bytes after their predecessor
-- an integer ratio of 16.00 in every one, with no rounding.**
record count ptr gap gap/count
0x22a6e8 22 009376f0 352 16.00
0x22a728 36 00939c50 576 16.00
0x22a768 23 0093cc70 368 16.00
0x22a7a8 36 00998880 576 16.00
0x22a7e8 25 009c19c0 400 16.00
0x22a828 24 009cf370 384 16.00
0x22a868 41 009d2da0 656 16.00
0x22a8a8 21 009f5aa0 336 16.00
Eight exact hits at a ratio that has to be an integer is not sampling luck. The
count at byte 8 is the **record count**, and these payloads are 16 bytes per
record.
**And these are exactly the sub-list payloads** -- `0x009376f0`, `0x00939c50`,
`0x0093cc70`, `0x00998880`, `0x009d2da0` were all read directly in earlier
passes and all open `00 00 00 30` followed by a repeated word.
**What the other nine are.** Their ratios are 168, 419, 559, 31288, 7985,
3458, 764, 6452 -- no shared factor, and far too large to be a record count
times anything. So either their payload is not followed immediately by the next
packet in the pointer order (other data intervenes), or they are not 16 bytes
per record. Both readings are open; what is ruled out is that they follow the
same rule.
**This resolves byte 8.** It was measured as "a count, 0x0b to 0x29" two entries
ago and is now shown to size a payload exactly, in eight independent cases,
within a single level. Whether it also sizes the fixed-point payloads is the
open half.
Still open: why the fixed-point packets do not follow the same rule, and what
the 16 bytes per sub-list record are -- the repeated word (`0xcd`, `0xc0`,
`0xc8`, `0x78`) differs per packet, so the records are not identical.
## Inside a sub-list payload: tagged blocks and a byte permutation table
With the extent known (`count * 16`), a whole sub-list payload can be read.
This is the packet at `0x009376f0`, count 22, 352 bytes, in full.
**Range measured:** savestate slot 1, `0x009376f0`..`0x0093784f`, 352 bytes.
**Result: the payload is a sequence of blocks, each opening `00 00 00 30`,**
carrying a repeating word and then a short trailer:
00 00 00 30 | cd 00 00 00 cd 00 00 00 cd 00 00 00 cd 00 00 00
01 00 00 05 | 22 c1 01 6e | 00 00 00 00
00 00 00 30 | 00 00 00 45 00 00 00 45 1d 00 00 00 1d 00 00 00 1f
81 01 6d 00 | 00 00 00 00 | 00 00 a4 00
00 00 00 30 | bc c0 03 00 23 17 03 00 e5 dd 00 00 00 00 00 00
02 01 00 01 | c1 80 01 69 | 00 00 00 00
The repeating word differs per block -- `0xcd`, `0x45`, `0x03` -- and the
trailer's second word varies in its high bytes: `22 c1 01 6e`, `81 01 6d 00`,
`c1 80 01 69`. Every block seen ends `... 6e` or `69` or `6d` at byte 23, which
is the same `6e` the `02 01 00 01` records carried at bytes 8-9.
**The payload contains a byte permutation table.** At `0x009377c4`:
00 01 02 03 3e 3f 53 54 40 41 42 43 4e 4f 50 51 04 05 06 07 08 ...
This is not a position or vertex list -- it is a run of ascending byte values
with a handful (`3e 3f`, `53 54`, `40 41 42 43`, `4e 4f 50 51`) lifted out of
order ahead of the sequential run. Read as a table, it maps each of the 0..0x39
symbols to a slot. That is the shape of a **code-length or symbol-ordering
table**, and it is the first thing in this format that is self-evidently a table
rather than data to be interpreted.
**What this does not say.** Whether the table is a Huffman-style code, an index
reorder for the vertex data that follows, or a palette permutation. The
following bytes continue the sequence to `39` and then `23 c1 01 6e`, after
which the next block opens.
**Corroboration worth noting.** The fixed-point payload `0x00937850` begins
immediately after this one ends at `0x00937850` exactly -- the 22-record
identity from the previous entry made the boundary exact, and the next packet
starts on a payload boundary with nothing in between. The two payload kinds sit
adjacent in RAM, which is consistent with them being two records of the same
blob rather than two unrelated objects.
## The sub-list payload has a fixed five-block shape
All eight sub-list payloads from level 0 were read to their exact extent
(`count * 16`) and compared block by block. The structure is the same in every
one.
**Range measured:** savestate slot 1, the eight packets at `0x009376f0`,
`0x00939c50`, `0x0093cc70`, `0x00998880`, `0x009c19c0`, `0x009cf370`,
`0x009d2da0` and `0x009f5aa0`, counts 21 to 41, each truncated to exactly
`count * 16` bytes.
**Result: every payload contains exactly five blocks tagged `00 00 00 30`, and
the first three are always at offsets 0, 32 and 64.**
| payload | count | blocks at |
|---|---|---|
| `009376f0` | 22 | 0, 32, 64, 184, 304 |
| `00939c50` | 36 | 0, 32, 64, 268, 400 |
| `0093cc70` | 23 | 0, 32, 64, 200, 288 |
| `00998880` | 36 | 0, 32, 64, 272, 404 |
| `009c19c0` | 25 | 0, 32, 64, 196, 300 |
| `009cf370` | 24 | 0, 32, 64, 204, 316 |
| `009d2da0` | 41 | 0, 32, 64, 308, 448 |
| `009f5aa0` | 21 | 0, 32, 64, 176, 280 |
Blocks 4 and 5 move with the payload; the first three do not. Eight payloads,
eight different block-4/5 positions, and 0, 32, 64 in every case.
**Per-block fields, constant across all eight:**
| block | byte 4 | other |
|---|---|---|
| 0 | varies | `0x78`-`0xcd` |
| 1 | **0** in 8/8 | trailer `00 00 00 00` in 8/8 |
| 2 | varies independently | bytes 16-20 `00000000` in 8/8 |
| 3 | **equals block 0 in 8/8** | |
| 4 | **0** in 8/8 | |
The block-0/block-3 equality is exact in all eight:
009376f0 0xcd 0x00 0xbc 0xcd 0x00
00939c50 0xc0 0x00 0xbf 0xc0 0x00
0093cc70 0x78 0x00 0x30 0x78 0x00
00998880 0xc0 0x00 0xc9 0xc0 0x00
009c19c0 0x93 0x00 0x1f 0x93 0x00
009cf370 0xb5 0x00 0x0d 0xb5 0x00
009d2da0 0xc8 0x00 0x65 0xc8 0x00
009f5aa0 0x9b 0x00 0x25 0x9b 0x00
Block 2's word is unrelated to block 0's in every case, so the equality is not
every block reading a shared field.
**Correction to the previous entry.** The "byte permutation table" reported
there does not generalise. It appears in `0x009376f0` at offset 212, but a
scan for it across all eight payloads finds no run of more than a few bytes
outside that one. What looked like a table is better described as one payload's
block content. The `00 00 00 30` block structure, the fixed 0/32/64 offsets and
the block-0/block-3 equality are the findings that survive eight-way
comparison; the permutation table was not among them.
**Not established.** What the five blocks are, why blocks 0 and 3 carry the
same word, and what moves blocks 4 and 5 -- their offsets are not monotone in
the count (`count` 21 gives 280 and `count` 36 gives 400 and 404), so something
inside the earlier blocks sets them.
## Partitioned across two levels: the sub-list shape is universal, the payload kinds are not mixed
The five-block result was measured on level 0 only. Rule 3a asks for the same
measurement per partition, so it was repeated on level 1's savestate before
being written up as a property.
**Range measured:** savestate slot 2 (level 1), RAM `0x22a000..0x28f52f`,
415,024 bytes, then six of its pointer-bearing records read to `count * 16`.
**The six sampled pointers split 3/3 between the two payload kinds:**
| pointer | count | first four bytes | classification |
|---|---|---|---|
| `00949430` | 41 | `00 00 00 30` | sub-list |
| `009496c0` | 13 | `69 b0 bf 80` | fixed-point |
| `00957970` | 36 | `00 00 00 30` | sub-list |
| `00957bb0` | 10 | `58 7e 8d 80` | fixed-point |
| `0095b740` | 12 | `37 49 41 80` | fixed-point |
| `0095d6d0` | 57 | `00 00 00 30` | sub-list |
Taking all six as sub-list gives 3/6 with five blocks. Taking only the three
that actually open `00 00 00 30` gives **3/3**, and the first three blocks land
at 0, 32, 64 in all three:
00949430 41 blocks at 0, 32, 64, 308, 428
00957970 36 blocks at 0, 32, 64, 268, 376
0095d6d0 57 blocks at 0, 32, 64, 400, 572
The three fixed-point payloads have **zero** `00 00 00 30` blocks, as
expected. So the earlier 3/6 was my sampling, not a counterexample: the six
pointers were never all the same kind of object.
**Combined: 11 of 11 sub-list payloads across both levels have exactly five
blocks, the first three at offsets 0, 32, 64.**
**The fixed-point payloads have an exact signature.** Byte 3 of every 4-byte
group is `0x80` in 100% of cases -- 52/52, 40/40 and 48/48 over the first 512
bytes of the three level 1 samples, and the same holds across all nine level 0
payloads. That is a clean one-bit-per-group marker and is what separates the
two payload kinds without needing the record tag, which the previous entries
established does not select them.
**Level 1's record tags are not level 0's.** Scanning the same 64 KB for
`04 04 00 01` headers gives 18, against 19 on level 0, plus 6 `02 01 00 01`
against 10. And byte 11 is no longer constant: `0x30` on 10, `0x00` on 5,
`0x20` on 2 and `0xbc` on 1. Level 0 had `0x30` on all 19.
That corrects an earlier statement. "Byte 11 is `0x30`, constant" was a
level-0 partition result written up as universal. Across both levels it takes
four values. The constancy was never a property of the format.
**Fixed-point payload entropy**, all nine level 0 payloads read 4,096 bytes:
5.51 to 5.89 bits per byte, against 8.0 for uniform. Structured rather than
random, and `mean` of the `s16` reading varies per payload from -82 to -907,
which is consistent with vertex positions relative to a per-payload origin.
**Not established.** What the five blocks are, and how the fixed-point data
decodes to vertices -- the payload entropy is far below random, so it is
compressed or permuted rather than stored plainly, and the permutation table
seen once in `0x009376f0` remains unexplained.
## The loader produces the RAM forms: the disc payload is neither of them
Everything above about five-block sub-lists and fixed-point payloads was read
out of the running game. Checking the same packets on disc settles what the
disc actually stores.
**Range measured:** level 0, all 29 packets named by the `+0x40` tables of the 6
bounded spans, read at their span-relative offsets; 1,636 whole 16-byte records
after the 16-byte header.
**Result: neither RAM form exists on disc.**
| | disc | RAM |
|---|---|---|
| `00 00 00 30` per sub-list packet | **exactly 1** | exactly 5 |
| sub-list blocks at 0, 32, 64 | **0 of 10** | 11 of 11 |
| fixed-point byte 3 == `0x80` | **0 of 19** | 100% |
The 29 packets split 10 sub-list / 19 fixed-point by whether they contain
`00 00 00 30` at all, and not one of the ten has the block structure, and not
one of the nineteen has the fixed-point marker.
**What the disc body actually is.** A compact 16-byte record stream:
+000: 04 04 00 01 | 00 00 00 00 | 00 00 00 05 | 00 80 0e 6c
+010: 02 00 00 00 | 05 00 00 00 | 32 00 00 00 | 0e 00 00 00
+020: 09 00 00 00 | 00 40 3e 30 | 12 04 00 00 | 05 00 00 00
+030: 09 00 00 00 | 00 40 3e 30 | 12 04 00 00 | 21 00 00 00
Small `u32` fields and a repeating `00 40 3e 30 12 04 00 00`. Per position over
all 1,636 records the same two outliers hold as on the earlier disc pass: byte 7
takes 4 distinct values and byte 15 takes 2, against 101 to 153 for every other
position.
**And byte 7 is per-record, not per-packet.** The first record of all 29
packets has byte 7 = `0x00`. So the four-value vocabulary is a field that varies
within a packet body, not a tag on the packet -- which is why grouping packets
by it never separated the two kinds.
**Consequence for Phase 2.** The packet payload is encoded on disc and expanded
at load, so the disc parser can bound it and check its signature but cannot read
it as either RAM form. Decoding it means reversing the expansion, not
re-describing what the loader produced. That is a larger job than the framing
work so far, and it is the honest boundary of what is currently known.
## Collision: a 64-byte record stride in RAM
Searching the savestates for the collision grid dimensions as a `u32` pair --
level 0's `11 x 33` and level 1's `24 x 16` -- puts level 1's at several RAM
addresses and level 0's nowhere. So the grid dimensions are **not held in RAM as
adjacent `u32`s**, and the collision payload is reshaped on load, as the shrub
packet tables are.
A nearby region of level 1's RAM does show structure worth recording. At
`0x12e048c` and `0x12e04cc`, 64 bytes apart:
28 01 00 00 | 92 ff 00 00 | 04 00 a0 41 | ...
296 -110 20.0f
Searching for that shared 12-byte tag finds a **consecutive run at a 64-byte
stride** in slot 2:
0x12df390
0x12df3d0 +0x40
0x12df410 +0x40
plus scattered singles elsewhere. Slot 1 has the tag at four addresses that are
*not* on a 64-byte stride, so the two levels lay it out differently.
**What this is.** A 64-byte record carrying two small signed values and a float
that reads exactly 20.0. That is the shape of a per-cell entry with coordinates
and a radius or half-extent, which is what the collision grid would hold.
**What this is not.** It is not decoded. Three consecutive records is not enough
to establish a stride, the tag matches only a few records rather than a whole
grid, and `20.0` may be coincidence. Level 1's grid is 384 cells; nothing here
has been shown to count to 384.
**Method note.** Savestates are compressed on disk, so a byte scan of the file
finds nothing. All RAM search must go through `gc-trace find --state`, which
decompresses first -- scanning the file directly reports zero hits for a
sequence that demonstrably exists in memory.
### The 64-byte stride was not the collision grid
A 415 KB RAM dump of level 1 around the tag above, scanned locally for 64-byte
strides sharing a header, finds the longest run at **27 records, not 384** --
and those records are:
u32 [1065353216, 0, 0, 0, 0, 1065353216, 0, 0]
f32 [1.0, 0, 0, 0, 0, 1.0, 0, 0]
Identity matrices, **exactly 27 of them** -- one per level. So it is a per-level
transform table, not the collision grid, and the `20.0` reported above was a
coincidence inside a short run.
**The collision lead in this file is disproved, not merely unproven.** Recorded
here so it is not picked up again. Level 1's grid is 384 cells and nothing found
so far counts to it.
## The chunk container: what the loader does to it
This is what RAM was needed for. Nothing about the loader's transformation is
recoverable from the disc.
**Anchor.** A 32-byte probe from inside a chunk record in level 0's `core_data`
was searched in the level-0 savestate. It hits at `0x18538a8`, and the record's
first bytes hit at `0x185387e` -- exactly `chunk_base + record_offset`, with no
rescaling. So the chunk payload is copied **verbatim**; it is not expanded or
repacked the way the collision payload and the shrub packet tables are.
**Result 1 -- the offset table is rewritten to absolute EE addresses.**
Disc, at chunk `0x100b302`:
offsets[0..3] = f000057c f00006b0 f00007e4 f0000914
RAM, in a table at `0x185331c`:
0xf185387c 0xf18539b0 0xf1853ae4 0xf1853c14
The tagged form `0xf0000000 | low24` becomes `0xf1850000 | low24`: the tag is
replaced by the chunk's load base and the low 24 bits are untouched. The
consecutive deltas agree exactly (`0x134, 0x134, 0x130`, which is why the
disc-side `stride` is approximate rather than constant).
**Result 2 -- every record gains a constant 2-byte prefix, and the table points
at the prefix.** RAM at each record:
0x185387c 02 00 00 00 38 d3 4c 38 ...
0x18539b0 02 00 10 00 2f bb 4c 38 ...
0x1853ae4 02 00 20 00 20 8f 4c 38 ...
0x1853c14 02 00 30 00 14 6f 4c 38 ...
Walked all **90** records of the chunk from the RAM dump:
2-byte prefix : 02 00 on 90 of 90
disc index bytes preserved at +2/+3 : 90 of 90
byte at +2, distinct values : 0, 16, 32, ... 240 (16 values)
**Result 3 -- the disc-side index finding is confirmed against live data.** The
bytes at `+2` take exactly the 16 values `ResolvedChunk::index_rule` predicts for
`step = 16, phase = 0`, stepping 0x00, 0x10, 0x20, ... That is the mixed-radix
record index, measured on 2,430 records from the disc and now corroborated on the
running game's own copy of one chunk.
**What this means for the format.** On disc, a chunk is
`[16-byte header][10 zero bytes][tagged offset table][records]`. In RAM it is
`[16-byte header][10 zero bytes][absolute-pointer table][records each prefixed
with 02 00]`. The `02 00` is unexplained -- it may be a record type or an
alignment the loader needs -- but it is constant, so it carries no per-record
information either.
## The collision payload is verbatim in RAM -- and the earlier "not in RAM" was wrong
**This overturns the premise the collision work was blocked on.**
Searching the level-0 savestate for a probe from collision payload `+1024` hits at
`0xac6c00`; a probe from `+64` hits at `0xac6840`. The difference is 0x3C0 = 960,
which is exactly 1024 - 64. So the payload sits at a fixed relative address --
RAM `0xac6800` for the payload whose disc address is `0x27e800` -- and reading
that address back gives byte-for-byte the disc's own bytes:
RAM 0xac6800 0b 00 21 00 b6 08 ad 08 a4 08 6b 08 38 08 63 07
disc 0x27e800 0b 00 21 00 b6 08 ad 08 a4 08 6b 08 38 08 63 07
**Nothing is reshaped -- except the last 36 bytes.** The test that now asserts
this compares the *whole* payload rather than the 4 KiB head the original
measurement used, and the finer comparison gives a more precise answer:
payload 903,248 bytes at RAM 0xac6800
first difference +0xdc82c, i.e. 903,212 bytes in
bytes that differ 6 (0.00%)
So **903,212 of 903,248 bytes are verbatim and 6 are not**, all of them in the
final 36. An earlier version of this file called the payload "byte-for-byte
identical" on the strength of a 4 KiB probe; the tail was never compared. Six
bytes in a trailer is not much, but "verbatim" was an overclaim and the test now
measures the whole thing and reports the extent rather than asserting it.
### The correction
The earlier note here reads: "level 1's `24 x 16` grid dimensions appear in
memory, level 0's `11 x 33` appear nowhere, so disc-side decoding has to work
against the reshaped form."
**Level 0's dimensions are in RAM, at `0xac6800`** -- `0b 00` and `21 00` are the
two leading bytes of the payload, 11 and 33.
The search that missed them looked for the pattern `0b 00 00 00 21 00 00 00`,
i.e. the two dimensions as a pair of `u32`s. They are stored as two `u16`s, so
that pattern never occurs. The negative result was an artifact of the search
pattern, not evidence about the data -- and it was then used as a premise for
the next several rounds of disc-side work.
Two lessons, both already written down and both re-learned:
- **a negative result from a byte search bounds the search, not the format.**
Getting the pattern right is part of the measurement;
- the control for this one is trivial and was not run: read the predicted RAM
address and compare it to the disc, which takes one command and would have
shown the verbatim copy immediately.
## tfrag: two relocated pointer fields per table record, and morphed vertices behind them
Checking the tfrag record framing against RAM. The tfrags block header is
`table_offset 0x40, tfrag_count 833, lod_distance 32.0`, and that 8-byte prefix
hits once in the level-0 savestate: **RAM `0x848000`**. So the block is at a
known place, and disc offset 0 maps to RAM `0x848000`.
**Result 1 -- exactly two fields per record are rewritten.** Comparing all 833
64-byte table records, field by field:
records compared 833
u32 fields differing 1,665 = 833 x 2, and no others
their offsets +0x10 and +0x38, on every record
relocation delta 8,683,584 = 0x848000, on 833 of them
So `+0x10` and `+0x38` are **block-relative pointers**, and the loader adds the
block's RAM base to each. Every other byte of all 833 records is verbatim. The
remaining six fields differ by 0xF80000 and 0x1F00000 rather than 0x848000 --
three records pointing into a different buffer, recorded rather than averaged.
**Correction:** both fields were already named. `+0x10` is
`TfragHeader::data` -- the byte offset of the tfrag's data block, relative to the
table -- and `+0x38` is `TfragHeader::cube_ofs`. The RAM comparison does not name
them; what it adds is that both are **pointers**, relocated by adding the block's
RAM base, rather than plain offsets.
**Result 2 -- behind the pointers, about 8.6% of bytes are rewritten.** Following
record 0's `+0x10` to disc `0xd040` / RAM `0x855040` and comparing 16 KiB:
identical bytes 14,977 of 16,384
differing 1,407 = 8.6%
The differences are spread across many field positions rather than concentrated,
which is what morphed **positions** look like: the topology, UVs, counts and
command lists stay verbatim while a fraction of the position bytes move. 8.6% is
about half of a 16-byte vertex's 6 position bytes, i.e. consistent with roughly
half the vertices in this patch being mid-morph at the saved camera distance.
### How much of a patch actually changes, and where
The whole tfrags block was read from RAM -- 4,379,200 bytes at `0x848000` -- and
every one of the 833 patches compared against its disc copy, grouped by whether
it *can* morph at all.
patches with NO morphing entries 334 differing bytes: max 10.59%, median 7.80%
patches WITH morphing entries 499 differing bytes: max 10.49%, median 8.12%
patches 100% identical to disc 0/334 and 0/499
**The two groups are indistinguishable.** Morphing is **not** what changes the
bytes. This is the disproof of the claim above that the 8.6% was "morphed
positions with topology and UVs left verbatim" -- if it were, the 334 patches with
no morphing entries would be untouched, and none of them is.
Nor is it a relocation: the differing bytes fall in short structured runs
(257-263, 272-277, 305 on patch 0) whose `u32` deltas have no common value.
The remaining explanation, consistent with everything measured, is that **the
loader regenerates each patch's VU packet stream for the LOD tier it selected** --
which happens to every patch, morphing or not. That also settles the verification
route: this memory image shows the LOD-selected form, not the morph weight, so the
weight cannot be read out of it at any camera distance.
Patch 1 (13 morphing entries of 83) compared over 4 KiB, disc `0xdf60` against RAM
`0x855f60`:
bytes differing 354 of 4,096 = 8.6%
contiguous runs 120
longest run 8 bytes
extent +0xc1 through the end of the window
83 positions at 6 bytes each is 498 bytes, and only 13 of 83 vertices morph, so
**if morphing rewrote position fields alone** the differing bytes would be a
13/83 slice of a 498-byte array -- a couple of dozen bytes in one contiguous
region. Instead 354 bytes differ, spread over 120 short runs across the whole
window.
So the loader is not patching positions in place. It **regenerates the VU packet
stream** for the current morph state, which on this hardware is what morph
actually is -- VU1 GIF packets, not a CPU-side vertex array.
**Consequence for verification.** The plan of "map RAM vertices to disc indices and
solve for the weight" does not work against this memory image: the positions are
inside regenerated packets, so there is no stable per-vertex correspondence to
read a weight out of. Verifying the morph weight against the original needs the
**camera distance at the savestate** so the weight can be computed from the
derivation and compared with the rendered result, rather than read back out of
RAM.
Along the way, two population figures worth having: **833 of 833 patches parse**,
carrying **43,613** vertex-info entries, and **499 of 833 patches have at least
one morphing entry** -- patch 0, the first record, is base-only with none.
+538 -37
View File
@@ -1,6 +1,6 @@
# Roadmap # Roadmap
Status as of **2026-10-09**. Status as of **2026-10-11**.
A from-scratch native Rust reimplementation of Ratchet & Clank: Going Commando A from-scratch native Rust reimplementation of Ratchet & Clank: Going Commando
(PS2). Each phase ends in a **gate** — something checkable, not a feeling that (PS2). Each phase ends in a **gate** — something checkable, not a feeling that
@@ -21,6 +21,19 @@ early, while they are still cheap to retire.
header, and the `data` extent is parsed as a container of byte ranges. The header, and the `data` extent is parsed as a container of byte ranges. The
geometry decompresses: level 0 yields 16,969,296 bytes of level content geometry decompresses: level 0 yields 16,969,296 bytes of level content
from a 9.7 MB stream. from a 9.7 MB stream.
- [x] **Parsers with real-disc tests, per asset type.** `level_wad`, `data`,
`core_index` class tables, `tfrag` (format, topology, textures, LOD,
normals, colour), `occlusion`, `sky`, `moby`, `collision` framing and
header, `shrub` (blob, packets, palette, billboard), the post-shrub
`chunks` container, `sound_bank` framing and `level_overlay`. 238 tests
green against the real disc. **What is open is naming, not locating** --
see the Phase 2 list for the four types that still have no decoded
fields: `tie` vertices, collision cells, `SBlk` records, `volumes` and
`moby_anim`.
- [x] **Lighting verified against the running game.** PCSX2 2.8.2 with savestates
for level 0 and level 1. The tfrag lighting pass is byte-exact on both
against the game's own RAM. Setup and method in
`plan/pcsx2-verification.md`.
- [x] **First drawable frame.** `gc-engine` loads a level, applies LOD selection - [x] **First drawable frame.** `gc-engine` loads a level, applies LOD selection
and morphing for the camera, groups triangles by texture and draws them and morphing for the camera, groups triangles by texture and draws them
under a Bevy fly-camera. Level 0 renders as 26,758 textured triangles under a Bevy fly-camera. Level 0 renders as 26,758 textured triangles
@@ -57,9 +70,30 @@ early, while they are still cheap to retire.
through `LightTfrags`. The renderer still counts and reports how many through `LightTfrags`. The renderer still counts and reports how many
vertices it lit, because a silent fallback to stored colours would look vertices it lit, because a silent fallback to stored colours would look
plausible and be wrong. plausible and be wrong.
- [ ] Lighting *magnitudes* are unverified: they need comparison against - [x] **The shrub region is bounded, and with it the whole lump.** Shrub's end
reference frames, which needs the PCSX2 comparison loop. was the last unlocated region bound: it is the start of the post-class
- [ ] Then the GS register set and the guard-band clip test. chunk container, found by a marker-paired signature in `core_data` (the
index carries no successor offset). The container closes shrub on the
24 shrub levels and tie on the three without. All 358 shrub class bodies
are now bounded and are whole 8-byte records, so shrub measurements are
trustworthy for the first time -- the "3.5M records" figure from the
unbounded final span is replaced by a measured 9,600.
- [x] **Lighting *magnitudes* are verified** by the PCSX2 comparison loop.
`gc-trace compare-tfrag-light` runs the pass over level 0's disc data and
diffs it against the game's own lit colours in EE RAM (live over PINE, or
from a savestate): **all 162,400 colour bytes match**, across 833/833
headers relocated and the light bank byte-identical. Two bugs it found:
the normal's z was not negated (dark but plausible, 95.88% agreement),
and records with no light set drew transparent black instead of the base
colour. See `docs/plan/pcsx2-verification.md`.
- [~] Then the GS register set and the guard-band clip test. **The clip test is
implemented** as `gc_formats::guard_band` -- steps 1-7 of the R&C1
algorithm, returning Cull / Normal / ClipAtLod0, with seven unit tests.
Its geometry inputs (`bsphere`, `clip_corners`, `draw_mode`) are parsed and
verified; the only missing piece is the per-frame camera, which needs a
PCSX2 session because no two savestates on hand are consecutive frames
(`1aa94ee`). **Not yet compared against a real frame**, and the `tan` and
clip-cube scales are unverified.
The disc being the exact build the reference projects pin is the single The disc being the exact build the reference projects pin is the single
biggest favourable fact so far: no disc conversion, no regional timing work, biggest favourable fact so far: no disc conversion, no regional timing work,
@@ -153,6 +187,9 @@ Phase 1 hands over the extents; this phase works out what each one contains.
- [x] Region layout of `core_data` established: tfrag, occlusion, sky, collision - [x] Region layout of `core_data` established: tfrag, occlusion, sky, collision
and `textures_base` are offsets, strictly increasing on all 27 levels. and `textures_base` are offsets, strictly increasing on all 27 levels.
Terrain is `[0, occlusion)` and carries inline texels. Terrain is `[0, occlusion)` and carries inline texels.
- [x] Region layout **closed**: the chunk container's marker-paired signature
ends the last class region, and the constant trailer ends the container.
Every byte of level-specific `core_data` now belongs to a named region.
- [x] **tfrag vertex format: recovered.** The tfrags block header gives a table - [x] **tfrag vertex format: recovered.** The tfrags block header gives a table
offset (`0x40`) and a patch count; each patch is a 0x40-byte header whose offset (`0x40`) and a patch count; each patch is a 0x40-byte header whose
`data` offset is relative to the table, not to the block. Positions are `data` offset is relative to the table, not to the block. Positions are
@@ -198,19 +235,183 @@ Phase 1 hands over the extents; this phase works out what each one contains.
with the WAD LZ77 decoder, bit order and header length ruled out" note with the WAD LZ77 decoder, bit order and header length ruled out" note
was wrong about the cause: the decoder was right and the input was not was wrong about the cause: the decoder was right and the input was not
what it looked like. Corrected in `plan/level-assets.md`. what it looked like. Corrected in `plan/level-assets.md`.
- [ ] `occlusion` — 2 to 25 sectors, small enough to read whole. - [~] `sound_bank` — the `SBlk` head is now characterised: `"SBlk", 1, 4,
- [ ] `sound_bank` — `SBlk` and `DAW` signatures found in all 27. "DAW\0", 0` constant on 27/27, and `+0x14` is the only varying word, with
- [ ] `data` — the large one: 4.2 to 21.9 MB of geometry per level. its low 16 bits always zero (`0x0092..=0x0103`). It tracks body length at
~100 bytes/unit and is **not** a count -- ratio 91.2..110.8, and no stride
makes it exact. The absence of a stride is asserted too. Container framing parsed (`SoundBankHeader::parse`,
`parse_sound_bank`); `SBlk` and `DAW` bounds verified on 27/27. The
**record fields inside `SBlk` are not decoded**, and `DAW` shows no
detectable stride, consistent with ADPCM. See the note under
`level_overlay` below for what the game's own strings say about loading.
- [x] `data` — **not geometry.** It is the level's file descriptor: its
first table carries the exact byte lengths of `overlay.bin`,
`core_index.bin` and `gs_ram.bin` at fixed `u32` indices 1, 3 and 5 on
every level checked. What follows the descriptor is still unknown.
- [ ] `tfrag` — terrain: LOD chains, strips, morph targets - [x] `tfrag` — **complete on the disc side.** Vertex format, topology, strips
- [ ] `tie` — instanced environment geometry, fat vertices (`TfragStrip`), texture bindings, per-vertex colour and normals, LOD
selection, parent indices, morphing and collapse. 36 format tests, 9 texture
tests, 23 engine tests. The morphing is a blend toward the **parent
vertices'** midpoint, not toward separate morph targets — there are no
morph targets in this format, which is what the old "morph targets
unrecovered" note wrongly implied.
- [x] The tfrag **table record has exactly two relocated pointer fields**, at
`+0x10` and `+0x38`, verified against PCSX2 across all **833** records: the
loader adds the **table's** RAM address (`0x848040` = block base plus
`table_offset`, matching `TfragHeader::data`'s own "relative to the table")
to `+0x10`; every other byte of every record is verbatim, and 1,665 fields
differ where 1,666 were possible.
They are the existing `TfragHeader::data` and `::cube_ofs` -- what RAM adds
is that both are **pointers**, not plain offsets. All **833** patches parse,
carrying **43,613** vertex-info entries; **499 of 833** have at least one
morphing entry.
- [x] Behind those pointers, **every patch's bytes are rewritten** -- and it is
**not morphing**. Across all 833 patches: the 334 with **no** morphing
entries differ by a median **7.80%**, the 499 with morphing entries by
**8.12%**, and **0 of 833** are byte-identical to disc. Morphing does not
predict which bytes change. Nor is it a relocation: the differences fall in
short structured runs with no common `u32` addend. The loader regenerates
each patch's **VU packet stream for the LOD tier it selected**, which
happens to every patch regardless of morphing.
- [x] **The tfrag morph is not a rewrite of the position arrays.** Across all
**833** patches of level 0, live PINE RAM against the disc: **40,600**
positions, **40,600 identical, zero changed**. The game does rewrite the
blocks -- **166,465** bytes differ -- and **none** of them fall inside a
position unpack: 116,981 land in VIF command words and 49,484 in other
unpack payloads. The blend is therefore not precomputed into the geometry;
it happens at draw time in the VU stream, which is why reading a weight back
out of RAM never worked -- there was nothing there to read. This sharpens
the earlier "positions live inside regenerated VU packets, so there is no
stable per-vertex correspondence": the correspondence was never missing, the
values are just identical. `gc-trace verify-morph` and the
`GC_SSTATE`-gated test both carry the numbers.
- [ ] What tfrag is **not** yet verified is that the morph matches
`resolve_vertex`'s formula. The blend target is still untested against the
game itself, and now the search space is bounded: it is in the VU stream,
not in the tfrag data block. Verifying it needs the **camera distance at the
savestate** so the weight can be computed, or a read of the VU packet stream
the game actually submits.
- [x] The tie class header's **`+0x0c` word is the three LOD record
counts** — LOD 0 at `+0x0c`, LOD 1 at `+0x0d`, LOD 2 at `+0x0e` — verified
on **1,864 of 1,864** classes by reading the counts independently from
the blocks' own record tables. This was filed as "R&C1's normals offset,
read past, not decoded", which was the right action on the wrong map:
R&C1 carries the same quadruple at `+0x20` and it is not an offset on
R&C2. The fourth byte is unidentified and is *not* the shader total
(disagrees on 1,827 of 1,864).
- [~] The tie block's **located `0xf6` terminator is inside the last packet's
data** -- the last packet's computed end lands 2 to 16 bytes past it on
all 2,150 blocks measured. So the block's *extent* is short by that much
(the `0xf6` test could not see it: a false terminator passes that test).
The block *starts* are unaffected and RAM-confirmed. The packet lengths
themselves are verified for every record but the last.
- [x] `tie` — **fully chained.** `resolve_tie_class` walks both indirections: the
class table is in `core_index.bin`, and the entry's `offset_in_asset_wad`
addresses `core_data.bin` (not `core_data.wad`). All **1,864** tie classes
resolve with `o_class` agreeing on every one. A `packets` word of **zero
means no block at that LOD** -- 2,177 real blocks, 1,551 absent slots.
- [x] Tie **blocks are self-delimiting**: `parse_tie_block` walks to the `0xf6`
terminator that every real block carries (2,177 of 2,177), followed by zero
padding. This is what makes blocks parseable at all -- `packets[1]` bounds
one on only 461 of 1,864 classes.
- [x] The tie **16-byte record is R&C1's `TiePacketHeader`.** Identified not by
resemblance but by R&C1's own rule, `bfc_distance == 5 * shader_count`,
holding on **15,219 of 15,219** records across all 2,177 blocks. A block is
`[16-byte header][record table][vertex payload]`, and the header word at
`+0x00` is the byte offset from the block start to the payload. The earlier
"variable-length, not 16-byte records" reading was wrong: the word is not
an element count, which is why dividing the payload by it gave 0 of 922.
- [x] The tie **vertex element is 16 bytes**, measured over 95,612 vertices on
levels 0-2 and settled by comparison: a 12-bit-fixed texcoord hit rate of
**82.5%** at 16 bytes against 47.8% at 8 and 44.8% at 12.
- [x] `i16[0..2]` are the **vertex positions**: LOD1/LOD2 of one class share 96.1%
of their position triples, while LOD1 of A against LOD2 of B shares 0.4% --
a factor of 240, asserted including the ratio. Axis order and scale unnamed.
- [x] `i16[3]` is a **byte-sized index**, not a coordinate: 201 distinct values in
`0..=254` inside the ordinary layout, whose high byte is the layout tag
(93.4% of elements are `0x00`, 5.6% are `0x03`, 146 selector values in all).
Asserted.
- [~] The tie **texcoord field is no longer located.** It was recorded at
`i16[4]`/`i16[5]` on a 73.6% "both multiples of 4096" score, but that score
is contaminated twice over: it was computed on the 16-byte reading that
`e5cd3df` retracted, and measured on all records rather than the flat ones.
Restricted to the **flat** records, and asking the discriminating question
-- how many of the slot's distinct values are multiples of 4096 at all --
gives **16 of 1,488** for `i16[4]` and 16 of 1,261 for `i16[5]`. A 12.4
fixed-point texcoord would be *all* of them. The dominant values do look
like UV corners (`(0,0)`, `(0,4096)`, `(4096,4096)`, `(4096,0)`), which is
why it read as convincing; that is a statement about the most frequent
values, not about the field -- the same shape as the 305x class-ID
artifact. The texcoord slot in a flat tie record is **not identified**.
The earlier note it supersedes read: `i16[4]`/`i16[5]` of the 16-byte
element, scoring 73.6% for 12-bit fixedness over 36,501 level-0 vertices,
against 1.1% for `(0,1)` and 4.8% for `(2,3)`. The `(6,7)` pair scores
*higher* at 76.6% and is **not** a texcoord -- it is the near-constant
`4096, 0` pair, which is trivially two multiples of 4,096.
- [~] `i16[3]` was recorded as selecting the texcoord encoding; that rested on
the same 16-byte reading and is withdrawn with it. See the note above.
The original text read: it is specifically the
**high byte**. Grouped across 95,612 vertices on levels 0-2: high byte `0x00`
covers 89,341 vertices (93.4%) at 92% 12-bit fixed, while **every** other
high byte scores 0%. The split is on the byte, not the value -- `i16[3]` of 1
and of 771 differ only in the high byte and behave completely differently.
Within the all-zero case, `i16[3] == 0` exactly (5,851) is a third
distribution at 62%. Three cases: `0x00/0x00`, `0x00/1..255`, and non-zero
high byte.
- [x] **The tie packet's length follows from its record**:
`vert_ofs + vert_size + scissor_size` quadwords, on **12,927 of 12,927**
record pairs whose `strip_count` is zero and **0 of 115** whose it is
not -- an exact partition, so the formula is safe when `strip_count == 0`
and knows nothing otherwise. This unblocks slicing the payload per
packet. It also corrects this file's own retraction of the same formula:
the "0 of 13,042" re-measurement had swapped `strip_count` for
`scissor_ofs` and blamed the block set.
- [~] The remaining tie vertex **fields are not named**, and two readings are now
excluded. `i16[6]`/`i16[7]` are constant across a whole draw packet in
43%/34% of records, so they are usually per-packet rather than per-vertex.
`i16[3]` is per-vertex (50 distinct values per packet) and unshared across
LODs (75.3% against a 78.7% control). **An earlier note here called it "not
a joint index" on a 0.95x distance ratio; that was an over-read and is
withdrawn.** A rigid object bound to joint 0 makes "vertices sharing a
joint" the whole mesh, so absence of clustering is what a joint index
predicts. With `MobyInstance.jointCnt` now named, the joint reading is the
leading candidate, not a disproved one.
`i16` range with 10,803-13,421 distinct values each. `i16[0..2]` are most
likely position, but that is not established.
- [~] `shrub` — class table done (48-byte entries, not 32; reading shrub at - [~] `shrub` — class table done (48-byte entries, not 32; reading shrub at
32 still gives 231 sane-looking entries of 358, which is the hazard). 32 still gives 231 sane-looking entries of 358, which is the hazard).
Geometry header parsed and both fields **checked**, not merely reported: Geometry header parsed and checked field by field on all 358 spans:
`+0x24` equals the entry's own `o_class`, and `+0x2c` is an in-span data `+0x24` equals the entry's own `o_class`, `+0x2c` is an in-span data
offset, on all 358 spans. Shrub does **not** use moby's part-table format. offset, `+0x16`/`+0x18` are zero, and `+0x1c` names a billboard on
The body past `+0x2c` is not decoded — it tiles at no stride consistent exactly the 25 classes whose core-index entry carries billboard texture
with the count at `+0x28`. fields. Shrub does **not** use moby's part-table format. The body is a
**24-entry palette of unit-length s16 normals** (squared length within
65,535 of 32767² on all 8,592 entries — a random triple would miss by a
billion) plus zero padding records; the billboard record carries K, MMIN
and the TEX0/MIPTBP addresses as R&C1's does. What remains open: the
three floats in the billboard record, `size`, `mode_bits`, `count`, and
**where the mesh geometry lives** — R&C1 puts the mesh packets in this
blob and R&C2's blob holds only the palette.
- [x] **The tie LOD block is a table of 16-byte records.** The header's word at
`+0x00` is the byte offset from the block start to the vertex payload --
the record table's length -- not a count, which is why the payload never
divides by it. Each record is a leading `u32` pointer **relative to the
table start** plus twelve verbatim field bytes, relocated at load to
`table + word`.
- [x] **The record is R&C1's `TiePacketHeader`.** R&C1's own rule
`bfc_distance == 5 * shader_count` holds on **all 15,219 records of all
2,177 blocks on all 27 levels**. R&C1's rule for the colour offset holds
on **none** of them, so the games use that field differently.
- [x] **The tie relocation verifies on two levels.** `gc-trace
verify-tie-records` checks it against PCSX2's RAM: **87 of 87** blocks on
level 0 and **104 of 104** on level 1, zero mismatches, which is what
makes the record layout a finding rather than a reading of one block.
- [x] **The shrub mesh geometry is in the class blob.** A packet table at
`+0x40` names `count` packets between the table and the palette, and
they tile on **358 of 358 spans** (2,239 packets), which makes `count`
the packet count. Confirmed against the running game, which relocates
the table's offsets to absolute addresses and `data_offset` likewise.
- [x] `moby` — per-class model geometry. A class span opens with a `u32` - [x] `moby` — per-class model geometry. A class span opens with a `u32`
pointing at a part table of 16-byte records; parts tile exactly, with pointing at a part table of 16-byte records; parts tile exactly, with
`vertex_offset + 16 * vertex_count == next_part` holding for **43,467 of `vertex_offset + 16 * vertex_count == next_part` holding for **43,467 of
@@ -218,13 +419,84 @@ Phase 1 hands over the extents; this phase works out what each one contains.
makes the record size fall out of the data rather than being assumed. makes the record size fall out of the data rather than being assumed.
1,270,802 vertices over 4,560 class spans; 116 spans carry no geometry. 1,270,802 vertices over 4,560 class spans; 116 spans carry no geometry.
Vertices are 16-byte VIF-packed half-float quads, handed back raw. Vertices are 16-byte VIF-packed half-float quads, handed back raw.
- [ ] `moby_anim` — skeletal animation, still open - [x] The **moby class-id space is known**: `lvl.vtbl` (section 3 of the level
- [x] `collision` — container framing. A 0x40-byte header self-describes: it program) is records of `{oClass, handler, auxiliary}`, and across 27 levels
carries its own size and the payload size, and `header + payload` stays that is **1,162 distinct ids** reaching `0x13BA` (5,050), median 38 new per
inside `core_data` on all 27 levels. 41,481,520 payload bytes across 19 level. `moby_classes.count` is 238 and **97.6% of real ids are at or above
levels; 8 levels declare zero length. The cell layout is **not** decoded it**, so the count was never a valid bound -- which is the mechanism behind
-- the payload does not walk as fixed-stride cells, and variable-length the chunk class-ID artifact now retracted. Re-scored against the real set,
faces stop partway into the first cell rather than tiling it. no chunk slot separates (best 0.375 against 0.018 chance, a dozen slots in
the same band). Asserted.
- [x] **The dispatch tables hold 4,737 behaviours, not 5,822 records.** `lvl.vtbl`
collapses 5,822 records over 1,162 classes to 4,737 handler addresses, the
median serving one class and the busiest 34; 400 addresses serve more than
one. The camera and sound tables are essentially one handler per record
(217 -> 216, 32 -> 32). A class's behaviour is therefore not one function,
which matters for any search for a specific class routine. Asserted.
- [x] **All three dispatch tables decode.** `lvl.vtbl` (3 words), `lvl.camvtbl`
(5) and `lvl.sndvtbl` (2) each divide their sections **exactly**, with zero
remainder on all 27 levels -- which is the check that would fail if a record
width were wrong. 217 camera records carry 14 distinct global ids (max
`0x1B`), 32 sound records carry 6 (max `0x07`), so both spaces are shared
across levels rather than renumbered. Handler addresses are function
entries in the same overlay's `.text`, which is now readable. Asserted.
- [x] `lvl.vtbl`'s **`oClass` is a global identifier**, not an index into the level's
class table: 1,162 distinct ids across the game, largest 5,050, against
`moby_classes.count` of 136..259. **1,133 of 1,162 are out of range** for any
per-level table. The two are different axes -- a type id, and the models a
level instantiates -- and the space is shared rather than renumbered
(1,162 distinct out of 5,822 records; level 0 supplies 20%, not a
majority). A class can now be recognised across levels by id. Asserted.
- [x] The **moby instance is named**: `MobyInstance`, 256 bytes, 67 fields,
transcribed from the decomp's `include/structs.h` into `gc_formats::moby`
with an offset map. `oClass` at `0xBC` is the dispatch key into `lvl.vtbl`,
which closes the instance-to-routine link; `animSeq`/`animLayers`/
`jointCnt`/`jointCache` at `0x40..0x5C` name the instance-side animation
state. **Not verified against disc bytes** -- mobies are runtime objects and
nothing of that shape is in the container; the test asserts what can be
asserted without them (offsets ascend, do not overlap, land exactly on
0x100). Layout claim from a third party, attributed as such.
- [ ] `moby_anim` — **unblock attempted and not achieved.** The unblock was "the
data behind those pointers", and the pointers are RAM addresses, so the
search moved to the moby class blob: 187 classes on level 0, part-table
offset `0x80` on 69 and `0x90` on 34, so each carries a `~0x40`-byte header
before its parts. That header has ~6 constant and ~12 per-class-varying
fields and **none is yet identifiable** — the varying ones hold values of
the same order as the span offsets, so the obvious discriminator ("the field
that points outside the span") does not resolve. Needs the class loader in
the overlay's `.text`, which is now readable, or a class whose animation is
known externally. Recorded as a stall with a location, not as progress. An
instance table would have to scale with the level's moby class count, but
**per-chunk-type record counts are level-independent** -- the same types
report 90, 77, 50 and 47 on every level tried, and the largest type
totals 2,430 = 90 x 27. Nothing in the chunk set is keyed by moby class.
The types also pair up (two types both reporting 90 on every level,
another pair both 77), which looks like two views of one fixed table
rather than two independent tables.
- [x] `collision` — container framing and header fields. A 0x40-byte header
self-describes: it carries its own size and the payload size, and
`header + payload` stays inside `core_data` on all 27 levels. 41,481,520
payload bytes across 19 levels; 8 declare zero length. Header bytes `+8`
reads zero on 22 levels and a large value on 7/9/12/13/14; the payload's
two leading `u16` are non-zero on all 19 non-empty levels (1-74 and
13-112) and are reported as `CollisionHeader::first`/`::second`.
- [ ] The collision **cell layout is not decoded.** The payload does not walk
as fixed-stride cells. On level 4 a table starts at payload `+5932` and
runs **17 entries** -- deltas rise then fall -- against a 75-cell grid,
with no arithmetic relating the two. A 64-byte RAM stride looked promising
and is **disproved** -- the longest such run is 27 records of identity
matrices, one per level.
- [x] **The collision payload is essentially verbatim in RAM, not reshaped.**
Level 0's sits at RAM `0xac6800` for a disc address of `0x27e800`:
**903,212 of 903,248 bytes identical**, first difference at `+0xdc82c`, with
**6** bytes differing in the final 36. Asserted by a savestate-gated test
that compares the whole payload. This **overturns** the earlier claim that the grid dimensions
are absent from RAM: level 0's `11 x 33` are the first two bytes of that
block. The earlier negative came from searching for the dimensions as a
`u32` pair when they are two `u16`s -- a bad pattern, not a property of
the data. The payload contains ascending `u32` offset tables from `+1024`
onward, and disc-side decoding is no longer blocked on reshaping. See
`plan/pcsx2-verification.md`.
- [x] `occlusion` — the PVS grid. A z/y/x tree of u16 node offsets over 4x4x4-unit - [x] `occlusion` — the PVS grid. A z/y/x tree of u16 node offsets over 4x4x4-unit
cells, populated cells naming 128-byte masks. **412,521 cells over cells, populated cells naming 128-byte masks. **412,521 cells over
140,939 masks** across the 24 levels that have one; the mask array ends 140,939 masks** across the 24 levels that have one; the mask array ends
@@ -232,12 +504,173 @@ Phase 1 hands over the extents; this phase works out what each one contains.
data, and an offset of 0 means *absent* rather than "at the start" — the data, and an offset of 0 means *absent* rather than "at the start" — the
latter parses as a grid whose mask array happens to fit, which is how it latter parses as a grid whose mask array happens to fit, which is how it
nearly went wrong. nearly went wrong.
- [ ] `sky`, `volumes`; textures are done (CSM1 CLUT + PS2 alpha) - [x] `sky` — 149 sub-objects across all 27 levels, each with a count, a
- [ ] `level_overlay` — the seven-section parser and relocation from Phase 3 four-float bounding sphere and texture dimensions. The offset table at
`+0x20` is self-limiting: entries run while strictly inside the region.
**The word at `+0x0c` is not a count** -- it disagrees with the table on
most levels and reads 196,617 on level 2 against 4 sub-objects.
- [x] The **overlay's load base is `field_00 - 16` = 1,735,792**, derived from
the overlay's own profiler table (whose absolute addresses point back at
its names, on all 27 levels) and **confirmed in RAM**: all four profiler
names land where the base predicts. Any overlay offset can now be carried
to a RAM address. The overlay is *relocated*, not copied: only 37.67% is
byte-identical at the base, so tables inside it move.
- [~] `volumes` — **still not identified, but a candidate exists.** Every level's
`gameplay.bin` carries a table of **144-byte float records** with world
positions at `+0x0c`, preceded by an all-zero record (levels 0-9: 23 to 612
records). That is the shape of a volume trigger region and it is not tied
to anything else in the file -- but camera bounds, spawn points and path
nodes would look identical, and there is no engine-side `Volume` string to
discriminate with. **Recorded as candidate, not volumes.** Two detector
bugs on the way are written up: a validity test that counted zero as
world-plausible scored zero-filled memory perfectly, and the 144-byte stride
rested on four eyeballed offsets that the gap histogram did not support.
A promising lead also failed: level 0's `data.bin` references one candidate
offset exactly once (chance ~0.003), but **none** of seven other levels'
candidates are referenced by their own `data.bin`, so the table is reached
by walking structure and nothing links it to volumes. Not located. Nothing examined so far presents itself as a
volume: `core_data`, the chunk region and `gameplay.bin` all yield
structures that are accounted for by something else.
- [~] `level_overlay` — header, constant block and level data parsed
(`gc_formats::overlay`, 27/27). `+0x10..+0x1220` -- 4,624 bytes -- is
byte-identical on every level; the word at `+0x0c` is **not** the file
length, falling below it on 17 levels and above on 10. The debug string
pool it opens with carries `Camera_CollPrimTest WARNING! - grid out of
bounds! (RAR)`, independent support for reading the collision payload's
two leading `u16`s as grid dimensions. `+0x00` **is** the load base of
the section this file is, `0x1A7C80`, constant on all 27 levels because
every level's `.lit` loads there. `+0x0c` is **not** the `.lit` size -- it
falls *below* the file length on 16 of 27 levels, by up to 8.9%, so it is
excluded by sign -- and it is still unnamed. **The remaining header words
are unnamed and the level-specific tail is not structured yet**; the
Phase 3 seven-section parser and relocation are untouched, though the
seven sections are now named (`lvl.vtbl`, `lvl.camvtbl`, `lvl.sndvtbl`).
- [x] **The post-shrub chunk container.** A 16-byte header, a 10-byte zero gap,
then offsets tagged `0xF0000000 | low24` resolving to `chunk_start + low`;
header byte 14 is the record count, equal to `offsets.len()` on 1,123 of
1,123 chunks across all 27 levels. **77 distinct chunk types, 1,509
chunks, 34,491 records.** Moby class IDs enrich at 9,493 hits against
246.1 expected with contiguous per-type arrays -- but the arrays land in a
different place for each type, so no single pooled scan finds them.
- [x] **Chunk record framing is closed**, but the uniformity claim in it is
**retracted**: only **257 of 1,509** chunks have a uniform record length, and
1,252 are variable within the chunk. "1,509 of 1,509" was `resolve` slicing
at the chunk's first offset delta and the predicate confirming the parser
back to itself. An earlier withdrawal ("variable-length was a pooled
artifact") was wrong too -- the records were always variable. The record's leading byte is a **per-chunk phase
counter** (`bucket_rule`, 1,262 of 1,509 chunks) and on **1,235** of them
*both* leading bytes encode the record's own index in mixed radix
(`index_rule`), so the leading bytes carry no per-record information.
- [x] **The loader's transformation, from PCSX2 RAM.** Disc offsets
`0xf0000000 | low24` are rewritten in RAM to absolute EE pointers
`0xf1850000 | low24` -- tag replaced by the chunk's load base, low 24 bits
untouched. The payload itself is copied **verbatim**. The index finding
is corroborated live: the byte at `+2` takes exactly `0, 16, ... 240`, the
16 values `index_rule` predicts.
- [x] **Corrected: a record starts two bytes before the table's offset.** The
`02 00` read in RAM as a loader-inserted prefix is the record's own
leading word, already on the disc at every record. The loaded table
simply points two bytes earlier than the disc's, at that word. It is a
field: uniform across a chunk's records on **1,439 of 1,509** chunks,
fixed within **all 71 types**, values `{1, 2, 4, 6, 8, 10}`. Disproved
readings: an entry count (57 distinct fractional "sizes") and a
distinct-leading-byte count (0 of 1,506 agree).
- [~] The chunk **record fields are not decoded**, and no type is named -- but the
stride table is now measured: **74 signatures, 55 distinct strides, 52..640,
every stride a multiple of 4**, and 73 of 74 signatures use a single stride.
The exception is pinned: `[75 42 ef f2 71 b5]` at strides `{76, 92, 104}`.
Asserted including the exception, so "every type is uniform" cannot rot.
A per-type layout found for one type (48 bytes of data then 60 permanently
zero) does **not** generalise -- 0 of 9 types fit that shape.
**65 of the 74 signatures are byte-identical in all 27 levels** (9 are
level-specific, 0 ragged), so most of the container is static game tables
rather than level content -- asserted. Consequence for method: the 65
cannot be decoded by comparing levels, because there is nothing to compare.
`[75 42 ef]` is both level-specific *and* the three-stride type, so it is
where per-type decoding should start -- and decoding it found a **parser
defect**: it makes three chunks per level, one of which has record offsets
stepping `92, 88, 84, 92, 92, 92, 84, 88` on every level. `resolve` slices
every record at the chunk's single stride, so four of the eight records
over-read by 4 and 8 bytes and include the next record's head. Records
still *start* correctly and bounds checking holds, so it is a data-correctness
bug, not a safety one. The "strides {76, 92, 104}" description is a
misdescription of three chunk shapes, one of them variable.
Measured across **all** chunks rather than that one signature, the pattern
is the norm: **1,252 of 1,509 chunks have non-uniform record offsets**,
across 66 of 74 signatures, with every deviation a word-aligned multiple of
4 and most exactly +/-4. **Which model this is remains open** -- genuinely
variable-length records and fixed records with inter-record padding produce
identical offset tables, and the offsets cannot tell them apart. A fix that
slices each record against its successor was written and reverted: it
implements one of the two models and the evidence does not establish it.
It also revealed that three tests asserting "1,509 uniform record length"
were passing only because the parser forced uniformity by slicing at a
single stride. Settling it needs a field whose end is independently
observable. The body
is dense (74% of `f32` reads plausible against a 49% random control) with
an index-like tail -- 12 of 163 `u16` slots in the largest type stay below
`0x1000` -- but **no count field** exists in the first 64 body bytes at any
width or stride tried. Only **9.4%** of `u16` slots are per-chunk constant,
so records are not templates.
- [x] **Moby class arrays are contiguous per type, and the pooled scan was
wrong.** See rule 3a in `AGENTS.md`: the original claim of a `u16` array at
slots 129-140 was the largest-stride type dominating an aggregate. Per
type, every one of the 26 places its array somewhere else.
**Gate:** every parser has a golden test against bytes from the real disc, and **Gate:** every parser has a golden test against bytes from the real disc, and
reports mismatched header counts as failures rather than warnings. reports mismatched header counts as failures rather than warnings.
### Gate status, checked
Both halves verified by inspection rather than assertion:
* **Golden tests.** Every module in `gc-formats/src` is referenced by a test or
another module's test path; none is unreferenced. `gc-extract/tests/disc.rs`
carries 50 real-disc tests, and the `overlay_reloc` mask test resolves a level-01
function on all 26 other overlays.
* **Counts are failures, not warnings.** No parser writes warning-style output
outside `#[cfg(test)]` -- the only `eprintln!` calls in `gc-formats` are inside
test modules. Count and length problems surface as typed errors: 42 uses of
`ErrorKind::Invalid` / `InvalidLength`, including `global_wad`'s "pair table is
not a whole number of entries", which is exactly a header count disagreeing with
the data.
**The gate is met.**
### Blocked, and what would unblock each
AGENTS.md requires a blocked item to say what would unblock it. These four did
not, which is itself a gap; this is that gap closed. None of these is a shortcut
or an approximation -- each is a measurement this repository currently cannot
make.
- **Collision cell layout.** Exhausted from the disc, and the last route is now
closed on evidence rather than left open. The payload is verbatim in RAM, it
does not walk as fixed-stride cells, and every disc-side segmentation is
excluded. The game's own `Camera_CollPrimTest` cannot be reached: the decomp's
symbol address lands in NOBITS, its assert-table address is a function with no
pointer arguments, and -- the decisive one -- **the assert string's address
occurs zero times in the 2,751,268-byte level program**, in either the address
or `$gp`-relative form, while a positive control in the same search finds the
`.lit` load base exactly once. The string is dead data; the assertion is
compiled out in retail. Needs a build with assertions enabled, a debug listing,
or an independent description of R&C2's collision cell. None is on this disc.
- **Volumes.** A 144-byte float record table with world positions at `+0x0c`
exists in every level and is not referenced by any `data.bin`, so it is reached
by walking structure, and nothing ties it to volumes. Needs ground truth: a
count of level 0's trigger volumes from a map or guide, to match against a
table's population. No class name containing "volume" exists on the disc, so the
dispatch-table route is closed.
- **`moby_anim`.** The instance-table reading is disproved and nothing replaces
it. Needs the class-dispatch table for the classes that reference the animation
data, which needs `moby` class records decoded past the header -- a dependency,
not a measurement.
- **tfrag morph.** All patch bytes are rewritten by LOD-selected VU-packet
regeneration, so the morph is invisible in a savestate. Needs a frame captured
at a *known* camera distance, so the expected blend is computable and can be
compared. Requires running the game and driving the camera, not analysing a
static savestate.
**Discipline:** bound every read. Typed errors, no panics, no reads past the **Discipline:** bound every read. Typed errors, no panics, no reads past the
buffer. buffer.
@@ -254,13 +687,32 @@ linked at different addresses. ReRAC solves this by masking relocations — `j`/
them, and `$gp`-relative operands — then matching the masked words to find a them, and `$gp`-relative operands — then matching the masked words to find a
reference function's copy on another overlay. reference function's copy on another overlay.
- [ ] Port the masking and code-identity logic - [x] Port the masking and code-identity logic (`gc_formats::overlay_reloc`,
- [ ] `Relocation::func` and `Relocation::data` `mask` / `address_refs` / `key`). **Verified against the disc**: the masked
- [ ] `$gp` for this game (`0x001AEFF0`, against R&C1's `0x166c00`) form of a level-01 function (858 words at `0x2a3b88`) is found **exactly
- [ ] Extend the level table from 19 to 27 once** on each of the other 26 overlays, and 0 times on its own level.
- [x] `Relocation::func` and `Relocation::data`, plus `is_identity`, `same_code`
and `copies`
- [x] `$gp` for this game (`0x001AEFF0`, against R&C1's `0x166c00`) -- confirmed
by `OpenRAC/rac2-gc-decomp` `docs/LEVELS.md` for all 27 levels
- [x] **The level table has 27 keys, and 21 is not one of them.** ReRAC, which this
project follows, has `rc_game::map::LEVELS = 19` -- Ratchet & Clank 1's
count. Reading every container header's own id off the disc gives
`0..=20, 22..=26, 30`: the files run `LEVEL0.WAD`..`LEVEL26.WAD` but
`LEVEL21.WAD` reports **30**, so one number was cut and reused by nothing.
`OpenRAC/rac2-gc-decomp`'s `config/levels/functions.tsv` independently
yields exactly the same 27 keys with 21 absent across 7,765 function rows --
ours from container headers, theirs from where functions land across
overlays. A table keyed by container index would mis-address one level in
twenty-seven and shift every id-keyed lookup after the gap. Asserted.
**Gate:** every level-01 function's relocated copy is found on all 27 overlays; **Gate:** every level-01 function's relocated copy is found on all 27 overlays;
cross-checked against an independent cluster hash. cross-checked against an independent cluster hash. **Both halves met.** The
cross-check is `OpenRAC/rac2-gc-decomp`'s `config/levels/functions.tsv`: 3,452
functions appear on all 27 levels in 2,984 distinct content fingerprints. Against
our own masking of the level-0 copies, **our grouping never merges two the
reference separates -- 0 cases** -- and splits 3,231 pairs the reference keeps
together, so our clustering is strictly finer rather than contradictory.
**Why this is high-value:** it turns "27 overlays of unknown code" into a table **Why this is high-value:** it turns "27 overlays of unknown code" into a table
lookup, and it is the main thing inherited from ReRAC. lookup, and it is the main thing inherited from ReRAC.
@@ -271,13 +723,60 @@ lookup, and it is the main thing inherited from ReRAC.
**Goal:** draw a frame. **Goal:** draw a frame.
- [ ] Graphics Synthesizer in wgpu, with the GS's own rules: its - [~] Graphics Synthesizer in wgpu, with the GS's own rules. **`gc_formats::gs`
`round(log2(z/n) + K)` mip selection, blend and alpha-test behaviour, now holds three of the four, each with unit tests:** `alpha_to_8` (where
alpha `0x80` meaning 1.0, CLAMP wrap `0x80` is opaque, and `0x80 & 0x7F == 0` is exactly why it reads as half to
an 8-bit reader), `clamp01` (CLAMP wrap, with `1.0` kept at the far edge),
and `mip_level` -- `round(log2(image / on_screen))` clamped to the chain,
where a *smaller* on-screen size selects a *higher* level. **All four rules
are now there**, plus `passes_alpha_test` and `BlendMode` for the
alpha-test and blend halves of the item. Two traps worth naming:
the GS's `AFAIL` bit reads as "fail if above the reference" and on the
hardware means the opposite -- it discards where alpha is **below**; and the
opaque bit must be kept out of the comparison, or `0x80` satisfies every
threshold and nothing is ever discarded. **The engine is on bevy 0.19, not raw wgpu**, so these rules reach the GPU
through bevy's sampler rather than wgpu directly. One is now wired: the
texture sampler was `..default()` with a comment claiming it clamped, and
bevy's default is `Repeat` -- see `image_of` in `crates/gc-engine`.
**What the disc says about priorities.** Measured over all 23,168,000 texels of
every decoded tfrag texture on 27 levels: **97.94% fully opaque, 0.98%
fully clear, 1.08% partial across 126 distinct values.** So the `0x80 -> 1.0`
rule is not a corner case -- it is what 22.7 million texels do -- while
blending touches about 1.08% of them. **These are the same size** -- the cutout
path handles the 0.98% fully-clear texels, the blend path the 1.08%
partial -- so an earlier version of this note claiming cutout "matters far
more" was not supported by the counts it cited. What does make cutout more
consequential is the *shape* of the failure: a clear texel drawn when it
should be discarded puts geometry where there is none, which is visible,
while a partial texel drawn opaque is a slightly wrong soft edge, which is
not. Cutout first is a claim about which failure you notice, not about how
many texels are involved.
The 126 evenly-populated partial values look like smooth alpha (soft edges,
foliage, distance fade) rather than a quantised blend ladder.
**No draw-path or shader work has started.**
- [ ] VU0/VU1 skinning and lighting on the PS2 float model - [ ] VU0/VU1 skinning and lighting on the PS2 float model
- [ ] LOD/morph/cull per `TfragProc` and `TieProc` - [ ] LOD/morph/cull per `TfragProc` and `TieProc`
- [ ] Occlusion culling, fog, shadows - [ ] Occlusion culling, fog, shadows
- [ ] GIF/VIF packet stream interpretation - [~] moby_anim: **the loader hunt has a base.** `moby_classes.offset` is
referenced by 13-72x more level-0 functions than any of its 16 adjacent
constants, at every immediate-set size -- 72 of 1,082 functions with fewer
than 16 distinct immediates, where the best neighbour appears once. The
~300 candidates with 48 or fewer immediates are a real shortlist.
Disassembly of one (`0x3a8060`) shows `addiu r17, r18, 0xc0` alongside
`0xd0` and `0xe0` -- a 16-byte-stride table walk at an address offset, with
the float constant (`lui 0x3f80`) elsewhere. Whether `r18` is
`core_data + moby_classes.offset` is not yet traced.
- [~] GIF/VIF packet stream interpretation. **The tie control region is not a VIF or GIF
tag stream** -- measured uniform on both framings across 13,042 records, so
the GS's draw packets are not stored there in standard DMA form. What the
probe did establish is the packet partition (control / vertices / scissor)
and a universal invariant, `scissor_ofs == vert_ofs + vert_size`, on 15,219
of 15,219 records. **`bfc_distance` is also not the control region's length** --
100% of records exceed it for 2+ shaders, 32% for one -- so the AD-GIF is not
stored in the tie packet at all. See `docs/plan/level-assets.md`.
**Gate:** CPU and GPU paths agree on every vertex of a reference level — the **Gate:** CPU and GPU paths agree on every vertex of a reference level — the
same identity check ReRAC runs. same identity check ReRAC runs.
@@ -289,7 +788,7 @@ analysis trips their disassembler on two opcodes.
--- ---
## Phase 5 — Veldin vertical slice ## Phase 5 — Aranos vertical slice
**Goal:** one planet, playable start to finish. **Goal:** one planet, playable start to finish.
@@ -298,13 +797,15 @@ analysis trips their disassembler on two opcodes.
- [ ] Wrench and the camera - [ ] Wrench and the camera
- [ ] Collision and the HUD - [ ] Collision and the HUD
**Gate:** Veldin is completable and visually matches PCSX2 frame-for-frame under **Gate:** Aranos is completable and visually matches PCSX2 frame-for-frame under
replayed input. replayed input.
**This is the go/no-go for the project.** If Veldin works, the other 26 levels **This is the go/no-go for the project.** If Aranos works, the other 26 levels
are content. If it does not, the reason is discovered while it is still cheap. are content. If it does not, the reason is discovered while it is still cheap.
Spherical gravity is the thing most likely to break, because it invalidates Spherical gravity is the thing most likely to break, because it invalidates the
R&C1's movement and camera assumptions wholesale. movement and camera assumptions the engine inherits from Ratchet & Clank (2002)
wholesale. That reference is to the *previous game*, not to this disc -- Going
Commando opens on Aranos, and Veldin appears nowhere in it.
--- ---
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "gc-overlay-anchors"
description = "Locates MIPS code referencing a string in overlay.bin, via the GP-relative offset"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-or-later"
publish = false
[dependencies]
anyhow = "1"
capstone = { version = "0.14", features = ["arch_mips"] }
+178
View File
@@ -0,0 +1,178 @@
//! Find the MIPS code that references a string in `overlay.bin`.
//!
//! The overlay has no symbol table, but it has a fixed load base and a fixed
//! `$gp`, so every string has a GP-relative offset. MIPS materialises that as a
//! `%hi`/`%lo` pair, and the `%lo` half is a 16-bit immediate that occurs in the
//! instruction stream. Searching for it turns a string into a code address.
//!
//! This is the step that precedes disassembly, and it needs no disassembler.
use anyhow::{anyhow, Context, Result};
use std::collections::BTreeSet;
/// Where a level program is loaded, and its global pointer. Both are stated in
/// OpenRAC/rac2-gc-decomp docs/LEVELS.md and hold for all 27 levels.
const LIT_BASE: u64 = 0x1A7C80;
const GP: u64 = 0x1AEFF0;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let path = args
.first()
.context("usage: gc-overlay-anchors <overlay.bin> [needle] [--level N]")?;
let overlay = std::fs::read(path).with_context(|| format!("read {path}"))?;
if args.get(1).map(String::as_str) == Some("--disasm") {
let ram = parse_ram(args.get(2).context("--disasm needs a RAM address")?)?;
let before: usize = args.get(3).and_then(|v| v.parse().ok()).unwrap_or(64);
let after: usize = args.get(4).and_then(|v| v.parse().ok()).unwrap_or(64);
disasm(&overlay, ram, before, after)?;
return Ok(());
}
match args.get(1) {
None => {
list_strings(&overlay);
Ok(())
}
Some(needle) => {
let needle = needle.as_bytes();
let hits = find_all(&overlay, needle);
if hits.is_empty() {
return Err(anyhow!(
"{:?} not found in {path}",
String::from_utf8_lossy(needle)
));
}
for off in &hits {
report(&overlay, needle, *off);
}
Ok(())
}
}
}
fn report(overlay: &[u8], needle: &[u8], off: usize) {
let ram = LIT_BASE + off as u64;
let delta = ram as i64 - GP as i64;
let lo = (delta as u16).to_be_bytes();
eprintln!("{:?}", String::from_utf8_lossy(needle));
eprintln!(" overlay+{off:#x} -> RAM {ram:#x}");
eprintln!(
" $gp {GP:#x} -> delta {delta:#x} ({delta}), $lo half {:#06x}",
u16::from_be_bytes(lo)
);
// Count instruction-aligned occurrences of the $lo immediate.
//
// The PS2's MIPS is little-endian, and an I-type immediate occupies bits
// 15..0 of the word, so the low byte of the immediate is word BYTE 0 and the
// high byte is word BYTE 1. Reading bytes 1..3 instead -- which an earlier
// version of this file did -- matches the opcode and $rs fields instead and
// manufactures hits out of nothing.
let lo_byte = (delta as u16) as u8;
let hi_byte = ((delta as u16) >> 8) as u8;
let mut hits = BTreeSet::new();
for i in (0..overlay.len().saturating_sub(3)).step_by(4) {
if overlay[i] == lo_byte && overlay[i + 1] == hi_byte {
hits.insert(i);
}
}
if hits.is_empty() {
eprintln!(" no instruction-aligned $lo occurrence -- not referenced through $gp\n");
} else {
eprintln!(" {} instruction-aligned occurrence(s):", hits.len());
for i in &hits {
eprintln!(" {:#x} (overlay+{:#x})", LIT_BASE + *i as u64, i);
}
eprintln!();
}
}
fn parse_ram(s: &str) -> Result<u64> {
let t = s.trim_start_matches("0x").replace('_', "");
u64::from_str_radix(&t, 16).with_context(|| format!("parse address {s}"))
}
/// Disassemble `before` bytes before and `after` bytes after `ram`.
fn disasm(overlay: &[u8], ram: u64, before: usize, after: usize) -> Result<()> {
use capstone::prelude::*;
use capstone::Endian;
let off = usize::try_from(
ram.checked_sub(LIT_BASE)
.context("address below .lit base")?,
)?;
if off + after > overlay.len() {
anyhow::bail!("{ram:#x}+{after} runs past the end of the section");
}
let cs = capstone::Capstone::new()
.mips()
.mode(capstone::arch::mips::ArchMode::Mips32)
.endian(Endian::Little)
.detail(true)
.build()
.context("capstone mips32")?;
// Disassemble word by word rather than one `disasm_all` over the window: a
// single undecodable word stops the whole run, and code regions have data
// mixed in. Skipping four bytes and continuing is the point of the tool --
// without it a window around an interesting address is usually five
// instructions long.
let start = off.saturating_sub(before);
let end = (off + after).min(overlay.len());
let mut at = start;
let mut bad = 0usize;
while at + 4 <= end {
let word = u32::from_le_bytes(overlay[at..at + 4].try_into().unwrap());
match cs.disasm_all(&overlay[at..at + 4], LIT_BASE + at as u64) {
Ok(v) if !v.is_empty() => {
for i in v.iter() {
eprintln!(
"{} {:#010x} {i}",
if i.address() == LIT_BASE + off as u64 {
">>"
} else {
" "
},
i.address()
);
}
}
_ => {
eprintln!(" {:#010x} .word {word:#010x}", LIT_BASE + at as u64);
bad += 1;
}
}
at += 4;
}
eprintln!("({bad} of {} words did not decode)", (end - start) / 4);
Ok(())
}
fn find_all(h: &[u8], needle: &[u8]) -> Vec<usize> {
if needle.is_empty() || needle.len() > h.len() {
return Vec::new();
}
(0..=h.len() - needle.len())
.filter(|&i| &h[i..i + needle.len()] == needle)
.collect()
}
fn list_strings(d: &[u8]) {
let mut i = 0usize;
while i < d.len() {
if d[i].is_ascii_graphic() || d[i] == b' ' {
let s = i;
while i < d.len() && (d[i].is_ascii_graphic() || d[i] == b' ') {
i += 1
}
if i - s >= 8 {
println!(
"{:#010x} {}",
LIT_BASE + s as u64,
String::from_utf8_lossy(&d[s..i])
);
}
} else {
i += 1;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "gc-trace"
description = "PCSX2 ground-truth comparison harness: reads the game's own memory and diffs it against the port"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow = "1"
gc-formats = { workspace = true }
gc-engine = { workspace = true }
ruzstd = { workspace = true }
flate2 = { workspace = true }
+121
View File
@@ -0,0 +1,121 @@
//! EE RAM: a flat 32 MiB window, plus the content search the locators use.
//!
//! The harness never hardcodes an address. Every structure the comparison
//! needs is found by content, because the addresses differ per level and per
//! run, and a pointer read from the wrong base is indistinguishable from a
//! right read from the right one. `EeRam` is deliberately dumb: byte-range
//! reads and a substring search.
/// The PlayStation 2's main RAM: 32 MiB at physical 0 (no extended RAM).
pub const EE_SIZE: usize = 32 * 1024 * 1024;
/// A borrowed EE RAM image.
#[derive(Debug, Clone, Copy)]
pub struct EeRam<'a>(&'a [u8]);
impl<'a> EeRam<'a> {
/// Wrap a RAM image. The length must be exactly 32 MiB, so a short dump is
/// an error rather than a search that silently stops early.
pub fn new(bytes: &'a [u8]) -> anyhow::Result<Self> {
if bytes.len() != EE_SIZE {
anyhow::bail!(
"EE RAM image is {} bytes, expected exactly {EE_SIZE}",
bytes.len()
);
}
Ok(Self(bytes))
}
pub fn bytes(&self) -> &'a [u8] {
self.0
}
/// Every offset at which `needle` occurs, scanning 32 MiB.
///
/// A naive first-byte scan keeps this correct for any needle, including
/// one inside a buffer that also contains zeros — which is most of RAM.
pub fn find_all(&self, needle: &[u8]) -> Vec<usize> {
if needle.is_empty() || needle.len() > self.0.len() {
return Vec::new();
}
let first = needle[0];
let mut out = Vec::new();
let mut i = 0usize;
while i + needle.len() <= self.0.len() {
if self.0[i] == first && &self.0[i..i + needle.len()] == needle {
out.push(i);
}
i += 1;
}
out
}
/// The first offset at which `needle` occurs.
pub fn find(&self, needle: &[u8]) -> Option<usize> {
self.find_all(needle).into_iter().next()
}
pub fn slice(&self, at: usize, len: usize) -> Option<&'a [u8]> {
self.0.get(at..at.checked_add(len)?)
}
/// A little-endian `i32` at `at`.
pub fn i32(&self, at: usize) -> Option<i32> {
self.slice(at, 4)
.map(|b| i32::from_le_bytes(b.try_into().unwrap()))
}
/// A little-endian `u32` at `at`.
pub fn u32(&self, at: usize) -> Option<u32> {
self.slice(at, 4)
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Fill with a non-zero byte: a zero-filled image makes any needle ending
/// in `0x00` match by accident, which is not what this is testing.
fn ram_with(needle: &[u8], at: usize) -> Vec<u8> {
let mut v = vec![0xaau8; EE_SIZE];
v[at..at + needle.len()].copy_from_slice(needle);
v
}
#[test]
fn only_a_32mib_image_is_accepted() {
EeRam::new(&[0u8; EE_SIZE]).expect("32 MiB is exactly right");
assert!(EeRam::new(&[0u8; EE_SIZE - 1]).is_err());
assert!(EeRam::new(&[]).is_err());
}
#[test]
fn find_reports_every_occurrence() {
let v = ram_with(&[0xde, 0xad, 0xbe, 0xef], 0x1000);
let ram = EeRam::new(&v).unwrap();
assert_eq!(ram.find(&[0xde, 0xad, 0xbe, 0xef]), Some(0x1000));
assert!(ram.find(&[0xde, 0xad, 0xbe, 0xef, 0x00]).is_none());
}
#[test]
fn an_empty_or_oversized_needle_finds_nothing() {
let v = vec![7u8; EE_SIZE];
let ram = EeRam::new(&v).unwrap();
assert!(ram.find(&[]).is_none());
// Longer than the image cannot occur inside it, however uniform the
// image is.
let too_long = vec![7u8; EE_SIZE + 1];
assert!(ram.find(&too_long).is_none());
}
#[test]
fn a_needle_at_the_very_end_is_found() {
let mut v = vec![0u8; EE_SIZE];
let n = [1u8, 2, 3, 4];
v[EE_SIZE - 4..].copy_from_slice(&n);
let ram = EeRam::new(&v).unwrap();
assert_eq!(ram.find(&n), Some(EE_SIZE - 4));
}
}
+38
View File
@@ -0,0 +1,38 @@
//! `gc-trace` — the PCSX2 ground-truth harness.
//!
//! This crate is a **dev tool**. It never ships and no shipped crate depends
//! on it; the dependency direction is the other way round (`tools/` may read
//! what the engine computes). Its job is the one the rest of the suite cannot
//! do: compare what the real game does against what we compute, from the same
//! disc data.
//!
//! Inputs, in order of convenience:
//!
//! * **PINE** — the live emulator, over its unix socket. Needs PCSX2 running
//! with PINE enabled. No savestate, no files.
//! * **a PCSX2 savestate** (`.p2s`) — a zip with zstd/deflate entries holding
//! `eeMemory.bin` and friends. Repeatable input: the file can be kept,
//! but it is personal data and is never committed.
//! * **a raw EE RAM dump** — 32 MiB, whatever produced it.
//!
//! The one comparison that exists so far is
//! [`compare-tfrag-light`](tfrag_cmp::compare): the game's lit per-vertex
//! tfrag colours in EE RAM against [`gc_engine::tfrag_light::light_tfrag`] over
//! the same disc data. See `docs/plan/pcsx2-verification.md`.
//!
//! **Findings reach tests only as numbers.** A test never reads a savestate, a
//! dump or a live emulator; when a comparison becomes a regression, distil the
//! facts it needs into a small fixture and test against that.
pub mod ee;
pub mod morph;
pub mod overlay_base;
pub mod p2s;
pub mod pine;
pub mod tfrag_cmp;
pub mod tie_verify;
pub mod zip;
pub use ee::EeRam;
pub use p2s::Savestate;
pub use tfrag_cmp::{compare, print_report, LevelInputs, Report};
+425
View File
@@ -0,0 +1,425 @@
//! The `gc-trace` command line.
//!
//! ```text
//! gc-trace compare-tfrag-light <unpacked-dir> --level N <src>
//! gc-trace info <src>
//! gc-trace dump-ee <src> [--out FILE]
//! gc-trace find <src> --bytes HEX
//! gc-trace read <src> --addr HEX --len N
//! gc-trace pine-info
//! gc-trace pine-savestate SLOT
//! ```
//!
//! `<src>` is where the memory comes from, and it is one of:
//!
//! | Flag | Reads |
//! |---|---|
//! | `--state FILE` | a PCSX2 `.p2s` savestate |
//! | `--state latest` | the newest state in PCSX2's folder |
//! | `--ee FILE` | a raw 32 MiB EE RAM dump |
//! | `--pine [slot]` | the running emulator over PINE |
use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use gc_trace::ee::EeRam;
use gc_trace::{compare, LevelInputs};
/// Compare every patch's positions in RAM against the disc's, and solve the
/// blend weight for the ones that moved.
fn verify_morph(dir: &std::path::Path, level: usize, src: &Source) -> Result<i32> {
let idx = std::fs::read(dir.join(format!("level{level}/core_index.bin")))
.with_context(|| format!("level{level}/core_index.bin"))?;
let core = std::fs::read(dir.join(format!("level{level}/core_data.bin")))
.with_context(|| format!("level{level}/core_data.bin"))?;
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let report = gc_trace::morph::verify(&idx, &core, level, &ram)?;
let mut text = String::new();
gc_trace::morph::print_report(&report, &mut text)?;
print!("{text}");
Ok(report.exit_code())
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
match run(&args) {
Ok(code) => std::process::exit(code),
Err(e) => {
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
}
/// Returns the process exit code.
fn run(args: &[String]) -> Result<i32> {
let Some(cmd) = args.first() else {
print!("{USAGE}");
return Ok(0);
};
match cmd.as_str() {
"info" => {
let src = Source::parse(&args[1..])?;
info(&src)
}
"dump-ee" => {
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let out = flag(&rest, "--out").unwrap_or_else(|| "ee.bin".into());
dump_ee(&src, &out)
}
"find" => {
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let hex = require(&rest, "--bytes")?;
find(&src, &hex)
}
"read" => {
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let addr = require(&rest, "--addr")?;
let len = require(&rest, "--len")?;
read(&src, &addr, &len)
}
"verify-overlay-base" => {
// gc-trace verify-overlay-base <unpacked-dir> <level> <src>
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let (dir, level) = match rest.as_slice() {
[dir, level, ..] => (PathBuf::from(dir), level.parse::<usize>().context("level")?),
_ => {
return Err(anyhow!(
"usage: gc-trace verify-overlay-base <unpacked-dir> <level> <src>"
))
}
};
let overlay = std::fs::read(dir.join(format!("level{level}/overlay.bin")))
.with_context(|| format!("level{level}/overlay.bin"))?;
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let report = gc_trace::overlay_base::verify(&overlay, &ram)?;
let mut text = String::new();
gc_trace::overlay_base::print_report(&report, &mut text)?;
println!("{text}");
Ok(i32::from(!report.names.iter().all(|&b| b)))
}
"verify-morph" => {
// gc-trace verify-morph <unpacked-dir> [--level N] <src>
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let dir = rest
.first()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("which unpacked level directory?"))?;
let level: usize = flag(&rest, "--level")
.unwrap_or_else(|| "0".into())
.parse()
.context("--level")?;
verify_morph(&dir, level, &src)
}
"verify-tie-records" => {
// gc-trace verify-tie-records <unpacked-dir> <level> <class> <lod> <src>
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let (dir, level, class, lod) = match rest.as_slice() {
[dir, level, class, lod, ..] => (
PathBuf::from(dir),
level.parse::<usize>().context("level")?,
class.parse::<usize>().context("class")?,
lod.parse::<usize>().context("lod")?,
),
_ => {
return Err(anyhow!(
"usage: gc-trace verify-tie-records <unpacked-dir> <level> <class> <lod> <src>"
))
}
};
let idx = std::fs::read(dir.join(format!("level{level}/core_index.bin")))
.with_context(|| format!("level{level}/core_index.bin"))?;
let core = std::fs::read(dir.join(format!("level{level}/core_data.bin")))
.with_context(|| format!("level{level}/core_data.bin"))?;
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let report = gc_trace::tie_verify::verify(&idx, &core, class, lod, &ram)?;
let mut text = String::new();
gc_trace::tie_verify::print_report(&report, &mut text)?;
println!("{text}");
Ok(report.exit_code())
}
"compare-tfrag-light" => {
let (src, rest) = Source::parse_with_rest(&args[1..])?;
let dir = rest
.first()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("which unpacked level directory?"))?;
let level: usize = flag(&rest, "--level")
.unwrap_or_else(|| "0".into())
.parse()
.context("--level")?;
compare_tfrag_light(&dir, level, &src)
}
"pine-info" => pine_info(),
"pine-savestate" => {
let slot: u8 = args
.get(1)
.ok_or_else(|| anyhow!("which slot?"))?
.parse()
.context("slot")?;
pine_savestate(slot)
}
_ => {
print!("{USAGE}");
Ok(0)
}
}
}
const USAGE: &str = "\
usage: gc-trace <command> ...
compare-tfrag-light <unpacked-dir> [--level N] <src>
verify-morph <unpacked-dir> [--level N] <src>
verify-tie-records <unpacked-dir> <level> <class> <lod> <src>
verify-overlay-base <unpacked-dir> <level> <src>
info <src>
dump-ee <src> [--out FILE]
find <src> --bytes HEX
read <src> --addr HEX --len N
pine-info
pine-savestate SLOT
<src> is one of:
--state FILE a PCSX2 .p2s savestate
--state latest the newest state in PCSX2's folder
--ee FILE a raw 32 MiB EE RAM dump
--pine [slot] the running emulator over PINE
";
/// Where a memory image comes from.
enum Source {
State(PathBuf),
Latest,
Raw(PathBuf),
Pine(u32),
}
impl Source {
/// The first three arguments after the command.
fn parse(args: &[String]) -> Result<Source> {
Ok(Self::parse_with_rest(args)?.0)
}
/// The source flag, and everything else.
///
/// The flag may sit anywhere: the usage line puts the level directory
/// first, which is the order people type, so a first-argument-only reader
/// would reject its own documented invocation. Whatever is left after the
/// flag and its value is passed back for the caller's own flags.
fn parse_with_rest(args: &[String]) -> Result<(Source, Vec<String>)> {
let Some(at) = args
.iter()
.position(|a| matches!(a.as_str(), "--state" | "--ee" | "--pine"))
else {
return Err(anyhow!("no source; pass --state FILE, --ee FILE or --pine"));
};
let (flag, tail) = (args[at].as_str(), &args[at + 1..]);
let (src, used) = match flag {
"--state" => {
let v = tail
.first()
.ok_or_else(|| anyhow!("--state needs a path or `latest`"))?;
let src = if v == "latest" {
Source::Latest
} else {
Source::State(PathBuf::from(v))
};
(src, 1)
}
"--ee" => {
let v = tail.first().ok_or_else(|| anyhow!("--ee needs a path"))?;
(Source::Raw(PathBuf::from(v)), 1)
}
// `--pine [slot]`: a following slot number is consumed, and
// anything else -- a directory, another flag -- is not.
_ => {
let slot = match tail.first() {
Some(s) if s.parse::<u32>().is_ok() => s.parse()?,
_ => gc_trace::pine::DEFAULT_SLOT,
};
let used = usize::from(tail.first().is_some_and(|s| s.parse::<u32>().is_ok()));
(Source::Pine(slot), used)
}
};
let mut rest: Vec<String> = args[..at].to_vec();
rest.extend_from_slice(&tail[used..]);
Ok((src, rest))
}
/// Load the RAM image this source names.
fn load(&self) -> Result<Vec<u8>> {
match self {
Source::State(p) => gc_trace::Savestate::open(p)
.and_then(|s| s.ee_ram_owned())
.with_context(|| format!("savestate {}", p.display())),
Source::Latest => {
let p = latest_state()?;
let bytes = gc_trace::Savestate::open(&p).and_then(|s| s.ee_ram_owned());
bytes.with_context(|| format!("savestate {} (latest)", p.display()))
}
Source::Raw(p) => {
let b = std::fs::read(p).with_context(|| format!("read {}", p.display()))?;
Ok(b)
}
Source::Pine(slot) => gc_trace::pine::Pine::connect_slot(*slot)
.and_then(|p| p.dump_ee())
.context("PINE dump"),
}
}
}
fn latest_state() -> Result<PathBuf> {
let dir = pcsx2_state_dir();
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = entry.path();
if p.extension().map(|e| e == "p2s").unwrap_or(false) {
if let Ok(meta) = entry.metadata() {
if let Ok(t) = meta.modified() {
if newest.as_ref().is_none_or(|(nt, _)| t > *nt) {
newest = Some((t, p));
}
}
}
}
}
newest
.map(|(_, p)| p)
.ok_or_else(|| anyhow!("no .p2s savestates in {}", dir.display()))
}
/// PCSX2's savestate folder, per platform.
fn pcsx2_state_dir() -> PathBuf {
if cfg!(target_os = "macos") {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join("Library/Application Support/PCSX2/sstates")
} else {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".config")
});
base.join("PCSX2/sstates")
}
}
fn info(src: &Source) -> Result<i32> {
match src {
Source::State(p) => info_state(p)?,
Source::Latest => info_state(&latest_state()?)?,
Source::Raw(_) => {
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
println!("raw EE RAM image, {} bytes", ram.bytes().len());
}
Source::Pine(slot) => {
let pine = gc_trace::pine::Pine::connect_slot(*slot)?;
println!("PINE: version {}", pine.version()?);
println!("PINE: title {}", pine.title()?);
}
}
Ok(0)
}
fn info_state(p: &std::path::Path) -> Result<()> {
let state = gc_trace::Savestate::open(p)?;
println!(
"{}: v{:#010x} build {}",
p.display(),
state.version.unwrap_or(0),
state.build
);
for e in state.entries() {
println!(" {e}");
}
Ok(())
}
fn dump_ee(src: &Source, out: &str) -> Result<i32> {
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
std::fs::write(out, ram.bytes()).with_context(|| format!("write {out}"))?;
println!("wrote {} bytes to {out}", ram.bytes().len());
Ok(0)
}
fn find(src: &Source, hex: &str) -> Result<i32> {
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let needle = parse_hex(hex)?;
for at in ram.find_all(&needle) {
println!("{at:#x}");
}
Ok(0)
}
fn read(src: &Source, addr_hex: &str, len_hex: &str) -> Result<i32> {
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let addr = usize::from_str_radix(addr_hex.trim_start_matches("0x"), 16)?;
let len = usize::from_str_radix(len_hex.trim_start_matches("0x"), 16)?;
let slice = ram
.slice(addr, len)
.ok_or_else(|| anyhow!("{addr:#x}+{len:#x} runs past the RAM image"))?;
for (i, row) in slice.chunks(16).enumerate() {
let hex: Vec<String> = row.iter().map(|b| format!("{b:02x}")).collect();
println!("{:08x} {}", addr + i * 16, hex.join(" "));
}
Ok(0)
}
fn compare_tfrag_light(dir: &std::path::Path, level: usize, src: &Source) -> Result<i32> {
let inputs = LevelInputs::load(dir, level)?;
let bytes = src.load()?;
let ram = EeRam::new(&bytes)?;
let report = compare(&inputs, level, &ram)?;
let mut text = String::new();
gc_trace::print_report(&report, &mut text)?;
println!("{text}");
Ok(report.exit_code())
}
fn pine_info() -> Result<i32> {
let pine = gc_trace::pine::Pine::connect()?;
println!("version: {}", pine.version()?);
println!("title: {}", pine.title()?);
println!(
"PINE is up on {}",
gc_trace::pine::socket_path(gc_trace::pine::DEFAULT_SLOT).display()
);
Ok(0)
}
fn pine_savestate(slot: u8) -> Result<i32> {
let pine = gc_trace::pine::Pine::connect()?;
pine.save_state(slot)?;
println!("savestate requested for slot {slot}");
Ok(0)
}
/// Parse `hex` bytes, ignoring whitespace.
fn parse_hex(s: &str) -> Result<Vec<u8>> {
let compact: String = s.chars().filter(|c| !c.is_whitespace()).collect();
if compact.len() % 2 != 0 {
return Err(anyhow!("hex string has an odd length"));
}
(0..compact.len())
.step_by(2)
.map(|i| u8::from_str_radix(&compact[i..i + 2], 16).map_err(Into::into))
.collect()
}
/// Look up `--flag VALUE` in the rest.
fn flag(args: &[String], name: &str) -> Option<String> {
let at = args.iter().position(|a| a == name)?;
args.get(at + 1).cloned()
}
fn require(args: &[String], name: &str) -> Result<String> {
flag(args, name).ok_or_else(|| anyhow!("missing {name}"))
}
+374
View File
@@ -0,0 +1,374 @@
//! Verifying the tfrag morph against RAM.
//!
//! `resolve_vertex`'s formula is a blend toward the **parent vertices'
//! midpoint**, and reading a weight back out of RAM was reported as not working
//! because the positions live inside regenerated VU packets. There is a route
//! in that does not need the camera distance:
//!
//! * the loader regenerates each patch's packet stream for the tier it
//! selected, and it does that to **every** patch -- but only the entries that
//! actually morph change value;
//! * so on positions, disc versus RAM is identity where nothing morphs, and a
//! difference where it does. The diff names the morphed vertices, which is
//! the correspondence the problem lacked;
//! * with the vertex named, its morph link gives the two parents from the disc
//! side, and the observed RAM value gives the blend weight as
//! `u = (Q - P) / (mid - P)` per component.
//!
//! Three components give three estimates of one weight. If the formula is right
//! they agree and land in 0..1; if the game blends toward anything else they
//! disagree, and the spread is the finding.
use anyhow::{anyhow, Context, Result};
use gc_formats::tfrag::{
parse_tfrag, tfrag_lod_link, tfrag_morph_parents, TfragHeader, TfragsBlock, TFRAG_HEADER_SIZE,
};
use crate::ee::EeRam;
/// One patch's morph verdict.
#[derive(Debug, Clone)]
pub struct PatchMorph {
pub patch: usize,
pub positions_disc: usize,
pub positions_ram: usize,
pub identical: usize,
pub changed: usize,
/// Bytes that differ between the two block copies, classified by whether
/// they fall inside a V3_16 position unpack.
pub differing_bytes: usize,
pub differing_in_position_ranges: usize,
pub position_range_bytes: usize,
}
/// A single solved weight, with the spread across the three components.
#[derive(Debug, Clone)]
pub struct SolvedWeight {
pub patch: usize,
pub vertex: usize,
pub base: [i32; 3],
pub ram: [i32; 3],
pub mid: [i32; 3],
/// `u` solved per component. `None` where the component does not move.
pub per_component: [Option<f32>; 3],
pub spread: f32,
pub weight: f32,
}
#[derive(Debug, Default)]
pub struct Report {
pub level: usize,
pub block_base: usize,
pub patch_count: usize,
pub patches: Vec<PatchMorph>,
pub solved: Vec<SolvedWeight>,
/// Solved weights that are consistent and inside 0..1.
pub agreeing: usize,
/// Disc-side totals across every patch, for the diff classification.
pub differing_bytes: usize,
pub differing_in_positions: usize,
pub position_range_bytes: usize,
/// Of the differing bytes, how many land in another unpack's payload and
/// how many in a VIF command word.
pub differing_in_other_unpacks: usize,
pub differing_in_command_words: usize,
}
impl Report {
pub fn exit_code(&self) -> i32 {
i32::from(self.agreeing < 4)
}
}
pub fn print_report(r: &Report, out: &mut dyn std::fmt::Write) -> std::fmt::Result {
writeln!(
out,
"level {}: {} patches at block base {:#x}",
r.level, r.patch_count, r.block_base
)?;
let discs: usize = r.patches.iter().map(|p| p.positions_disc).sum();
let rams: usize = r.patches.iter().map(|p| p.positions_ram).sum();
let same: usize = r.patches.iter().map(|p| p.identical).sum();
let changed: usize = r.patches.iter().map(|p| p.changed).sum();
writeln!(
out,
" positions: disc {discs}, ram {rams}, identical {same}, changed {changed}"
)?;
writeln!(
out,
" solved weights: {} ({} agree)",
r.solved.len(),
r.agreeing
)?;
writeln!(
out,
" block bytes differing: {} -- {} in a position unpack ({} position bytes), \
{} in another unpack, {} in command words",
r.differing_bytes,
r.differing_in_positions,
r.position_range_bytes,
r.differing_in_other_unpacks,
r.differing_in_command_words
)?;
for s in r.solved.iter().take(8) {
writeln!(
out,
" patch {} v{}: base {:?} -> mid {:?}, ram {:?}, u {} pc {:?}",
s.patch, s.vertex, s.base, s.mid, s.ram, s.weight, s.per_component
)?;
}
Ok(())
}
/// Where the tfrags block sits in RAM, and the header table inside it.
fn locate(region: &[u8], ram: &EeRam, table_off: usize, count: usize) -> Result<usize> {
let key = &region[table_off..table_off + 16];
let mut best: Option<(usize, usize)> = None;
for cand in ram.find_all(key) {
if cand < table_off {
continue;
}
let base = cand - table_off;
let Some(hdr) = ram.slice(base, 0x10) else {
continue;
};
if hdr != &region[..0x10.min(region.len())] {
continue;
}
let mut matched = 0usize;
for i in 0..count.min(64) {
let at = i * TFRAG_HEADER_SIZE;
let d = region.get(table_off + at..table_off + at + TFRAG_HEADER_SIZE);
let r = ram.slice(cand + at, TFRAG_HEADER_SIZE);
if let (Some(a), Some(b)) = (d, r) {
if invariant_equal(a, b) {
matched += 1;
}
}
}
if best.is_none_or(|(_, m)| matched > m) {
best = Some((base, matched));
}
}
match best {
Some((base, m)) if m == count.min(64) => Ok(base),
Some((base, m)) => Err(anyhow!(
"tfrags block at {base:#x} matches {m} of {} headers; refusing a partial match",
count.min(64)
)),
None => Err(anyhow!("no tfrags block in RAM matches this level")),
}
}
fn invariant_equal(disc: &[u8], ram: &[u8]) -> bool {
let keep = |j: usize| !(0x10..0x14).contains(&j) && !(0x3a..0x3c).contains(&j);
disc.iter()
.zip(ram.iter())
.enumerate()
.filter(|&(j, _)| keep(j))
.all(|(_, (a, b))| a == b)
}
pub fn verify(disc_index: &[u8], disc_core: &[u8], level: usize, ram: &EeRam) -> Result<Report> {
let tfrags_ofs = i32_at(disc_index, 0x08).context("tfrags offset")?;
let region_end = [0x0c_usize, 0x10, 0x14]
.iter()
.filter_map(|&o| i32_at(disc_index, o))
.filter(|&v| v > tfrags_ofs)
.min()
.unwrap_or(disc_core.len() as i32);
let region = &disc_core[tfrags_ofs as usize..region_end as usize];
let block = TfragsBlock::parse(region).context("tfrags block")?;
let table_off = block.table_offset as usize;
let count = block.tfrag_count as usize;
if count == 0 {
anyhow::bail!("level{level} has no tfrags");
}
let base = locate(region, ram, table_off, count)?;
let table = base + table_off;
let _ = level;
let mut report = Report {
block_base: base,
patch_count: count,
..Default::default()
};
// Each patch's extent is the gap to the next one's `data`, which is exact
// and needs no guess about which header field marks the patch's end.
// Spans are `(start, length)` relative to the start of the disc region.
let mut starts: Vec<(usize, usize)> = (0..count)
.filter_map(|i| {
let v = i32_at(region, table_off + i * TFRAG_HEADER_SIZE + 0x10).filter(|&d| d > 0)?;
// `data` is relative to the table, and these spans index the
// region, so carry the table base along.
Some((table_off + v as usize, i))
})
.collect();
starts.sort_unstable();
starts.dedup_by_key(|&mut (v, _)| v);
let region_len = region.len();
let span_of = |i: usize| -> Option<(usize, usize)> {
let k = starts.iter().position(|&(_, j)| j == i)?;
let (start, _) = starts[k];
let end = starts.get(k + 1).map(|&(v, _)| v).unwrap_or(region_len);
Some((start, end.saturating_sub(start)))
};
for i in 0..count {
let at = i * TFRAG_HEADER_SIZE;
let disc_h = TfragHeader::parse(
region
.get(table_off + at..table_off + at + TFRAG_HEADER_SIZE)
.ok_or_else(|| anyhow!("tfrag header {i} is out of bounds"))?,
)
.with_context(|| format!("tfrag {i} header"))?;
let ram_h = TfragHeader::parse(
ram.slice(table + at, TFRAG_HEADER_SIZE)
.ok_or_else(|| anyhow!("tfrag {i} header is out of RAM"))?,
)
.with_context(|| format!("tfrag {i} RAM header"))?;
if disc_h.vert_count == 0 || disc_h.data == 0 {
continue;
}
// The patch's own extent, from the gap to the next one.
let Some((span_start, span_len)) = span_of(i) else {
continue;
};
let disc_block = region
.get(span_start..span_start + span_len)
.ok_or_else(|| anyhow!("tfrag {i} disc data runs past the region"))?;
let disc_geo = match parse_tfrag(disc_block, &disc_h) {
Ok(g) => g,
Err(_) => continue,
};
// The RAM copy: behind the relocated pointer, bounded by the region's
// RAM end. `parse_tfrag` indexes from the start of what it is given.
let ram_data = ram_h.data as usize;
let region_end_ram = base + region.len();
if ram_data < base {
anyhow::bail!("tfrag {i} data pointer {ram_data:#x} is before the block");
}
if ram_data + span_len > region_end_ram || ram_data + span_len > ram.bytes().len() {
continue;
}
let block_ram = &ram.bytes()[ram_data..ram_data + span_len];
let ram_geo = match parse_tfrag(block_ram, &ram_h) {
Ok(g) => g,
Err(_) => continue,
};
let dp = disc_geo.positions.clone();
let rp = ram_geo.positions;
let n = dp.len().min(rp.len());
let mut identical = 0usize;
let mut changed = 0usize;
for v in 0..n {
if dp[v] == rp[v] {
identical += 1;
} else {
changed += 1;
}
}
// Classify the raw byte diff. The two block copies start at their own
// absolute addresses but the header's field offsets are invariant, so a
// range from the disc walk indexes the RAM copy the same way.
let ranges = &disc_geo.position_ranges;
let other = &disc_geo.other_ranges;
let position_range_bytes: usize = ranges.iter().map(|&(a, b)| b.saturating_sub(a)).sum();
let mut differing_bytes = 0usize;
let mut differing_in_position_ranges = 0usize;
let mut differing_in_other_unpacks = 0usize;
let mut differing_in_command_words = 0usize;
let cmp_len = disc_block.len().min(block_ram.len());
for off in 0..cmp_len {
if disc_block[off] == block_ram[off] {
continue;
}
differing_bytes += 1;
if ranges.iter().any(|&(a, b)| off >= a && off < b) {
differing_in_position_ranges += 1;
} else if other.iter().any(|&(a, b)| off >= a && off < b) {
differing_in_other_unpacks += 1;
} else {
differing_in_command_words += 1;
}
}
report.differing_bytes += differing_bytes;
report.differing_in_positions += differing_in_position_ranges;
report.position_range_bytes += position_range_bytes;
report.differing_in_other_unpacks += differing_in_other_unpacks;
report.differing_in_command_words += differing_in_command_words;
report.patches.push(PatchMorph {
patch: i,
positions_disc: dp.len(),
positions_ram: rp.len(),
identical,
changed,
differing_bytes,
differing_in_position_ranges,
position_range_bytes,
});
// Every changed position whose morph link resolves gives a weight.
for v in 0..n {
if dp[v] == rp[v] {
continue;
}
let Some(link) = tfrag_lod_link(&disc_geo, v) else {
continue;
};
if !link.morphs {
continue;
}
let Some((p1, p2)) = tfrag_morph_parents(&disc_geo, v) else {
continue;
};
let a = dp[p1];
let b = dp[p2];
let mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
let base_pos = dp[v];
let ram_pos = rp[v];
let mut per_component = [None, None, None];
for k in 0..3 {
let denom = (mid[k] - base_pos[k]) as f32;
if denom.abs() > f32::EPSILON {
per_component[k] = Some((ram_pos[k] - base_pos[k]) as f32 / denom);
}
}
let present: Vec<f32> = per_component.iter().flatten().copied().collect();
if present.is_empty() {
continue;
}
let weight = present.iter().sum::<f32>() / present.len() as f32;
let spread =
present.iter().fold(0.0f32, |a, &u| a + (u - weight).abs()) / present.len() as f32;
let in_band = present.iter().all(|&u| (-0.05..=1.05).contains(&u));
if in_band && spread < 0.02 {
report.agreeing += 1;
}
report.solved.push(SolvedWeight {
patch: i,
vertex: v,
base: base_pos,
ram: ram_pos,
mid,
per_component,
spread,
weight,
});
}
}
Ok(report)
}
fn i32_at(bytes: &[u8], at: usize) -> Option<i32> {
bytes
.get(at..at + 4)
.map(|b| i32::from_le_bytes(b.try_into().unwrap()))
}
+189
View File
@@ -0,0 +1,189 @@
//! The overlay's load base, confirmed against the running game.
//!
//! The overlay's own profiler table carries absolute RAM addresses pointing
//! back at its strings, so the base can be derived from disc bytes (see
//! `overlay_load_base_from_the_profiler_table` in the disc tests). This module
//! does the part only the emulator can do: read a savestate and check the
//! bytes are where the base predicts.
//!
//! The check matters because the two failures are different. A wrong base puts
//! the names somewhere else and finds nothing; a right base that finds nothing
//! means the loader relocated the table. Distinguishing those is the whole
//! point of confirming rather than deriving.
use anyhow::{anyhow, Result};
use gc_formats::overlay::OverlayHeader;
use crate::ee::EeRam;
/// What the check found.
#[derive(Debug, Clone)]
pub struct OverlayBaseReport {
/// The overlay's payload load base, from its own header.
pub load_base: u64,
/// Bytes of the overlay compared against RAM.
pub overlay_bytes: usize,
/// Bytes byte-identical at the predicted addresses.
pub verbatim: usize,
/// The four profiler names, and whether RAM held each at its predicted
/// address.
pub names: Vec<bool>,
}
impl OverlayBaseReport {
/// Fraction of the overlay the loader left in place.
pub fn verbatim_fraction(&self) -> f64 {
self.verbatim as f64 / self.overlay_bytes.max(1) as f64
}
}
/// Check one level's overlay against a RAM image.
///
/// `overlay` is the level's `overlay.bin`; `ram` is a 32 MiB EE RAM image taken
/// while that level is loaded.
pub fn verify(overlay: &[u8], ram: &EeRam) -> Result<OverlayBaseReport> {
// The profiler table's distinctive name run, and the four names before it.
let Some(p) = overlay.windows(15).position(|w| w == b"cam+chr\0volume\0") else {
return Err(anyhow!(
"the overlay carries no profiler name run; is this an overlay?"
));
};
let names_at = p - 16;
// The base is computed after the table is found, so an all-zero overlay
// used as a negative control does not underflow first.
let hdr = OverlayHeader::parse(overlay)?;
let load_base = hdr.ram_address(0);
let mut names = Vec::new();
for k in 0..4usize {
let at = hdr.ram_address(names_at + k * 8) as usize;
let want = &overlay[names_at + k * 8..names_at + k * 8 + 8];
names.push(ram.slice(at, 8) == Some(want));
}
// How much of the overlay did the loader leave in place? The base locates
// the image; this measures whether it also locates the tables inside it.
let base = load_base as usize;
let image = ram.slice(base, overlay.len()).unwrap_or(&[]);
let verbatim = overlay
.iter()
.zip(image.iter())
.filter(|(a, b)| *a == *b)
.count();
Ok(OverlayBaseReport {
load_base,
overlay_bytes: overlay.len(),
verbatim,
names,
})
}
/// Render a report for a terminal.
pub fn print_report(r: &OverlayBaseReport, out: &mut dyn std::fmt::Write) -> std::fmt::Result {
writeln!(
out,
"overlay load base: {} ({:#x})",
r.load_base, r.load_base
)?;
writeln!(
out,
" profiler names at the predicted addresses: {}/{}",
r.names.iter().filter(|&&b| b).count(),
r.names.len()
)?;
writeln!(
out,
" overlay bytes byte-identical in RAM: {}/{} ({:.2}%)",
r.verbatim,
r.overlay_bytes,
100.0 * r.verbatim_fraction()
)?;
if r.names.iter().all(|&b| b) {
writeln!(
out,
" the base is confirmed: every name is where the overlay's own table says"
)
} else {
writeln!(
out,
" the base did NOT place every name where the overlay's table says"
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ee::EE_SIZE;
/// An overlay carrying the profiler table, whose four names sit at
/// `names_at` and whose four addresses point back at them.
fn overlay() -> Vec<u8> {
let mut v = vec![0u8; 0x6000];
// Header: field_00 is the payload's load address.
v[0..4].copy_from_slice(&1_735_808u32.to_le_bytes());
let names_at = 0x5310usize;
for (k, name) in ["normal\0", "camera\0", "cam+chr", "volume\0"]
.iter()
.enumerate()
{
let mut slot = [0u8; 8];
slot[..name.len()].copy_from_slice(name.as_bytes());
v[names_at + k * 8..names_at + k * 8 + 8].copy_from_slice(&slot);
}
// The address run, pointing back at the four names.
let addrs_at = names_at + 4 * 8;
for k in 0..4usize {
let a = 1_735_808u32 - 16 + (names_at + k * 8) as u32;
v[addrs_at + k * 4..addrs_at + k * 4 + 4].copy_from_slice(&a.to_le_bytes());
}
v
}
#[test]
fn reads_the_base_and_relocates_the_names() {
let ov = overlay();
let hdr = OverlayHeader::parse(&ov).unwrap();
assert_eq!(hdr.field_00, 1_735_808);
assert_eq!(hdr.ram_address(0), 1_735_792);
// The derived base matches the header word less the header's size.
let addrs_at = 0x5310 + 32;
let a0 = u32::from_le_bytes(ov[addrs_at..addrs_at + 4].try_into().unwrap());
assert_eq!(a0 as u64 - 0x5310, hdr.ram_address(0));
}
#[test]
fn verifies_against_a_ram_image() {
let ov = overlay();
let mut ram = vec![0u8; EE_SIZE];
let base = 1_735_792usize;
// The loader places overlay offset 0 at the base.
ram[base..base + ov.len()].copy_from_slice(&ov);
let r = verify(&ov, &EeRam::new(&ram).unwrap()).unwrap();
assert!(r.names.iter().all(|&b| b), "{:?}", r.names);
assert_eq!(r.overlay_bytes, ov.len());
assert_eq!(r.verbatim, ov.len());
}
#[test]
fn an_overlay_without_the_profiler_table_is_an_error() {
// All zeros: no profiler table, and a field_00 of 0 that would
// underflow if the base were computed before the table were found.
let ov = vec![0u8; 0x6000];
assert!(verify(&ov, &EeRam::new(&vec![0u8; EE_SIZE]).unwrap()).is_err());
}
#[test]
fn a_wrong_base_finds_nothing_and_says_so() {
// An empty RAM: the names are not at the predicted addresses, and only
// the fixture's zero bytes line up with the zeros there.
let ov = overlay();
let nonzero = ov.iter().filter(|&&b| b != 0).count();
let ram = vec![0u8; EE_SIZE];
let r = verify(&ov, &EeRam::new(&ram).unwrap()).unwrap();
assert_eq!(r.names, vec![false; 4]);
assert_eq!(r.verbatim, ov.len() - nonzero);
}
}
+182
View File
@@ -0,0 +1,182 @@
//! A PCSX2 savestate (`.p2s`), read for its EE RAM.
//!
//! The format is a zip archive written by libzip, whose entries are the
//! emulator's state: `eeMemory.bin` is main RAM, and there is a version id
//! entry that says which PCSX2 wrote it. All of this is read from the file,
//! never assumed: the version check and the size check are what stop a
//! plausible-looking wrong read — a partial file, a state from a much older
//! PCSX2 — from being compared against as though it were the game's RAM.
//!
//! Compression is whatever PCSX2 chose (zstd by default, deflate or stored if
//! the user configured otherwise), which is [`crate::zip`]'s job.
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use crate::zip::Zip;
/// The savestate version PCSX2 v2.x writes; the high 16 bits are the load
/// gate, the low 16 the patch level.
const SAVE_VERSION: u32 = 0x9A59_0000;
const VERSION_ID: &str = "PCSX2 Savestate Version.id";
const EE_MEMORY: &str = "eeMemory.bin";
/// A loaded savestate.
pub struct Savestate {
bytes: Vec<u8>,
zip: Zip,
/// The version word from the version-id entry, if present.
pub version: Option<u32>,
/// The version string that follows it.
pub build: String,
}
impl Savestate {
/// Open and fully validate a savestate file.
pub fn open(path: &Path) -> Result<Savestate> {
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
let state = Self::from_bytes(bytes)?;
state
.validate()
.with_context(|| format!("{}: not a supported savestate", path.display()))?;
Ok(state)
}
/// The version gate. A state whose version entry is missing or from a
/// different major line may have a different entry layout, so stop here
/// rather than guess which entry is the RAM.
///
/// Exposed so a fixture is checked the same way a real file is; `open`
/// calls exactly this.
fn validate(&self) -> Result<()> {
let v = self
.version
.ok_or_else(|| anyhow!("no {VERSION_ID} entry; not a PCSX2 v2.x savestate"))?;
if v & 0xFFFF_0000 != SAVE_VERSION {
return Err(anyhow!(
"savestate version {v:#010x}, this reader handles {SAVE_VERSION:#010x} (PCSX2 v2.8.x)"
));
}
Ok(())
}
/// Build from an in-memory image.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Savestate> {
let zip = Zip::parse(&bytes)?;
let (version, build) = match zip.find(VERSION_ID) {
Some(_) => {
let id = zip.read(&bytes, VERSION_ID)?;
if id.len() < 4 {
return Err(anyhow!("version id entry is {} bytes", id.len()));
}
// The build string is read out of the first 32 bytes after the
// version word. A longer entry carries more after it, which is
// not read: only the version is load-bearing here.
(
Some(u32::from_le_bytes(id[..4].try_into().unwrap())),
String::from_utf8_lossy(&id[4..36.min(id.len())])
.trim_end_matches('\0')
.to_string(),
)
}
None => (None, String::new()),
};
Ok(Savestate {
bytes,
zip,
version,
build,
})
}
/// The entry names, which is also a cheap sanity check on what is here.
pub fn entries(&self) -> Vec<&str> {
self.zip.entries().iter().map(|e| e.name.as_str()).collect()
}
/// One decompressed entry.
pub fn entry(&self, name: &str) -> Result<Vec<u8>> {
self.zip.read(&self.bytes, name)
}
/// The decompressed `eeMemory.bin` as an owned buffer.
///
/// Owned rather than borrowed: the caller wants a stable `EeRam` over the
/// bytes, and the zip entry's payload cannot outlive this call anyway.
pub fn ee_ram_owned(&self) -> Result<Vec<u8>> {
let ram = self.entry(EE_MEMORY)?;
Ok(ram)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::zip::LOCAL_HEADER;
fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
for (name, payload) in entries {
out.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // stored
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&crate::zip::crc32(payload).to_le_bytes());
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(payload);
}
out
}
fn version_id() -> Vec<u8> {
let mut v = SAVE_VERSION.to_le_bytes().to_vec();
v.extend_from_slice(b"v2.8.2");
v.resize(36, 0);
v
}
#[test]
fn a_state_round_trips_its_ram() {
let ram = vec![0x5au8; 16];
let z = build_zip(&[(VERSION_ID, &version_id()), (EE_MEMORY, &ram)]);
let state = Savestate::from_bytes(z).expect("should read");
assert_eq!(state.version, Some(SAVE_VERSION));
assert!(state.build.starts_with("v2.8"));
assert_eq!(state.entry(EE_MEMORY).unwrap(), ram);
}
#[test]
fn a_foreign_savestate_version_is_an_error() {
let bad = 0x9A3B_0000u32; // a future major line
let mut v = bad.to_le_bytes().to_vec();
v.resize(36, 0);
let z = build_zip(&[(VERSION_ID, &v), (EE_MEMORY, &[0u8; 8])]);
let state = Savestate::from_bytes(z).expect("reads the archive");
assert_eq!(state.version, Some(bad));
let err = state.validate().unwrap_err().to_string();
assert!(err.contains("version"), "got {err}");
// The gate reads the high half only: a newer patch level of the same
// major line is a state this reader handles.
let patched = 0x9A59_0001u32;
let mut v = patched.to_le_bytes().to_vec();
v.resize(36, 0);
let z = build_zip(&[(VERSION_ID, &v), (EE_MEMORY, &[0u8; 8])]);
assert!(Savestate::from_bytes(z).unwrap().validate().is_ok());
}
#[test]
fn a_missing_version_entry_is_reported_not_guessed() {
let z = build_zip(&[(EE_MEMORY, &[0u8; 8])]);
let state = Savestate::from_bytes(z).unwrap();
assert_eq!(state.version, None);
assert!(state.validate().is_err());
}
}
+189
View File
@@ -0,0 +1,189 @@
//! PINE: PCSX2's live debug protocol.
//!
//! PCSX2 exposes a unix socket (`$XDG_RUNTIME_DIR/pcsx2.sock` on Linux,
//! `$TMPDIR` on macOS, `/tmp/pcsx2.sock` otherwise, `.N` appended for a
//! non-default slot) that answers framed messages with the emulator's own
//! memory. The framing is simple and versions of it appear in several
//! clients, so it is spelled out from the v2.8.2 sources:
//!
//! ```text
//! request: u32 length | command bytes, command after command
//! reply: u32 length | u8 status | one reply per command, in order
//! ```
//!
//! A read is `u8 MsgRead64` followed by a `u32` address and answers with the
//! eight bytes at that address; commands batch, so a 32 MiB dump is 84
//! round-trips of 50,000 reads each, which is the reply-size ceiling.
//!
//! Live reads mean the input is whatever the emulator is doing *right now*.
//! That makes this mode excellent for exploring and poor for a repeatable
//! test, which is why the harness also reads savestates (`crate::p2s`).
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
/// PCSX2's default slot.
pub const DEFAULT_SLOT: u32 = 28011;
const MSG_READ64: u8 = 3;
const MSG_VERSION: u8 = 8;
const MSG_SAVE_STATE: u8 = 9;
const MSG_TITLE: u8 = 0xB;
const MSG_STATUS: u8 = 0xF;
/// Reads per batched message. The reply budget is 450,000 bytes, so 50,000
/// eight-byte reads (400,000 bytes of payload plus framing) is the largest
/// batch that cannot overflow it.
const BATCH: usize = 50_000;
/// The emulator's state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Running,
Paused,
Shutdown,
}
/// A connected PINE session.
pub struct Pine {
stream: UnixStream,
}
impl Pine {
/// Connect to the socket for `slot`.
pub fn connect_slot(slot: u32) -> Result<Pine> {
let path = socket_path(slot);
let stream = UnixStream::connect(&path).with_context(|| {
format!(
"cannot connect to PINE at {}\n\
is PCSX2 running with PINE enabled (Settings > Advanced)?",
path.display()
)
})?;
Ok(Pine { stream })
}
/// Connect at the default slot.
pub fn connect() -> Result<Pine> {
Self::connect_slot(DEFAULT_SLOT)
}
/// One framed round-trip; `payload` is the concatenated commands.
fn roundtrip(&self, payload: &[u8]) -> Result<Vec<u8>> {
let mut msg = Vec::with_capacity(4 + payload.len());
msg.extend_from_slice(&((4 + payload.len()) as u32).to_le_bytes());
msg.extend_from_slice(payload);
let mut stream = &self.stream;
stream.write_all(&msg).context("PINE write")?;
let mut header = [0u8; 5];
stream
.read_exact(&mut header)
.context("PINE reply header")?;
let size = u32::from_le_bytes(header[..4].try_into().unwrap()) as usize;
let status = header[4];
if status != 0 {
return Err(anyhow!("PINE refused the message (status {status:#x})"));
}
let mut body = vec![0u8; size - 5];
stream.read_exact(&mut body).context("PINE reply body")?;
Ok(body)
}
/// The emulator's build string, which is how the protocol is discovered.
pub fn version(&self) -> Result<String> {
let body = self.roundtrip(&[MSG_VERSION])?;
Ok(String::from_utf8_lossy(&body)
.trim_end_matches('\0')
.to_string())
}
pub fn title(&self) -> Result<String> {
let body = self.roundtrip(&[MSG_TITLE])?;
Ok(String::from_utf8_lossy(&body)
.trim_end_matches('\0')
.to_string())
}
/// Read `count` u64s starting at `addr`.
///
/// Addresses are physical: EE RAM is 0..32 MiB, so a physical address is
/// what this takes and what the game stores once its pointers are
/// relocated.
pub fn read64(&self, addr: u32, count: usize) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(count * 8);
let mut done = 0usize;
while done < count {
let n = (count - done).min(BATCH);
let mut payload = Vec::with_capacity(n * 5);
for i in 0..n {
payload.push(MSG_READ64);
payload.extend_from_slice(&(addr + (done + i) as u32 * 8).to_le_bytes());
}
out.extend_from_slice(&self.roundtrip(&payload)?);
done += n;
}
Ok(out)
}
/// All of EE RAM, which is what the comparisons want.
pub fn dump_ee(&self) -> Result<Vec<u8>> {
self.read64(0, crate::ee::EE_SIZE / 8)
}
pub fn status(&self) -> Result<Status> {
let body = self.roundtrip(&[MSG_STATUS])?;
match u32::from_le_bytes(body[..4].try_into().unwrap()) {
0 => Ok(Status::Running),
1 => Ok(Status::Paused),
_ => Ok(Status::Shutdown),
}
}
/// Ask PCSX2 to write a savestate to `slot`. Saving happens on the
/// emulator's own thread, so the state lands shortly after this returns.
pub fn save_state(&self, slot: u8) -> Result<()> {
self.roundtrip(&[MSG_SAVE_STATE, slot])?;
Ok(())
}
}
/// The socket path for a slot, following PCSX2's own resolution order.
pub fn socket_path(slot: u32) -> PathBuf {
let base = std::env::var_os("XDG_RUNTIME_DIR")
.or_else(|| std::env::var_os("TMPDIR"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
let mut path = base.join("pcsx2.sock");
if slot != DEFAULT_SLOT {
path = PathBuf::from(format!("{}.{slot}", path.display()));
}
path
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_socket_path_has_the_slot_suffix_only_when_needed() {
let path = socket_path(DEFAULT_SLOT);
assert!(path.ends_with("pcsx2.sock"));
let other = socket_path(28012);
assert!(other.ends_with("pcsx2.sock.28012"));
}
#[test]
fn a_reply_is_framed_length_then_status_then_body() {
// A failed command is `05 000000 FF` on the wire; the parser reads
// exactly that shape, so check it by constructing the reply the same
// way the reader does.
let mut reply: Vec<u8> = 0x05u32.to_le_bytes().to_vec();
reply.push(0xFF);
assert_eq!(reply.len(), 5);
assert_eq!(u32::from_le_bytes(reply[..4].try_into().unwrap()), 5);
assert_eq!(reply[4], 0xFF);
}
}
+597
View File
@@ -0,0 +1,597 @@
//! The tfrag lighting comparison: the game's own bytes against ours.
//!
//! `LightTfrags` overwrites every tfrag's RGBA block in EE RAM with its lit
//! per-vertex colours, so after a level loads the RAM holds the answer. The
//! comparison is:
//!
//! 1. **Locate** the tfrags block in RAM by content, never by a fixed
//! address — the load address differs per level and per run. The tfrag-0
//! bounding sphere is the search key; a candidate is only accepted when
//! *every* header matches the disc on the bytes the game leaves alone
//! (all but the relocated `data` pointer and the runtime occlusion
//! index) and the `data` pointers are relocated exactly as
//! `table + disc_data`.
//! 2. **Locate the light bank** the same way: the bytes of the level's
//! gameplay light section, searched in RAM. It is compared against the
//! disc's so a parse error and a maths error are distinguishable.
//! 3. **Run the pass** over the same disc data with
//! [`gc_engine::tfrag_light::light_tfrag`].
//! 4. **Diff** per channel, per tfrag, bucketed by vertex class so a
//! mismatch in the blend path (which is the only path `rsqrt` touches)
//! separates from one in the plain path.
//!
//! The pass-ran check is the number of vertices whose RAM colour still equals
//! the disc's unlit placeholder. If that is near the whole level, the pass
//! has not run yet and the comparison means nothing.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use gc_engine::tfrag_light::{light_tfrag, light_vertex_terms, vertex_normal_bits, VertexTerms};
use gc_formats::level_core::LevelCoreHeader;
use gc_formats::tfrag::{parse_tfrag, TfragHeader, TfragsBlock, TFRAG_HEADER_SIZE};
use gc_formats::tfrag_light::{LightBank, NormalTable};
use crate::ee::EeRam;
/// How many mismatching vertices keep their terms in the report. Enough to
/// read a pattern by hand, few enough that the report stays readable.
const BREAKDOWNS: usize = 6;
/// Result of one comparison, everything a report needs.
#[derive(Debug, Clone)]
pub struct Report {
pub level: usize,
/// RAM address of the tfrags block, its header table, and the tfrag count.
pub block_base: usize,
pub table: usize,
pub tfrag_count: usize,
pub headers_matched: usize,
pub pointers_relocated: usize,
/// RAM address of the directional light bank, when found.
pub bank_ram: Option<usize>,
/// Whether the bank was found in RAM **byte-identical to the disc's**. It
/// is a content hit, so a found bank is by construction the disc's; the
/// flag exists to keep the not-found case out of that claim, because then
/// the comparison below runs on the disc's bank with nothing from the
/// game to confirm it.
pub bank_is_disc: bool,
pub vertices_compared: usize,
pub bytes_compared: usize,
pub bytes_equal: usize,
/// Vertices whose RAM colour still equals the disc's unlit placeholder.
pub placeholder_equal: usize,
/// Per-vertex-class tallies: `single-set`, `blended`, and `no-set` for
/// vertices whose `select` names a light set the disc's bank does not have
/// -- runtime lights, which a level-load comparison cannot see.
pub classes: BTreeMap<&'static str, ClassStat>,
/// `ours - ram` per channel, in order.
pub histogram: BTreeMap<i32, usize>,
/// The first few differing vertices, for reading by hand.
pub mismatches: Vec<Mismatch>,
/// The pass's terms for the first few mismatching vertices. A colour
/// mismatch alone cannot be attributed, and the whole point of the
/// comparison is to say *which* term the game disagrees with.
pub breakdowns: Vec<Breakdown>,
}
/// One mismatching vertex with its terms, so the difference can be read as
/// `ours` versus `ram` per term rather than per byte.
#[derive(Debug, Clone)]
pub struct Breakdown {
pub tfrag: usize,
pub entry: usize,
/// `None` when the pass found no light set for the vertex's `select` --
/// which is itself the finding, so it is recorded rather than skipped.
pub terms: Option<VertexTerms>,
/// The record's `select`, always available even when the terms are not.
pub select: u16,
/// The record's base colour, PEXT5-expanded: what the game would write if
/// the light contributed nothing.
pub base: [u8; 4],
/// The disc's stored RGBA for the entry, which is the unlit placeholder.
pub disc: [u8; 4],
pub ram: [u8; 4],
}
/// Totals for one vertex class.
#[derive(Debug, Clone, Copy, Default)]
pub struct ClassStat {
pub vertices: usize,
pub equal: usize,
}
/// One differing vertex byte.
#[derive(Debug, Clone, Copy)]
pub struct Mismatch {
pub tfrag: usize,
pub entry: usize,
pub channel: usize,
pub ours: i32,
pub ram: i32,
}
impl Report {
/// 0 = every byte equal, 2 = mismatches.
pub fn exit_code(&self) -> i32 {
if self.bytes_equal == self.bytes_compared {
0
} else {
2
}
}
}
/// Everything the comparison needs from one unpacked level.
pub struct LevelInputs {
pub core_data: Vec<u8>,
pub core_index: Vec<u8>,
pub gameplay: Vec<u8>,
pub overlay: Vec<u8>,
}
impl LevelInputs {
pub fn load(dir: &Path, level: usize) -> Result<LevelInputs> {
let read = |name: &str| {
std::fs::read(dir.join(format!("level{level}")).join(name))
.with_context(|| format!("level{level}/{name}"))
};
Ok(LevelInputs {
core_data: read("core_data.bin")?,
core_index: read("core_index.bin")?,
gameplay: read("gameplay.bin")?,
overlay: read("overlay.bin")?,
})
}
}
/// Compare the tfrag lighting of one level against a RAM image.
pub fn compare(inputs: &LevelInputs, level: usize, ram: &EeRam) -> Result<Report> {
// Parsed for its validation, not for a field: a core index that does not
// parse cannot name the tfrags region the rest of this reads.
LevelCoreHeader::parse(&inputs.core_index)?;
let tfrags_ofs = i32_at(&inputs.core_index, 0x08)
.ok_or_else(|| anyhow!("core index is too short for the tfrags offset"))?;
let region_end = [0x0c_usize, 0x10, 0x14]
.iter()
.filter_map(|&o| i32_at(&inputs.core_index, o))
.filter(|&v| v > tfrags_ofs)
.min()
.unwrap_or(inputs.core_data.len() as i32);
let region = &inputs.core_data[tfrags_ofs as usize..region_end as usize];
let block = TfragsBlock::parse(region)?;
let table_off = block.table_offset as usize;
let count = block.tfrag_count as usize;
if count == 0 {
anyhow::bail!("level{level} has no tfrags");
}
let region_len = region.len();
let block_base = locate_block(region, ram, table_off, count)?;
// The RAM table, then every header against the disc on the invariant mask.
let table = block_base + table_off;
let mut headers_matched = 0usize;
let mut pointers_relocated = 0usize;
for i in 0..count {
let at = i * TFRAG_HEADER_SIZE;
let disc_h = &region[table_off + at..table_off + at + TFRAG_HEADER_SIZE];
let ram_h = ram
.slice(table + at, TFRAG_HEADER_SIZE)
.ok_or_else(|| anyhow!("tfrag header {i} runs past the RAM image"))?;
if invariant_equal(disc_h, ram_h) {
headers_matched += 1;
}
// data is relocated to an absolute pointer into the RAM copy.
let disc_data = i32_at(disc_h, 0x10).unwrap_or(0);
let ram_data = ram.i32(table + at + 0x10).unwrap_or(0);
if ram_data as usize == block_base + table_off + disc_data as usize {
pointers_relocated += 1;
}
}
// The bank: search RAM for the gameplay's light section, byte for byte.
// What is searched for is the section's own bytes, so a hit *is* the
// comparison -- a bank that is in RAM by content is the disc's bank, and
// the only other outcome is that it is not there, which is reported
// rather than papered over.
let disc_bank = LightBank::parse(&inputs.gameplay)?;
let section = bank_section(&inputs.gameplay)?;
let bank_ram = if section.is_empty() {
None
} else {
ram.find(section)
};
let table_normals =
NormalTable::from_overlay(&inputs.overlay).context("the level overlay's normal table")?;
let mut report = Report {
level,
block_base,
table,
tfrag_count: count,
headers_matched,
pointers_relocated,
bank_ram,
bank_is_disc: bank_ram.is_some(),
vertices_compared: 0,
bytes_compared: 0,
bytes_equal: 0,
placeholder_equal: 0,
classes: BTreeMap::new(),
histogram: BTreeMap::new(),
mismatches: Vec::new(),
breakdowns: Vec::new(),
};
for i in 0..count {
let at = i * TFRAG_HEADER_SIZE;
let disc_h =
TfragHeader::parse(&region[table_off + at..table_off + at + TFRAG_HEADER_SIZE])?;
let ram_h = TfragHeader::parse(ram.slice(table + at, TFRAG_HEADER_SIZE).unwrap())?;
let vert = disc_h.vert_count as usize;
if vert == 0 {
continue;
}
// The tfrag's own data block in RAM, bounded by the region. `ram_h.data`
// is the already-relocated absolute pointer, and the region ends at
// `block_base + region_len`, so the block is what is between the two.
let ram_data = ram_h.data as usize;
let start = ram_data
.checked_add(0)
.filter(|&s| s < ram.bytes().len())
.ok_or_else(|| anyhow!("tfrag {i} data pointer {ram_data:#x} is outside RAM"))?;
let region_end_ram = block_base + region_len;
if start >= region_end_ram {
anyhow::bail!("tfrag {i} data pointer {ram_data:#x} is past the tfrags region");
}
let len = region_end_ram - start;
let block_ram = &ram.bytes()[start..start + len];
let geom = parse_tfrag(block_ram, &ram_h).context("the game's copy of tfrag {i}")?;
// The lit colours, from our pass over this same data.
let ours = light_tfrag(&geom, &disc_bank, &table_normals);
// And the RAM's, plus the disc's unlit placeholder for the same
// entries. Only 0..vert_count mean anything: past them the block is
// scratchpad leftovers that no vertex references.
let rgba_ofs = ram_h.rgba_ofs as usize;
let entries = vert.min(ours.len());
let disc_data = disc_h.data as usize;
for (e, ours_byte) in ours.iter().take(entries).enumerate() {
let l = &geom.lights[e];
let select = gem_select(&geom, e);
let class = if select & 0xff00 == 0 {
if disc_bank.sets.len() > (select & 0xf) as usize {
"single-set"
} else {
"runtime-set"
}
} else if disc_bank.sets.len() > (select & 0xf) as usize {
"blended"
} else {
"runtime-blend"
};
let stat = report.classes.entry(class).or_default();
stat.vertices += 1;
report.vertices_compared += 1;
let ram_rgba = match ram.slice(start + rgba_ofs + e * 4, 4) {
Some(b) => b,
None => break,
};
let disc_rgba = region
.get(table_off + disc_data + disc_h.rgba_ofs as usize + e * 4..)
.and_then(|b| b.get(..4))
.unwrap_or(&[0, 0, 0, 0]);
let mut all_equal = true;
for (ch, ours_byte) in ours_byte.iter().enumerate() {
let o = *ours_byte as i32;
let r = ram_rgba[ch] as i32;
report.bytes_compared += 1;
if o == r {
report.bytes_equal += 1;
} else {
all_equal = false;
*report.histogram.entry(o - r).or_default() += 1;
if report.mismatches.len() < 32 {
report.mismatches.push(Mismatch {
tfrag: i,
entry: e,
channel: ch,
ours: o,
ram: r,
});
}
}
}
if all_equal {
stat.equal += 1;
} else if report.breakdowns.len() < BREAKDOWNS {
// The terms for this vertex, as the pass computed them, so the
// difference can be attributed to a term. A missing light set
// is kept too: that is a finding, not a gap.
let normal =
vertex_normal_bits(&table_normals, l.azimuth_index(), l.elevation_index());
let terms = light_vertex_terms(&disc_bank, normal, &geom.lights[e], l.select);
report.breakdowns.push(Breakdown {
tfrag: i,
entry: e,
terms,
select: l.select,
base: gc_engine::tfrag_light::pext5(l.color as u16),
disc: [disc_rgba[0], disc_rgba[1], disc_rgba[2], disc_rgba[3]],
ram: [ram_rgba[0], ram_rgba[1], ram_rgba[2], ram_rgba[3]],
});
}
if ram_rgba == disc_rgba {
report.placeholder_equal += 1;
}
}
}
Ok(report)
}
/// The `select` word for entry `e`, from the parsed light records.
fn gem_select(g: &gc_formats::tfrag::TfragGeometry, e: usize) -> u16 {
g.lights.get(e).map(|l| l.select).unwrap_or(0)
}
/// The light bank's section bytes: the count word, the pad, then the sets.
///
/// This is what `LightBank::parse` reads, so searching RAM for it is checking
/// the parse against the game: a hit means the game holds the same bytes the
/// disc holds, and a miss means either the level rewrites the section or the
/// parse does not describe what the game loaded. Both are worth knowing
/// before any colour is compared.
fn bank_section(gameplay: &[u8]) -> Result<&[u8]> {
let ofs = u32_at(gameplay, 0x04)
.ok_or_else(|| anyhow!("gameplay extent is too short for the bank offset"))?
as usize;
let count = i32_at(gameplay, ofs)
.ok_or_else(|| anyhow!("the light bank offset is past the gameplay extent"))?;
if count < 0 {
// No lights on this level, which is not an error.
return Ok(&[]);
}
let end = ofs + 0x10 + 0x40 * count as usize;
gameplay.get(ofs..end).ok_or_else(|| {
anyhow!(
"the light section runs {} bytes past the gameplay extent",
end - gameplay.len()
)
})
}
/// Find the level's tfrags block in RAM.
///
/// The key is tfrag 0's bounding sphere: sixteen bytes of float that are
/// unique in a 32 MiB image, but the candidate still has to prove itself —
/// the block header must match, *every* header must match the disc on the
/// bytes the game does not rewrite, and every `data` pointer must be the
/// relocated form of the disc's. Anything less and the answer would be a
/// plausible but wrong read.
fn locate_block(region: &[u8], ram: &EeRam, table_off: usize, count: usize) -> Result<usize> {
let key = &region[table_off..table_off + 16];
let candidates = ram.find_all(key);
let mut best: Option<(usize, usize)> = None;
for &cand in &candidates {
if cand < table_off {
continue;
}
let base = cand - table_off;
let Some(ram_block_header) = ram.slice(base, 0x10) else {
continue;
};
if ram_block_header != &region[..0x10.min(region.len())] {
continue;
}
let mut matched = 0usize;
for i in 0..count.min(64) {
let at = i * TFRAG_HEADER_SIZE;
let disc_h = match region.get(table_off + at..table_off + at + TFRAG_HEADER_SIZE) {
Some(h) => h,
None => break,
};
match ram.slice(cand + at, TFRAG_HEADER_SIZE) {
Some(h) if invariant_equal(disc_h, h) => matched += 1,
_ => {}
}
}
if best.is_none_or(|(_, m)| matched > m) {
best = Some((base, matched));
}
}
match best {
Some((base, matched)) if matched == count.min(64) => Ok(base),
Some((base, matched)) => Err(anyhow!(
"tfrags block at {base:#x} matches {matched} of {count} headers on the invariant bytes; \
refusing a partial match"
)),
None => Err(anyhow!(
"no tfrags block in the RAM image matches this level ({} candidates for the tfrag-0 \
bounding sphere)",
candidates.len()
)),
}
}
/// Bytes a tfrag header must keep between the disc and RAM: everything except
/// the relocated `data` pointer (+0x10..0x14) and the occlusion index
/// (+0x3a..0x3c), which the loader renumbers per tfrag.
fn invariant_equal(disc: &[u8], ram: &[u8]) -> bool {
let keep = |j: usize| !(0x10..0x14).contains(&j) && !(0x3a..0x3c).contains(&j);
disc.iter()
.zip(ram.iter())
.enumerate()
.filter(|&(j, _)| keep(j))
.all(|(_, (a, b))| a == b)
}
fn i32_at(bytes: &[u8], at: usize) -> Option<i32> {
bytes
.get(at..at + 4)
.map(|b| i32::from_le_bytes(b.try_into().unwrap()))
}
fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
bytes
.get(at..at + 4)
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
}
/// Render a report for a terminal.
pub fn print_report(r: &Report, out: &mut dyn std::fmt::Write) -> std::fmt::Result {
let _ = writeln!(
out,
"level{}: tfrags block at {:#x}, table at {:#x}, {} tfrags",
r.level, r.block_base, r.table, r.tfrag_count
);
let _ = writeln!(
out,
" headers matching the disc on invariant bytes: {}/{}",
r.headers_matched, r.tfrag_count
);
let _ = writeln!(
out,
" data pointers relocated as table + disc offset: {}/{}",
r.pointers_relocated, r.tfrag_count
);
match r.bank_ram {
Some(at) => writeln!(
out,
" light bank in RAM at {at:#x}, byte-identical to the disc's"
)?,
None => writeln!(
out,
" the disc's light bank is not in RAM by content; the colours below are\\
\n compared against the disc's bank with nothing from the game to confirm it"
)?,
}
let _ = writeln!(
out,
"\nvertices compared: {} ({} colour bytes)",
r.vertices_compared, r.bytes_compared
);
let _ = writeln!(
out,
" equal: {} ({:.2}%)",
r.bytes_equal,
100.0 * r.bytes_equal as f64 / r.bytes_compared.max(1) as f64
);
let _ = writeln!(
out,
" unlit placeholders: {} of {} vertices ({:.2}%)",
r.placeholder_equal,
r.vertices_compared,
100.0 * r.placeholder_equal as f64 / r.vertices_compared.max(1) as f64
);
for (class, stat) in &r.classes {
let _ = writeln!(
out,
" {class}: {} vertices, {} fully equal",
stat.vertices, stat.equal
);
}
let _ = writeln!(out, "\ndifference (ours - ram) per channel:");
for d in -8..=8 {
let n = r.histogram.get(&d).copied().unwrap_or(0);
if n > 0 {
let bar = "#".repeat((n as f64).log10().ceil() as usize);
let _ = writeln!(out, " {d:+3}: {n:8} {bar}");
}
}
// The tail in magnitude buckets: a single far value hiding a count of
// differing bytes is the number this report most needs to be legible.
for (lo, hi) in [(9, 33), (33, 65), (65, 129), (129, 256), (256, i32::MAX)] {
let n: usize = r
.histogram
.iter()
.filter(|(&d, _)| !(-lo..=hi).contains(&d))
.map(|(_, n)| n)
.sum();
if n > 0 {
let _ = writeln!(out, " |d| in {lo}..{hi}: {n:8}");
}
}
if !r.mismatches.is_empty() {
let _ = writeln!(out, "\nfirst mismatches:");
for m in r.mismatches.iter().take(16) {
let _ = writeln!(
out,
" tfrag {} entry {} channel {}: ours {} ram {}",
m.tfrag, m.entry, m.channel, m.ours, m.ram
);
}
}
for b in &r.breakdowns {
let Some(t) = &b.terms else {
let _ = writeln!(
out,
"\ntfrag {} entry {}: no light set for select {:#06x}; the pass yields transparent\
\n black, the game wrote {:?}\n (record base {:?}, disc placeholder {:?})",
b.tfrag, b.entry, b.select, b.ram, b.base, b.disc
);
continue;
};
let _ = writeln!(
out,
"\ntfrag {} entry {}: ours {:?} ram {:?} (select {:#06x}, set {:?}, blended {})",
b.tfrag, b.entry, t.out, b.ram, t.select, t.set_index, t.blended
);
let _ = writeln!(
out,
" normal (az {}, el {}) = {:.3} {:.3} {:.3}",
t.azimuth, t.elevation, t.normal[0], t.normal[1], t.normal[2]
);
let _ = writeln!(
out,
" base {:?} A colour {:.3} {:.3} {:.3} (w {:.3}) B colour {:.3} {:.3} {:.3} (w {:.3})",
t.base,
t.color_a[0], t.color_a[1], t.color_a[2], t.wa,
t.color_b[0], t.color_b[1], t.color_b[2], t.wb
);
let _ = writeln!(
out,
" dA {:.3} -> max {:.3} dB {:.3} -> max {:.3}",
t.dot_a, t.max_a, t.dot_b, t.max_b
);
let _ = writeln!(
out,
" dir A {:.3} {:.3} {:.3} dir B {:.3} {:.3} {:.3}",
t.dir_a[0], t.dir_a[1], t.dir_a[2], t.dir_b[0], t.dir_b[1], t.dir_b[2]
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_invariant_mask_excludes_the_pointer_and_the_occlusion_index() {
let disc = vec![0u8; 0x40];
let mut ram = disc.clone();
// The relocated pointer: any difference is allowed.
ram[0x10..0x14].fill(0xff);
// The occlusion index, which the loader renumbers.
ram[0x3a] = 1;
ram[0x3b] = 2;
assert!(invariant_equal(&disc, &ram));
// But the bounding sphere is load-invariant and must match.
let mut bad = ram.clone();
bad[1] = 0x99;
assert!(!invariant_equal(&disc, &bad));
}
#[test]
fn a_ram_image_must_be_exactly_32mib() {
let image = vec![0u8; crate::ee::EE_SIZE];
assert!(EeRam::new(&image).is_ok());
assert!(EeRam::new(&vec![0u8; 1024]).is_err());
}
}
+172
View File
@@ -0,0 +1,172 @@
//! The tie record relocation, verified against the game's own RAM.
//!
//! A tie LOD block is a table of 16-byte records -- a leading `u32` pointer plus
//! twelve bytes of fields -- and the loader relocates every pointer to
//! `table_address + word`, leaving the twelve bytes verbatim. That is what
//! [`verify`] checks, and it is the only way to see it: nothing in the disc
//! bytes distinguishes a relocated pointer from any other small integer.
//!
//! The search is by content, because each block has **several copies in RAM**
//! and only one of them is the loaded record table. Checking the first hit is
//! how a first attempt at this reported 0 of 16 blocks: the copy it found had
//! *modified* field bytes, which is itself interesting and is why every
//! candidate is tried rather than the first.
use anyhow::{anyhow, Result};
use gc_formats::level_core::LevelCoreHeader;
use gc_formats::tie::{
parse_tie_block, resolve_tie_class, TieBlockHeader, TieRecord, TIE_BLOCK_HEADER_SIZE,
};
use crate::ee::EeRam;
/// One verification, everything a report needs.
#[derive(Debug, Clone)]
pub struct TieReport {
pub class_index: usize,
pub lod: usize,
/// Block offset from the class header, and its length in bytes.
pub block_offset: usize,
pub block_len: usize,
/// Records in the block's table.
pub records: usize,
/// Copies of the record table found in RAM.
pub copies: usize,
/// RAM address of the loaded table, when one matched.
pub table_ram: Option<usize>,
/// Pointers relocated as `table + word`, and records whose twelve field
/// bytes are verbatim.
pub pointers_relocated: usize,
pub fields_verbatim: usize,
}
impl TieReport {
/// 0 = every record relocated with verbatim fields, 2 = mismatches.
pub fn exit_code(&self) -> i32 {
if self.table_ram.is_some()
&& self.pointers_relocated == self.records
&& self.fields_verbatim == self.records
{
0
} else {
2
}
}
}
/// Verify one class's LOD block against a RAM image.
///
/// `lod` is 1 or 2 -- slot 0 is the constant [`gc_formats::tie::TIE_PACKETS0`]
/// and never a block.
pub fn verify(
core_index: &[u8],
core_data: &[u8],
class_index: usize,
lod: usize,
ram: &EeRam,
) -> Result<TieReport> {
let header = LevelCoreHeader::parse(core_index)?;
let class = resolve_tie_class(core_index, core_data, header.tie_classes, class_index)?;
let offset = class
.header
.packets
.get(lod)
.copied()
.ok_or_else(|| anyhow!("lod must be 1 or 2, got {lod}"))?;
if offset <= 0 {
anyhow::bail!("class {class_index} has no block at LOD {lod}: the offset is {offset}");
}
let start = class.entry.offset_in_asset_wad as usize + offset as usize;
let parsed = parse_tie_block(core_data, start)
.ok_or_else(|| anyhow!("no tie block at {start:#x} in core_data"))?;
// Header, then everything up to and including the terminator and padding.
let block_len = TIE_BLOCK_HEADER_SIZE + parsed.payload.len() + parsed.padding.len();
let slice = core_data
.get(start..start + block_len)
.ok_or_else(|| anyhow!("the tie block at {start:#x} runs past core_data"))?;
let table = TieBlockHeader::parse(slice)?;
let count = table.record_count();
if count == 0 {
anyhow::bail!("the block's table is empty");
}
let records: Vec<TieRecord> = table
.records(slice)
.map(|r| TieRecord::parse(r).map_err(Into::into))
.collect::<Result<_>>()?;
// The probe is the first record whose twelve field bytes are distinctive
// enough to find: a run of one repeated byte occurs all over RAM.
let probe = records
.iter()
.map(|r| r.trailing_bytes())
.find(|f| f.iter().collect::<std::collections::HashSet<_>>().len() >= 4)
.ok_or_else(|| anyhow!("no record has a distinctive field run"))?;
// Every copy of the table in RAM. The one where every pointer has become
// table + word and every field run is verbatim is the loaded copy.
let mut copies = 0usize;
let mut table_ram = None;
let (mut relocated, mut verbatim) = (0usize, 0usize);
for hit in ram.find_all(&probe) {
let base = match hit.checked_sub(4) {
Some(b) => b,
None => continue,
};
copies += 1;
let ok_ptr = records.iter().enumerate().all(|(k, r)| {
ram.slice(base + k * 16, 4)
.map(|b| u32::from_le_bytes(b.try_into().unwrap()) as usize)
== Some(r.pointer as usize + base)
});
let ok_fields = records
.iter()
.enumerate()
.all(|(k, r)| ram.slice(base + k * 16 + 4, 12) == Some(&r.trailing_bytes()[..]));
if ok_ptr && ok_fields {
table_ram = Some(base);
relocated = records.len();
verbatim = records.len();
break;
}
}
Ok(TieReport {
class_index,
lod,
block_offset: offset as usize,
block_len: slice.len(),
records: count,
copies,
table_ram,
pointers_relocated: relocated,
fields_verbatim: verbatim,
})
}
/// Render a report for a terminal.
pub fn print_report(r: &TieReport, out: &mut dyn std::fmt::Write) -> std::fmt::Result {
let _ = writeln!(
out,
"class {} LOD {}: block at +{:#x}, {} bytes, {} records of 16",
r.class_index, r.lod, r.block_offset, r.block_len, r.records
);
let _ = writeln!(out, " copies of the record table in RAM: {}", r.copies);
match r.table_ram {
Some(at) => {
writeln!(out, " loaded table at {at:#x}")?;
writeln!(
out,
" pointers relocated to table + word: {}/{}",
r.pointers_relocated, r.records
)?;
writeln!(
out,
" field bytes verbatim: {}/{}",
r.fields_verbatim, r.records
)
}
None => writeln!(out, " no copy of the table matched the relocation rule"),
}
}
+336
View File
@@ -0,0 +1,336 @@
//! A minimal zip reader: enough for a PCSX2 savestate, nothing more.
//!
//! PCSX2 writes savestates with libzip: a sequence of local file headers
//! followed by a central directory. Entries are stored, deflated or
//! zstd-compressed, so this reader takes the entry's compression method from
//! its local header and decodes with it. Only what the format needs is
//! implemented, and what is not needed is rejected rather than guessed at:
//!
//! * **no streamed entries** — libzip writes the compressed size into the
//! local header, and a `data descriptor` (flag bit 3) is an error. Making
//! that an error rather than a guess matters: misparsed sizes produce a
//! plausible but wrong read of the next entry.
//! * **CRC-32 is checked.** A savestate that was truncated or partially
//! overwritten would otherwise read as a plausible wrong buffer, and this
//! harness exists to compare against real bytes.
//!
//! Zip64 is not needed for a 32 MiB entry, and is not supported.
use std::io::Read;
use anyhow::{anyhow, Context, Result};
/// One entry in a zip archive.
#[derive(Debug, Clone)]
pub struct Entry {
pub name: String,
/// 0 = stored, 8 = deflate, 93 = zstd.
pub method: u16,
/// Byte range of the compressed payload within the archive.
pub data: std::ops::Range<usize>,
/// Expected CRC-32 of the uncompressed payload.
pub crc32: u32,
/// Uncompressed size in bytes.
pub uncompressed_size: usize,
}
/// A parsed zip archive over a borrowed byte range.
#[derive(Debug, Clone)]
pub struct Zip {
entries: Vec<Entry>,
}
pub const LOCAL_HEADER: u32 = 0x0403_4b50;
const METHOD_STORED: u16 = 0;
const METHOD_DEFLATE: u16 = 8;
const METHOD_ZSTD: u16 = 93;
impl Zip {
/// Walk the local file headers from the start of the archive.
pub fn parse(bytes: &[u8]) -> Result<Zip> {
let mut entries = Vec::new();
let mut pos = 0usize;
while pos + 4 <= bytes.len() {
let sig = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap());
if sig != LOCAL_HEADER {
if pos == 0 {
return Err(anyhow!("not a zip archive"));
}
// The central directory (or an end-of-central-directory
// record) ends the local-header run.
break;
}
let h = LocalHeader::parse(bytes, pos)?;
if h.flags & 0x08 != 0 {
return Err(anyhow!(
"entry {:?} uses a data descriptor; this reader needs sizes in the local header",
h.name
));
}
let data_start = pos + 30 + h.name_len + h.extra_len;
let data_end = data_start.checked_add(h.compressed_size).ok_or_else(|| {
anyhow!("entry {:?} compressed size overflows the archive", h.name)
})?;
if data_end > bytes.len() {
return Err(anyhow!(
"entry {:?} at +{data_start:#x} runs {} bytes past the end of the archive",
h.name,
data_end - bytes.len()
));
}
entries.push(Entry {
name: h.name,
method: h.method,
data: data_start..data_end,
crc32: h.crc32,
uncompressed_size: h.uncompressed_size,
});
pos = data_end;
}
if entries.is_empty() {
return Err(anyhow!("zip archive holds no entries"));
}
Ok(Zip { entries })
}
pub fn entries(&self) -> &[Entry] {
&self.entries
}
pub fn find(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.name == name)
}
/// Read and decompress one entry.
pub fn read(&self, bytes: &[u8], name: &str) -> Result<Vec<u8>> {
let e = self
.find(name)
.ok_or_else(|| anyhow!("zip archive has no entry {name:?}"))?;
let raw = &bytes[e.data.clone()];
let out = match e.method {
METHOD_STORED => raw.to_vec(),
METHOD_DEFLATE => {
let mut d = flate2::read::DeflateDecoder::new(raw);
let mut out = Vec::with_capacity(e.uncompressed_size);
d.read_to_end(&mut out).context("deflate entry")?;
out
}
METHOD_ZSTD => {
let d =
ruzstd::decoding::StreamingDecoder::new(raw).context("zstd entry header")?;
let mut out = Vec::with_capacity(e.uncompressed_size);
d.take(e.uncompressed_size.max(1) as u64)
.read_to_end(&mut out)
.context("zstd entry")?;
out
}
other => return Err(anyhow!("entry {name:?} uses unsupported method {other}")),
};
if out.len() != e.uncompressed_size {
return Err(anyhow!(
"entry {name:?} decompressed to {} bytes, header says {}",
out.len(),
e.uncompressed_size
));
}
if crc32(&out) != e.crc32 {
return Err(anyhow!("entry {name:?} failed its CRC-32 check"));
}
Ok(out)
}
}
struct LocalHeader {
name: String,
method: u16,
flags: u16,
crc32: u32,
compressed_size: usize,
uncompressed_size: usize,
name_len: usize,
extra_len: usize,
}
impl LocalHeader {
fn parse(bytes: &[u8], pos: usize) -> Result<LocalHeader> {
let fixed = bytes
.get(pos..pos + 30)
.ok_or_else(|| anyhow!("local header at +{pos:#x} is truncated"))?;
let flags = le16(fixed, 6);
let method = le16(fixed, 8);
let crc32 = le32(fixed, 14);
let compressed_size = le32(fixed, 18) as usize;
let uncompressed_size = le32(fixed, 22) as usize;
let name_len = le16(fixed, 26) as usize;
let extra_len = le16(fixed, 28) as usize;
let name_start = pos + 30;
let name_end = name_start + name_len;
let name = bytes
.get(name_start..name_end)
.ok_or_else(|| anyhow!("entry name at +{pos:#x} runs past the archive"))?;
let name = String::from_utf8(name.to_vec())
.map_err(|_| anyhow!("entry name at +{pos:#x} is not UTF-8"))?;
Ok(LocalHeader {
name,
method,
flags,
crc32,
compressed_size,
uncompressed_size,
name_len,
extra_len,
})
}
}
fn le16(b: &[u8], at: usize) -> u16 {
u16::from_le_bytes(b[at..at + 2].try_into().unwrap())
}
fn le32(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
/// CRC-32 (IEEE 802.3), the one zip uses. Written out rather than pulled from
/// a crate because it is fifteen lines and keeps the lockfile unchanged.
pub fn crc32(data: &[u8]) -> u32 {
let mut table = [0u32; 256];
for (i, slot) in table.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 {
0xEDB8_8320 ^ (c >> 1)
} else {
c >> 1
};
}
*slot = c;
}
let mut c = 0xFFFF_FFFFu32;
for &b in data {
c = table[((c ^ b as u32) & 0xff) as usize] ^ (c >> 8);
}
c ^ 0xFFFF_FFFF
}
#[cfg(test)]
mod tests {
use super::*;
/// A stored entry, written by hand so the test does not need an encoder.
fn stored_zip(name: &str, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
out.extend_from_slice(&0u16.to_le_bytes()); // flags
out.extend_from_slice(&METHOD_STORED.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // time
out.extend_from_slice(&0u16.to_le_bytes()); // date
out.extend_from_slice(&crc32(payload).to_le_bytes());
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // extra len
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(payload);
// A minimal end-of-central-directory record; the reader stops at it.
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&[0u8; 18]);
out
}
#[test]
fn reads_a_stored_entry_and_checks_its_crc() {
let z = stored_zip("eeMemory.bin", b"0123456789");
let zip = Zip::parse(&z).expect("should parse");
assert_eq!(zip.entries().len(), 1);
assert_eq!(zip.entries()[0].name, "eeMemory.bin");
assert_eq!(zip.read(&z, "eeMemory.bin").unwrap(), b"0123456789");
}
#[test]
fn a_corrupted_entry_is_an_error_not_a_wrong_read() {
let mut z = stored_zip("v.bin", b"0123456789");
let last = z.len() - 19 - 5;
z[last] ^= 0xff;
let zip = Zip::parse(&z).expect("header parsing survives");
let err = zip.read(&z, "v.bin").unwrap_err().to_string();
assert!(err.contains("CRC-32"), "got {err}");
}
#[test]
fn a_data_descriptor_entry_is_rejected() {
let mut z = stored_zip("v.bin", b"payload");
// Rewrite the flags field (offset 6) to set bit 3.
z[6] = 0x08;
let err = Zip::parse(&z).unwrap_err().to_string();
assert!(err.contains("data descriptor"), "got {err}");
}
#[test]
fn a_truncated_entry_is_rejected() {
let mut z = stored_zip("v.bin", b"0123456789");
// The header declares ten payload bytes; leave one of them missing.
// Truncating the end instead would only chew into the central
// directory, which this reader stops at before it gets there.
let declared = 30 + "v.bin".len() + 10;
z.truncate(declared - 1);
let err = Zip::parse(&z).unwrap_err().to_string();
assert!(err.contains("past the end"), "got {err}");
}
#[test]
fn crc32_matches_the_known_value() {
// The standard check value for CRC-32/ISO-HDLC.
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
}
/// A deflate entry. The stream is the deflate encoding of `b"AB"` produced
/// by an independent encoder (Python's zlib, raw wbits), so this checks
/// against something this code did not make.
#[test]
fn reads_a_deflate_entry() {
let deflated: &[u8] = &[115, 116, 2, 0];
let mut out = Vec::new();
out.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&METHOD_DEFLATE.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&crc32(b"AB").to_le_bytes());
out.extend_from_slice(&(deflated.len() as u32).to_le_bytes());
out.extend_from_slice(&2u32.to_le_bytes());
out.extend_from_slice(&("v.bin".len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(b"v.bin");
out.extend_from_slice(deflated);
let zip = Zip::parse(&out).expect("should parse");
assert_eq!(zip.read(&out, "v.bin").unwrap(), b"AB");
}
/// A zstd entry, the way PCSX2 writes them. The frame is the zstd encoding
/// of `b"AB"` from an independent encoder (Python 3.14's zipfile), and it
/// uses raw blocks the way a seven-byte payload needs.
#[test]
fn reads_a_zstd_entry() {
let zstd_frame: &[u8] = &[40, 181, 47, 253, 0, 88, 17, 0, 0, 65, 66];
let mut out = Vec::new();
out.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&93u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&crc32(b"AB").to_le_bytes());
out.extend_from_slice(&(zstd_frame.len() as u32).to_le_bytes());
out.extend_from_slice(&2u32.to_le_bytes());
out.extend_from_slice(&("v.bin".len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(b"v.bin");
out.extend_from_slice(zstd_frame);
let zip = Zip::parse(&out).expect("should parse");
assert_eq!(zip.entries()[0].method, 93);
assert_eq!(zip.read(&out, "v.bin").unwrap(), b"AB");
}
}