The harness that compares the port against the game's own memory existed but
had never been compiled: tools/trace did not build (27 errors, all in the
argument plumbing), and the tfrag_light.rs light-bank correction it motivated
was unverified. This finishes it and runs it, and the run finds two real bugs.
Result, on level 0 against three inputs -- the live emulator over PINE and two
PCSX2 savestates from the same level, agreeing byte for byte:
headers matching the disc on invariant bytes: 833/833
data pointers relocated as table + disc offset: 833/833
light bank in RAM at 0x50ab20, byte-identical to the disc's
vertices compared: 40600 (162400 colour bytes)
equal: 162400 (100.00%)
The locator finds the level's tfrags block in RAM by tfrag 0's bounding
sphere and accepts a candidate only when every header matches the disc on the
bytes the game leaves alone and every data pointer is the relocated form, so
"the block was found" is itself a verification rather than an assumption. The
bank is searched in RAM by its exact section bytes, which is what settles its
framing: offset at +0x04, an i32 set count, twelve bytes of pad, then the sets.
The previous reading (offset at +0x00, count at +0x04) was wrong.
Two bugs the comparison found, both of which produced plausible output:
1. vertex_normal_bits negated x and y and left z positive, while
NormalTable::normal negates all three. dA came out -0.707 where the game
had +0.707, so max(dA, dA*wA) took the wrong branch and every vertex lit
near its base colour: dark, but not wrong-dark. 95.88% agreement with the
bug, 100.00% without.
2. A record whose select names a set the bank lacks drew transparent black.
The game writes the record's base colour; 3,130 of level 0's 40,600
vertices do this (select 0x000f, set 15 -- a runtime slot the level's
directional bank does not have). The vertex is unlit, not invisible.
Both are now regression tests in the engine. The comparison prints the pass's
per-vertex terms (light_vertex_terms) for the first mismatches, because a
colour mismatch alone cannot be attributed and the comparison is worthless
without attribution -- that is how the second bug was identified from a
three-line read rather than guessed at.
Docs: docs/plan/pcsx2-verification.md records the harness, the result, the
two bugs and what to compare next; roadmap and level-assets.md stop carrying
lighting magnitudes as an open item. Findings reach tests only as numbers --
no test reads a savestate, a dump or a live emulator.
Two pieces, and the first is the reason the second can be trusted.
ps2.rs is the EE's FPU on raw u32 bit patterns. It is not f32: there are
no denormals (exponent 0 *is* zero), no NaN and no infinity (exponent 255
is an ordinary value, so MAX reads back in Rust as NaN -- a test asserts
exactly that, because it is the sharpest available statement of why this
module exists), every result truncates toward zero, the adder discards
the small operand's bits more than one below the large one's LSB and
returns the large operand unchanged across a 25-bit exponent gap, and
rsqrt is sqrt-then-divide with truncation between the steps rather than
one rounded operation. The lighting pass accumulates all of these across
every vertex of every patch, so this is a visible difference rather than
a rounding curiosity.
tfrag_light.rs is the pass itself: PEXT5 base colour, the select word
picking one light set or blending two by bits 8..15 / 256, blended
directions re-normalised where unblended ones are not, and
base + A.rgb*max(dA, dA*wA) + B.rgb*max(dB, dB*wB). Lighting indexes
positions, not vertex-info entries, since a common entry and a LOD entry
can share a position.
TfragLight gains a `select` field for the u16 at +6. It was briefly
reconstructed with an unsafe pointer cast over the struct, which is
exactly the sort of thing that breaks silently the moment a field is
reordered; it is now a plainly parsed field.
The pass does not run yet. The light bank framing the reference uses --
a u32 offset at +0x04, an i32 count there -- gives offset 0xa0d and a
count of 698810368 on RAC2 level 0, so RAC2's gameplay extent is not
laid out that way. The renderer now counts the vertices it actually lit
and prints it, because falling back silently to the stored colours
produces a plausible but wrong picture:
lighting: 0 of 80274 vertices shaded by LightTfrags; the rest fall
back to the tfrag's stored colours (no usable light bank)
That line is the current honest state, and the frame has not changed as
a result. Where RAC2 keeps its light bank is the open question.