At a glance

Retail side
psxrecomp static recomp of SCUS_942.54, driven over its JSON-over-TCP debug server by scripts/recomp/probe.py
Engine side
legaia-engine sim-trace (crates/engine-shell/src/sim_trace.rs) ticking a BootSession
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_*.py are 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.

Retail recomp TCP debug server From-scratch engine BootSession trace_capture.py reads pinned globals sim-trace reads Camera::globals JSONL retail units JSONL retail units trace_diff.py first divergent frame
Both sides emit the same trace shape; the diff aligns on the first camera cut and reports per channel.

Quick start

python3 scripts/recomp/probe.py --port 4494 launch --cache-dir /tmp/mycache --wait-tcp
python3 scripts/recomp/probe.py --port 4494 load-state 4 --expect-scene 'jou ene' --expect-mode 0x15
python3 scripts/recomp/trace_capture.py --port 4494 --frames 100 --map camera --out /tmp/scratch/recomp_cam.jsonl
legaia-engine sim-trace --scene town01 --disc "$LEGAIA_DISC_BIN" --frames 100 --out /tmp/scratch/engine_town01.jsonl
python3 scripts/recomp/trace_diff.py /tmp/scratch/recomp_cam.jsonl /tmp/scratch/engine_town01.jsonl --tol-angle 2 --tol-pos 2

Each 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

ToolLives inRole
probe.pyscripts/recomp/RecompClient library + CLI for the debug server: ping / read / press / load-state / screenshot / launch / kill
trace_capture.pyscripts/recomp/Per-frame capture of a named address map (camera, player, scene, --actors) into JSONL
legaia-engine sim-tracecrates/engine-shellEngine side: boots the scene live, ticks N frames, emits N+1 records
trace_diff.pyscripts/recomp/Aligns two traces, first divergence per channel
preflight.py, apply_boot_state_fix.pyscripts/recomp/Diagnose and repair the recomp's savestate-resume defect
minigame_warp.pyscripts/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.pyscripts/recomp/, crates/engine-audioThe note-level BGM differential
xa_cue_capture.pyscripts/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}]}
ChannelRetail global (recomp side)Engine sourceCompared as
cam.pitch/yaw/rollu16 trio 0x8007B790/92/94, masked to 12 bitsCamera::globalswraparound distance mod 4096
cam.hGTE projection distance 0x8007B6F4Camera::globalsabsolute
cam.eye[3]i32 trio 0x800840B8Camera::globalsabsolute distance
cam.focus[3]i32 trio 0x80089118 (X, Z negated)Camera::globalsabsolute distance
player.x/z/headingpointer 0x8007C364 → +0x14, +0x18, +0x26move_state.world_x/z, render_26absolute / wraparound
scene8-byte name at 0x8007050CSceneMode nameexact
modeu16 at 0x8007B83CField 3, WorldMap 13, Battle 0x15, Menu 0x17, Cutscene 27; omitted otherwiseexact

Channels present on only one side are reported and skipped, never counted as divergence.

Capturing and diffing

Two capture engines

--enginePropertyUse when
ringFrame-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.
pollBest-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 N overrides; 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
RouteCostHow
1. Parked savestatesecondsprobe.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 loadminutesReaches 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 minigamesecondsminigame_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.

FaultSignalFix
Self-wiping runtimeboot_state.c carries cpu->pc = entry_pcapply_boot_state_fix.py
Stale buildbinary older than boot_state.cmake psx-runtime
Stale snapshotthe .pst records pc == 0recapture 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.
  • press takes a raw active-low SIO pad word in a field named buttons (Cross 0xBFFF, Circle 0xDFFF, Start 0xFFF7; 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_frame are removed); the frame-exact primitive is set_snapshot + read_frame_ram.
  • vram_peek clamps to 128 px per call (the client chunks); dirty_exec_hot PCs are physical - OR 0x80000000 before resolving against overlay VAs.
  • Kill by PID, never by pattern; one port, one instance (launch refuses a port that already answers).
  • A display disabled error 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
RuleWhy
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 pitchRaw 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 0x8007BAB8Both 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 ordinalCaptures 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..127 where retail's occupy the SPU's 14-bit 0..0x3FFF. fire carries the retail chain of FUN_80067550 (vel × bank_mvol × 0x3FFF / 0x3F01, then × prog_mvol × tone_vol / 0x3F01) and the sequencer's closing square taper lands in sequencer.rs's channel_mix.
  • Tone selection collapsed. The VAB packs one tone page per used program; retail resolves a program to its page by rank among used ProgAtr slots (FUN_80068d94 writes the rank table, FUN_80068b98 reads it). VabBank::upload expands pages into program-number space; pinned corpus-wide by engine-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

ItemAddress / functionWhat it provesSource
Camera trio + focus + eye globals0x8007B790, 0x80089118, 0x800840B8; writer FUN_801DE084 (op-0x45 apply)The channels are the retail camera state, not a derived posecutscene, memory map
Player record fields0x8007C364 → +0x14/+0x18/+0x26Position + heading sourcefield locomotion
Key-on volume chainFUN_8006755014-bit volume domain and truncation pointsghidra/scripts/funcs/80067550.txt
Program-to-page rank tableFUN_80068d94 (write), FUN_80068b98 (read)Tone pages are indexed by rank among used programsghidra/scripts/funcs/
XA cue fireFUN_8003D53C; clip table 0x801C6ED8Which globals stage a cue and how a location maps to a filebattle action
Diff semanticsscripts/recomp/test_trace_diff.py, test_minigame_warp.py, test_xa_cue_capture.pyAlignment, wraparound, tolerance and edge rules on synthetic fixturespython3 -m unittest

See also