Recomp differential oracle
When the engine's camera swings a beat late or an NPC turns the wrong way, "it looks off" is not a bug report. This harness turns it into one: the retail game (run natively as a static recomp) and the from-scratch engine both write the same per-frame state trace in retail units, and a diff names the first frame on which any channel - camera pose, player position, an NPC's heading - departs from retail. A note-level variant does the same for music.
At a glance
- Retail side
- psxrecomp static recomp of
SCUS_942.54, driven over its JSON-over-TCP debug server byscripts/recomp/probe.py - Engine side
legaia-engine sim-trace(crates/engine-shell/src/sim_trace.rs) ticking aBootSession- Trace shape
- One JSON object per frame; angles 12-bit (4096 = full turn), positions in retail world units, mode = the retail game-mode word
- Diff
trace_diff.py(state),note_diff.py(BGM key-ons); non-zero exit on any divergence- Environment
LEGAIA_RECOMP_DIR,LEGAIA_RECOMP_BIOS,LEGAIA_RECOMP_PORT- each overridable per call- Never committed
- Captured traces carry retail RAM values; only the synthetic fixtures in
scripts/recomp/test_*.pyare tracked - Used by
- cutscene camera parity, audio sequencer parity, the arts-voice pins in battle action
What this solves
The emulator harnesses (PCSX-Redux, mednafen) observe retail through breakpoints and save-state diffs: precise, but slow and one address at a time. The recomp path reads a dozen retail globals every frame at full speed, so hundreds of aligned frames from both sides cost seconds. Reach for it when a scene plays differently in the engine and you need the first diverging quantity and frame.
Quick start
python3 scripts/recomp/probe.py --port 4494 launch --cache-dir /tmp/mycache --wait-tcppython3 scripts/recomp/probe.py --port 4494 load-state 4 --expect-scene 'jou ene' --expect-mode 0x15python3 scripts/recomp/trace_capture.py --port 4494 --frames 100 --map camera --out /tmp/scratch/recomp_cam.jsonllegaia-engine sim-trace --scene town01 --disc "$LEGAIA_DISC_BIN" --frames 100 --out /tmp/scratch/engine_town01.jsonlpython3 scripts/recomp/trace_diff.py /tmp/scratch/recomp_cam.jsonl /tmp/scratch/engine_town01.jsonl --tol-angle 2 --tol-pos 2Each divergent channel prints its first divergent frame with a ±5-frame window of both sides' values. Always pass --expect-scene / --expect-mode to a load: a stale slot loads "successfully" into the wrong state.
Moving parts
| Tool | Lives in | Role |
|---|---|---|
probe.py | scripts/recomp/ | RecompClient library + CLI for the debug server: ping / read / press / load-state / screenshot / launch / kill |
trace_capture.py | scripts/recomp/ | Per-frame capture of a named address map (camera, player, scene, --actors) into JSONL |
legaia-engine sim-trace | crates/engine-shell | Engine side: boots the scene live, ticks N frames, emits N+1 records |
trace_diff.py | scripts/recomp/ | Aligns two traces, first divergence per channel |
preflight.py, apply_boot_state_fix.py | scripts/recomp/ | Diagnose and repair the recomp's savestate-resume defect |
minigame_warp.py | scripts/recomp/ | Replays the field VM's door-warp writes to drop any field state into a minigame |
audio_note_capture.py, note-trace, note_diff.py | scripts/recomp/, crates/engine-audio | The note-level BGM differential |
xa_cue_capture.py | scripts/recomp/ | Per-frame capture of CD-XA voice-cue fires (arts shouts, grunts) |
Trace shape and channels
One JSON object per line; every field except frame is optional (absent = not captured that frame):
{"frame": 32915, "scene": "jou ene", "mode": 21,
"cam": {"pitch": 32, "yaw": 2408, "roll": 0, "h": 256,
"eye": [0, 1280, 7920], "focus": [0, 0, 0]},
"player": {"x": 100, "z": 200, "heading": 0},
"actors": [{"i": 1, "x": -40, "z": 80, "heading": 1024}]}
| Channel | Retail global (recomp side) | Engine source | Compared as |
|---|---|---|---|
cam.pitch/yaw/roll | u16 trio 0x8007B790/92/94, masked to 12 bits | Camera::globals | wraparound distance mod 4096 |
cam.h | GTE projection distance 0x8007B6F4 | Camera::globals | absolute |
cam.eye[3] | i32 trio 0x800840B8 | Camera::globals | absolute distance |
cam.focus[3] | i32 trio 0x80089118 (X, Z negated) | Camera::globals | absolute distance |
player.x/z/heading | pointer 0x8007C364 → +0x14, +0x18, +0x26 | move_state.world_x/z, render_26 | absolute / wraparound |
scene | 8-byte name at 0x8007050C | SceneMode name | exact |
mode | u16 at 0x8007B83C | Field 3, WorldMap 13, Battle 0x15, Menu 0x17, Cutscene 27; omitted otherwise | exact |
Channels present on only one side are reported and skipped, never counted as divergence.
Capturing and diffing
Two capture engines
--engine | Property | Use when |
|---|---|---|
| ring | Frame-exact: the server's per-frame snapshot ring (4 regions × 128 bytes, 36000-frame history). The camera map alone uses all four regions. | Any map that fits the budget. |
| poll | Best-effort live loop tagged with the frame counter; skipped frames are absent lines, a sample can straddle a frame. | Actor sweeps (pointer chases) and maps past the budget - camera+player together fall back here. |
Alignment
- Default: the first camera change on each side - the two sides boot from unrelated frame counters, but the first scripted cut is the same event.
--offset Noverrides; first frames are the fallback when neither side has a cut. sim-trace's first record is a pre-tick boot sample, so the engine camera always "changes" between its first two lines;--skip-lead-b(default 1) drops it.- A first divergence with no context rows above it is the first frame of the overlap, not the end of a matching run.
- Right after a savestate load the camera can sit static for a settle window; capture longer and let alignment find the first change.
Reading a camera divergence
Over the opening chain the angle channels, cam.roll, scene and mode match retail exactly. The remaining position gap is a timeline-phase difference: sim-trace boots a scene cold at frame 0, while a retail capture arrives through the prologue chain several beats in, so the engine's beat 0 is compared against retail's beat 5. A scene where the cold-booted engine runs no camera script at all reads the same way - a beat gated on arrival story flags. Distinguishing either from a pose error needs the engine driven through the chain.
Getting into a scene the opening chain does not reach
| Route | Cost | How |
|---|---|---|
| 1. Parked savestate | seconds | probe.py load-state N --expect-scene ... --expect-mode .... Verify liveness by sampling frame twice. Slots drift (F1-F12 in a windowed run overwrites one): the fingerprint is the evidence, not the slot number. The name-entry screen shares the field mode word - screenshot when in doubt. |
| 2. Cold boot + memory-card load | minutes | Reaches anywhere a save reaches. The runtime resolves the card next to the executable, never the cwd; seed build-dbg/card1.mcd from a real card (back it up), confirm with save-tool saves, then drive START → DOWN → CROSS → CROSS → CROSS → UP → CROSS, screenshotting each screen. |
| 3. Warp into a minigame | seconds | minigame_warp.py --from-slot N --sub-id K replays the field VM's op-0x3E door-warp writes from any field state. The scene name is not a fingerprint here (it holds the host scene); identify a dome state by mode == 0x15 + sub-id global 0x8007BA34 + the match phase byte. |
Route 2's traps: the title cursor starts on NEW GAME; the load confirm defaults to No; the title times out back to the attract demo (poll for mode 0x17 and press immediately); START does not always skip the demo.
Capturing a slot (save_savestate) is staged like a load and is believed only after three checks: the .pst carries a non-zero resume PC (preflight.py --slot N), a fresh process's load lands on the expected fingerprint, and a scripted input advances the state.
The savestate-resume fix and its preflight
Stock, the recomp runtime cannot resume a savestate: its loader forces the PC back to the game entry (the BSS-clear routine), so every load restores RAM and immediately wipes the game-mode word - and acks {"ok":true} either way. apply_boot_state_fix.py rewrites the one line (honour the recorded resume PC); it is a script rather than a .patch so no PolyForm-licensed source is vendored. Then build the psx-runtime target - not the executable-named target, which make treats as already satisfied and silently skips.
| Fault | Signal | Fix |
|---|---|---|
| Self-wiping runtime | boot_state.c carries cpu->pc = entry_pc | apply_boot_state_fix.py |
| Stale build | binary older than boot_state.c | make psx-runtime |
| Stale snapshot | the .pst records pc == 0 | recapture that slot |
preflight.py separates the three and is wired into probe.py launch, trace_capture.py --savestate and failed --expect-* checks (--skip-preflight bypasses).
Debug-server protocol traps probe.py bakes in
- One request per connection - the server closes the socket after every response.
presstakes a raw active-low SIO pad word in a field namedbuttons(Cross0xBFFF, Circle0xDFFF, Start0xFFF7;probe.BUTTON_WORDS). It returns before the hold elapses; use ≥ 30-frame holds for confirms.- Savestate loads are staged - executed at the next block boundary, dropping every connection; the client retries verification reads through the reconnect.
- No synchronous stepping (
pause/step/run_to_frameare removed); the frame-exact primitive isset_snapshot+read_frame_ram. vram_peekclamps to 128 px per call (the client chunks);dirty_exec_hotPCs are physical - OR0x80000000before resolving against overlay VAs.- Kill by PID, never by pattern; one port, one instance (
launchrefuses a port that already answers). - A
display disablederror means the guest GPU's display-enable bit is off (early boot, between attract segments), not that the host lacks a display. Headless instances can screenshot without X.
Note-level audio differential
The same shape applied to music: instead of camera channels per frame it compares the stream of key-ons a sequencer asked the SPU for. Both sides snapshot the same instant (sample start address, pitch, per-voice volumes, ADSR words), so a divergence localises directly - a missing key-on means the sequencer never asked, a wrong start address means tone selection diverged, a wrong pitch means the note or its bend resolved differently.
python3 scripts/recomp/audio_note_capture.py --port 4472 --seconds 30 --out /tmp/scratch/recomp_notes.jsonl --summary
./target/release/note-trace --extracted extracted --track 0 --frames 1800 --out /tmp/scratch/engine_notes.jsonl
python3 scripts/recomp/note_diff.py /tmp/scratch/recomp_notes.jsonl /tmp/scratch/engine_notes.jsonl
| Rule | Why |
|---|---|
Capture from a clocked instance: SDL_AUDIODRIVER=dummy xvfb-run -a <runtime> --debug-port N --no-launcher ... | A --headless recomp never clocks the SPU; every voice stays at envelope 0, the driver thinks all 24 are free, and nearly everything keys onto voice 0. The script refuses unless SPU frames advance - verify the rate is exactly 735.0 per guest frame yourself. |
Read vag before pitch | Raw SPU addresses never match (each side lays out the bank); ids are renumbered per trace by ascending address. When distinct-VAG counts differ, pitch is the reliable channel. |
Pick the track from the running game: BGM id at 0x8007BAC8, PROT index at 0x8007BAB8 | Both read 0 until a scene starts music. --track is not the sound-test slot - cross-check note-trace --list's prot_entry column (see music tracks). |
| Alignment is on note ordinal | Captures start mid-track and the frame counters have unrelated origins. |
History: two engine defects the note diff surfaced (both closed)
- Per-voice volume in the wrong domain. Key-on volumes occupied
0..127where retail's occupy the SPU's 14-bit0..0x3FFF.firecarries the retail chain ofFUN_80067550(vel × bank_mvol × 0x3FFF / 0x3F01, then× prog_mvol × tone_vol / 0x3F01) and the sequencer's closing square taper lands insequencer.rs'schannel_mix. - Tone selection collapsed. The VAB packs one tone page per used program; retail resolves a program to its page by rank among used
ProgAtrslots (FUN_80068d94writes the rank table,FUN_80068b98reads it).VabBank::uploadexpands pages into program-number space; pinned corpus-wide byengine-audio/tests/real_vab_program_mapping.rs. - The
v(voice index) channel differs whenever either does - allocation order is an effect, not a finding.
The reading that field BGM is scene-bundle-resident is falsified - see do-not-re-walk.
CD-XA cue capture
xa_cue_capture.py applies the snapshot ring to the streamed-voice layer (arts shouts, grunts, fanfares): every cue fire stages its parameters in a cluster of SCUS globals before arming the CD filter, and the tool records those globals per frame, detects an edge on the play-state write, and resolves the staged disc location against the clip table to name the XA<n>.XA file. It is the instrument behind the arts-voice channel pins in legaia_art::arts_voice.
How we know
| Item | Address / function | What it proves | Source |
|---|---|---|---|
| Camera trio + focus + eye globals | 0x8007B790, 0x80089118, 0x800840B8; writer FUN_801DE084 (op-0x45 apply) | The channels are the retail camera state, not a derived pose | cutscene, memory map |
| Player record fields | 0x8007C364 → +0x14/+0x18/+0x26 | Position + heading source | field locomotion |
| Key-on volume chain | FUN_80067550 | 14-bit volume domain and truncation points | ghidra/scripts/funcs/80067550.txt |
| Program-to-page rank table | FUN_80068d94 (write), FUN_80068b98 (read) | Tone pages are indexed by rank among used programs | ghidra/scripts/funcs/ |
| XA cue fire | FUN_8003D53C; clip table 0x801C6ED8 | Which globals stage a cue and how a location maps to a file | battle action |
| Diff semantics | scripts/recomp/test_trace_diff.py, test_minigame_warp.py, test_xa_cue_capture.py | Alignment, wraparound, tolerance and edge rules on synthetic fixtures | python3 -m unittest |