615ff937eaa2407bf7c259dc858ffc84e27684eb
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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. |
||
|
|
ca42793e48 |
Frustum search: bounded with a proper filter, not found
|
||
|
|
e7eec01b50 |
m4 is the live camera: a 20x outlier against eight controls
|
||
|
|
d4e81ba977 |
Correction: the camera region is nine matrices, not three
|
||
|
|
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 ( |
||
|
|
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.
|
||
|
|
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 (
|
||
|
|
3dbd384b59 |
The guard-band clip test: implemented and unit-tested ahead of the camera
|
||
|
|
1aa94ee288 |
0x152c70 rejected as the camera; the savestates are not consecutive frames
Ran the cheapest confirmation from |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
4d66cf6214 |
The tie vertex element is 16 bytes and the layout cycles; correct e5cd3df
Picking up after |
||
|
|
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.
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
cd1648571c |
The verified mapping is the flat memory image, not section indexing
The fix |
||
|
|
e6b3e0b091 |
Root cause: the per-section indexing used throughout this investigation was wrong
The verification |
||
|
|
f2d7e7ecd4 |
d97e7df was wrong: my section offset was wrong, not the decoder
Ran gc-overlay-anchors on 0x2ee138 as |
||
|
|
d97e7df41f |
Second decode error in the same investigation; stopping rather than trusting this decoder
Following
|
||
|
|
e1482abd75 |
Correct my own decode bug: there is a caller, and r2 is a return value
|
||
|
|
349b498ddd |
r2 is never written in the enclosing function, and it has no callers
The trace
|
||
|
|
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.
|
||
|
|
cee17c95b1 |
Disassembled: 0xc0 is an address offset, and the walk is 16-byte strided
Closes the ambiguity
|
||
|
|
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 |
||
|
|
a2f8c924b4 |
moby_classes.offset enrichment is size-independent: 13x to 72x at matched size
Runs the control |
||
|
|
06531fb66c |
The 394 does not survive: 0xc0's rate rises with immediate-set size
Ran the size-independent statistic |
||
|
|
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. |
||
|
|
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
|
||
|
|
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
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
5a60a2920c |
Remove two dead counters; clippy clean again
Follow-up to the note in
|
||
|
|
6d26da3630 |
The control fails my own claim: 0x1c/0x34 select nothing distinctive
|
||
|
|
2ff2abbd32 |
Two class-header words are base-relative offsets pointing past the tie packets
Chasing where the AD-GIF is, since |
||
|
|
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.
|
||
|
|
f43a1c2e6e |
bfc_distance is not the control region's length; the AD-GIF is not in the packet
Follows
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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
|
||
|
|
87e2367daf |
Retract the "cutout matters far more" claim; the counts do not support it
|
||
|
|
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 |
||
|
|
25d73b4fa8 |
What the disc says about GS priority: cutout matters, blending barely does
The alpha histogram from
|
||
|
|
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.
|
||
|
|
4bf552dab0 |
The alpha bug had a test named after the bug
Fixes a broken build left by |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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. |
||
|
|
cc52060c67 |
Pin the dispatch counts exactly, after finding two of them were pinned wrong
Follows directly from
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
7b6de36958 |
Mark the two tie tests that still measure the retracted 16-byte layout
The stride retraction (
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
4ab30e1f4d |
Retract the tie texcoord field: a dominant-value artifact, one level down
Picked the tie flat encoding to close, since |
||
|
|
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.
|
||
|
|
67ce068c7f |
Tie vertex regions are two encodings, not one; mark Phase 3's port done
Read up on the five commits that landed since |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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. |
||
|
|
855aadf523 |
Record the tie terminator defect in the format doc, plus two failed fixes
The defect itself was measured and committed in
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|