Why PCSX-Redux

Three properties make it the right tool for runtime probes:

  • Open-source + scriptable. The Lua API exposes the CPU register file, main RAM as a file-like object, and a breakpoint manager.
  • Interpreter CPU + debug mode. The interpreter (-interpreter) is the only CPU back-end that hits Lua breakpoints, and the interpreter only invokes the debug-process hook when DebugSettings::Debug is set (-debugger). Both flags are required; silently neither alone fires Lua breakpoints. (Source: psxinterpreter.cc:1652 - if constexpr (debug).)
  • Save-state load from Lua. PCSX.loadSaveState(zReader(file)) loads a .sstate file at runtime, which lets the autorun script reach any captured game state without driving the GUI.

Mednafen's binary save-state format is supported for offline RAM scans via the mednafen-state crate, but its runtime debugger is GUI-only; PCSX-Redux is where the breakpoint probes run.

Setup

The expected on-disk layout (matches the run-script defaults):

~/Tools/pcsx-redux/pcsx-redux                  # locally-built binary
~/Tools/pcsx-redux/<TITLE_ID>.sstate<N>        # PCSX-Redux quicksave (F1..F10 in-emulator)
~/.mednafen/firmware/SCPH1001.BIN              # PSX BIOS, reused from mednafen
~/Downloads/Legend of Legaia (USA)/            # disc image

The <TITLE_ID> is the PSX disc's product code (e.g. SCUS94254 for the USA release of Legaia); PCSX-Redux writes one file per quicksave slot when you press the assigned F-key in the running emulator. Each probe's documentation calls out which game state the save needs to be in - pick a save you've prepared locally that matches.

Override any of these via env vars (PCSX_REDUX, LEGAIA_BIOS, LEGAIA_SSTATE, LEGAIA_ISO). The repo doesn't ship the binary or BIOS or disc; those stay local.

Save-state library (immutable backups)

PCSX-Redux quicksave slots (<TITLE_ID>.sstate<N>) and mednafen mc{N} cards are ephemeral - the next time you save in that slot, the bytes are gone, and a save you reverse-engineered against has to be recaptured from scratch. To stop that, back interesting states up into a fingerprint-named library:

scripts/manage-states.py backup pcsx-redux ~/Tools/pcsx-redux/SCUS94254.sstate6 \
    --label field_walled_collision_pin
scripts/manage-states.py library          # list what's backed up + catalogue status
scripts/manage-states.py library --audit  # scenario-centric: emulator-aware catalogue
                                          # status + PCSX-probe-usability + orphan/missing gaps

library --audit is the inverse view: it walks every manifest scenario and classifies it as CATALOGED (for which emulator), EPHEMERAL-ONLY (a live-slot pointer never backed up), BACKUP-MISSING (fingerprint recorded but the file is gone), or NO-SAVE (a pure phase marker), and flags which scenarios are usable for a PCSX-Redux breakpoint probe - a mednafen-only backup is catalogued but cannot be loaded by run_probe.sh (PCSX-Redux needs a pcsx-redux .sstate).

backup copies the file to saves/library/<emulator>/<sha256>.<ext> (immutable; the sha256 is the filename, so it never collides or gets overwritten) and records the fingerprint on the named scripts/scenarios.toml scenario as backup_fingerprint. The library directory is gitignored (it holds Sony game RAM); the committed pointer is the manifest's backup_fingerprint field. When a scenario has one, both scripts/manage-states.py and run_probe.sh --scenario resolve the library copy in preference to the live slot - so probes keep working after you've saved over the original slot. See the field schema + workflow at the top of scripts/scenarios.toml.

The harness

scripts/pcsx-redux/run_probe.sh is the canonical wrapper. Despite the name, every other Lua autorun re-uses it via the LEGAIA_LUA override:

LEGAIA_SSTATE=$HOME/Tools/pcsx-redux/<your-saved-state>.sstate \
LEGAIA_LUA=scripts/pcsx-redux/autorun_world_map_fog_probe.lua \
LEGAIA_OUT=/tmp/fog_probe.csv \
LEGAIA_FRAMES=600 \
    bash scripts/pcsx-redux/run_probe.sh

The wrapper:

  1. Verifies the binary / BIOS / save state / Lua file all exist (fails early with a clear error if any one is missing).
  2. Launches PCSX-Redux with -interpreter -debugger -run -bios <SCPH> -iso <bin> -dofile <lua> -stdout and pipes the emulator log to logs/pcsx_<probe>.log.
  3. Tails the log for a === summary === block on exit.

The -stdout flag is what makes the autorun's PCSX.log(...) calls visible to the parent shell.

The probe pattern

Every autorun script under scripts/pcsx-redux/ follows the same state machine:

  1. WAIT_BOOT - vsync listener counts up while the emulator boots the BIOS to a known state (typically 60 vsyncs = 1s).
  2. ARMED_LOADED - load the save state, read the register file, compute breakpoint addresses (often GP-relative), arm the probes, write an initial snapshot. Capture for LEGAIA_FRAMES vsyncs while breakpoints log hits to the CSV.
  3. DONE - disarm breakpoints, write a final snapshot, PCSX.quit(0).

This pattern is factored out as a shared library at scripts/pcsx-redux/lib/probe.lua, which is an umbrella that re-exports the per-concern submodules under scripts/pcsx-redux/lib/probe/ - env, mem, sstate, pad, bp, csv, snapshot, sm, watch, step, and symbols (the umbrella's submodule list must include step for the tracing helpers below to resolve). A new probe doesn't reimplement the state machine, the memory readers + writers, the save-state loader, the pad-override helpers, the CSV writer, or the live-snapshot writer - it imports them:

package.path = package.path .. ";scripts/pcsx-redux/lib/?.lua"
local probe = require("probe")

local csv = probe.csv_open("/tmp/x.csv", "addr,pc,ra")

probe.run({
    sstate         = probe.getenv("LEGAIA_SSTATE", DEFAULT),
    capture_frames = probe.getenv_num("LEGAIA_FRAMES", 600),
    snapshot_path  = "/tmp/x.hits.txt",
    on_arm = function()
        local descs = {}
        for _, addr in ipairs({ 0x801E76D4 }) do
            local d = { addr = addr, name = string.format("0x%08X", addr),
                        hits_ref = { n = 0 } }
            probe.arm_breakpoint(addr, "Exec", 4, d.name, function()
                d.hits_ref.n = d.hits_ref.n + 1
                local r = PCSX.getRegisters()
                csv:row("0x%08X,0x%08X,0x%08X",
                    addr, tonumber(r.pc), tonumber(r.GPR.n.ra))
            end)
            descs[#descs + 1] = d
        end
        return descs
    end,
    on_done = function() csv:close() end,
})

probe.ram_offset(addr) is bit.band(addr, 0x1FFFFFFF) - strips the KSEG segment selector so KSEG0 (0x80xxxxxx) and KSEG1 (0xA0xxxxxx) map to the same physical byte. Always work in absolute PSX virtual addresses on input; convert at the boundary.

Call-context capture

probe.capture_call_context(label) returns a multi-line text snapshot of the CPU at the moment of a breakpoint hit:

  • All 32 GPRs by MIPS name (zero, at, v0, …, ra), four per row.
  • The 8 instruction words straddling PC (pc-0x20..pc+0x60), one row per 16 bytes, with a <- pc marker on the row containing PC. Lets the reader see the calling instruction context without round-tripping through Ghidra.
  • The 32 stack words at sp (sp..sp+0x80), 4 per row. The MIPS calling convention saves ra into a sp-relative prologue slot for any non-leaf function, so this captures the visible ra-chain without DWARF unwind info. Walking the chain still requires reading the prologue offsets out of the disassembly post-hoc, but the bytes you need to do that are already in the snapshot.

probe.append_call_context(path, snap) is the matching writer; it opens the file in append mode so multi-shot probes can stack snapshots without overwriting earlier ones. The slot-4 reader and the XP-table probe both use this for first-hit detail dumps.

Write-watchpoint logging (probe.watch)

The recurring “what writes this address?” probe arms a Write breakpoint and, in the callback, logs (elapsed, label, addr, pc, ra, new_value) to a CSV plus a first-N call-context dump. probe.watch factors that closure out (it composes bp + mem + snapshot, adding no new emulator interaction): probe.watch.new{ csv=…, detail_path=…, elapsed=… } then w:arm(addr, width, label).

Instruction tracing + write attribution (probe.step)

PCSX-Redux's Lua FFI exposes no native single-step (only pauseEmulator / resumeEmulator and non-pausing breakpoints - the internal m_debug->stepIn() is not bound). probe.step reconstructs the two things single-stepping is used for, on top of breakpoints, so fine-grained RE stays scriptable instead of needing the GUI debugger:

-- Observational single-step over a code region: an Exec BP on every 4-byte
-- instruction in [lo, hi); each fires in execution order with LIVE
-- pre-execution registers. opts.gate() restricts recording to a window.
local tr = probe.step.trace(0x801de840, 0x801df000, { gate = on_door_frame })

-- Width-correct range write-finder: arms width-`unit` (default 2) Write BPs
-- across [addr, addr+len) so a store of unknown width/alignment to a struct
-- is caught with the correct faulting PC + live registers + post-store bytes.
local fw = probe.step.find_writer(player + 0x10, 0x10, { on_write = log })
-- ... fw:count() / fw:records() / fw:dump(path)

Two gotchas these encode:

  • Watch width matters as much as address. A Write BP only matches accesses overlapping [addr, addr+width), and PCSX supports widths 1/2/4 only - a width-2 watch at exactly +0x14 misses a wider/offset store into the same struct. find_writer covers a range by arming a unit BP per slot. (This is what hid the Mei's-house door reposition behind a 2-byte no-op re-store; the range watch found the real writer - a field-VM 0x23 MOVE_TO - in one run.)
  • A Write BP fires at the store with live registers (not after the function returns); read getRegisters() directly in the callback.

Early-quit signal

probe.run polls ctx.request_quit each vsync and exits the capture loop on the next tick if it's set. Probes use this to bail as soon as their stop condition is met (e.g. every probe in a sweep has hit at least once), instead of waiting for LEGAIA_FRAMES to elapse:

on_capture = function(ctx, _elapsed)
    if every_probe_hit() then
        ctx.request_quit = true
    end
end,

Symbolic breakpoint addresses

Hard-coded 0x801DA51C-style breakpoint targets break across overlay re-imports that shift function entry points. The symbol resolver accepts Ghidra-canonical names from two sources:

  • Function entry points (FUN_801DA51C, slot-4 k10_shared labels, named overlays). Source: per-function dump headers under ghidra/scripts/funcs/*.txt.
  • Global data labels (DAT_8007078C / _DAT_8007BCD0, both case forms accepted). Source: the same dump-header walk, plus a regex harvest of DAT_xxxxxxxx references from the decomp body content (so DAT names show up even before dump_globals.py has been run for a given program), plus a dedicated dump_globals.py Jython script for authoritative names + lengths.

Three ways to use it:

-- Bespoke autorun:
local symbols = require("probe.symbols").load()
probe.arm_breakpoint(symbols.FUN_801DA51C, "Exec", 4, "world_map_sm", cb)
# .probe.toml: addr/base accept either an int or a symbol-name string.
[[breakpoint]]
addr = "FUN_801DD35C"     # resolves at spec-load time
kind = "Exec"
[[breakpoint]]
addr  = "_DAT_801EF16C"
kind  = "Read"
width = 4
# Regenerate after adding new dumps (covers funcs/* dumps and globals_*).
python3 scripts/pcsx-redux/build-symbols.py
# Authoritative globals (one-time per program; optional but lossless):
docker compose exec ghidra /ghidra/support/analyzeHeadless /projects legaia \
    -process SCUS_942.54 -noanalysis -postScript /scripts/dump_globals.py
# ... or pass `-process overlay_<name>.bin` for per-overlay globals.
python3 scripts/pcsx-redux/build-symbols.py

The resolver fails loudly on a typo'd symbol name - arming a breakpoint at nil otherwise silently captures zero hits and the probe runs to completion with no diagnostic. The hex portion of the name is case-insensitive: docs use FUN_801DD35C, Ghidra emits FUN_801dd35c, both resolve identically.

scripts/pcsx-redux/probes/_check_specs.py cross-validates every .probe.toml spec's symbol references against symbols.json so a typo'd symbol fails CI rather than the probe run.

Things that catch people out

  • Breakpoint width matters. lbu from a watched word triggers only when the width-1 byte falls inside the breakpoint's range. Arming a width-4 probe at an LW target works; arming a width-1 probe at an LBU target works; mismatches silently miss hits.
  • GP-relative addresses are decided at runtime. A naive hard-coded address can be wrong across overlay swaps. Read gp from PCSX.getRegisters() after the save-state load, then compute breakpoint addresses from there.
  • Sign-extended u64s in Lua. PCSX-Redux returns CPU register values as signed Lua numbers (64-bit doubles). gp = 0xFFFFFFFF8007B318 is the sign-extended display of 0x8007B318. Use bit.band(v, 0xFFFFFFFF) to normalise before formatting.
  • In-RAM guard predicates. Pure bitwise comparisons against literals like 0x80000000 interact with Lua's 32-bit signed return shape from bit.band - the literal is the unsigned 2147483648 while the bit-result is the signed -2147483648, so ~= returns true even when the addresses match. Use the explicit bit.band(addr, 0x1FFFFFFF) < RAM_SIZE form from the existing helpers; don't reinvent it.
  • GPU::Vsync events fire on game-driven VSync(0) calls, not 60 Hz hardware. PCSX-Redux delivers GPU::Vsync when the game calls libcd's VSync(0) syscall, which is sparse during boot init / CD-DMA phases. A probe waiting on vsync_count >= 600 to fire during boot can sit for minutes of wall time even when emulator-time has advanced past the target. For boot-phase timing use a memory watchpoint at a known transition register (e.g. _DAT_801EF16C title countdown) instead of a vsync-count target - the watchpoint fires precisely when the game writes the state transition.
  • Keep the createEventListener return value alive - a GC'd handle silently kills the listener. PCSX.Events.createEventListener returns a proxy object whose __gc deletes the underlying C++ listener (see src/core/eventslua.cc). Discard it and the listener dies at the next Lua GC cycle: the probe goes silent mid-session with no error, exactly when allocation churn (event bursts, string formatting, big reads) triggers a collect. A forced-collectgarbage A/B test kills an unanchored listener on the first pass while an anchored one survives indefinitely. GC of the proxy from inside an event dispatch can also corrupt the event bus and segfault the emulator (the eventslua.cc nested-GC comment). The shared probes anchor every handle in the global PROBE_LISTENER_ANCHORS table - follow that pattern in new probes.
  • Unpatched PCSX-Redux caps every Lua listener session at ~32.7k vsync events. The event dispatch in src/core/eventslua.cc pushes the EVENT_LISTENERS table + the listener-info table per event and never pops them - 2 leaked Lua stack slots per dispatch, hitting LuaJIT's ~65500-slot ceiling at almost exactly tick 32716 (the error dump is a wall of N: (Table) lines, then a fatal escaped exception; on a live display it takes the whole app down). Deterministic and content-independent - any long-running probe dies there. The local build carries a rebalance patch (int base = L.gettop() before dispatch, pop back to base after; verified alive past tick 33500 by a forced counter probe). Rebuilding PCSX-Redux from clean upstream REINTRODUCES the cap until the patch is upstreamed - re-apply it after any emulator update.
  • Don't readAt(2 MiB, 0) inside a vsync callback. A single 2 MiB PCSX.getMemoryAsFile():readAt(...) call permanently degrades subsequent GPU::Vsync event delivery in the same emulator launch - subsequent callbacks fire rarely or not at all. This is the listener-GC trap above wearing a different hat: the multi-MiB garbage burst triggers the collect that kills an unanchored listener. With the handle anchored, prefer small reads anyway (64 KiB at a time is safe) - full-RAM materialisation per vsync still stalls the frame.
  • PCSX.quit(0) doesn't always exit the process. Wrap every probe invocation with timeout --kill-after=10s <budget> so a hung emulator gets reliably killed. The captured data is already on disk by the time PCSX.quit fires - the timeout-kill is purely cleanup.
  • --fast must FORCE -dynarec, not just omit -interpreter. PCSX-Redux persists CPU + debugger choice in pcsx.json ("Dynarec": false, "Debug": true once the debugger has ever been used). With no CPU flag on the command line those saved values win, so a run launched --fast still comes up on the interpreter with the debugger enabled - the top bar reads CPU: Interpreted and fps stays at the slow-core rate. run_probe.sh --fast therefore passes -dynarec explicitly per-run. Always confirm the top bar reads CPU: Dynarec. The dynarec runs happily with the debugger window still open (no BPs = nothing to single-step); leaving the debugger unchecked is cleaner and drops it out of the sporadic scene-transition crash surface, and is safe because the exec-bp probes re-enable it per-run.
  • Config isolation makes the community kit config-independent. The -dynarec override above pins the CPU, but a volunteer's persisted pcsx.json still rides in for everything else - a broken hardware-GPU pick, a low frame limit, leftover debugger windows - because PCSX-Redux reads its whole profile (settings + memcards + imgui layout) from getPersistentDir() (src/core/system.cc): $HOME/.config/pcsx-redux on Linux, %APPDATA%\pcsx-redux on Windows. It does not honour XDG_CONFIG_HOME. The override hook is the -portable <PATH> flag, which repoints getPersistentDir() at any dir (src/core/arguments.cc: the flag's value sets both m_portable and m_portablePath). So run_probe.sh --fast (and run_probe.ps1 -Fast) default to isolation: they write a minimal fast profile - Dynarec on, Debug off, ship-default renderer, Scaler 100, auto-update off; every unset key falls back to the emulator's compile-time ship default (src/core/psxemulator.h) - into LEGAIA_PCSX_PROFILE_DIR (default captures/.pcsx-profile) and launch -portable at it. Memory cards are pointed at the real config dir via absolute Mcd1/Mcd2 paths (memorycard.cc only prepends the persistent dir to relative names), so card saves still load and save. Opt out with --no-isolate-config / -NoIsolateConfig (or LEGAIA_NO_ISOLATE=1); force the OpenGL renderer with LEGAIA_PCSX_HARDWARE_GPU=1.

Fast whole-playthrough capture (two-tier model)

Some questions - "which story flag/item/party change happens in which scene" across a long play session - are answered by a human playing the game, the one thing the harness can't automate. Two probes split that work by cost:

  • Tier 1 - autorun_state_poll.lua (fast, --fast/dynarec, ~full speed): arms no breakpoints. Every GPU::Vsync it diffs a fixed set of progression cells against the previous frame - the story-flag bank (0x80085758, idx space identical to the exec-bp writer's a0), the battle-id staging byte (0x8007B7FC), gold (0x8008459C), item inventory (0x80085958, consumables + start of the key-item page), party count/ids (0x80084594/0x80084598) - plus scene (0x8007050C) and mode (0x8007B83C) transitions. On each field->battle mode edge it also emits one battle row carrying the formation table (0x8007BD0C[4], the first-monster ids that identify boss vs random, sampled once the battle scene is active) plus a best-effort staging id. Per-frame diffing naturally filters intra-frame churn. The staging byte, unlike everything else, is written and consumed within one frame, so the battleid diff comes up empty and the battle row's staging field usually reads 0 - the writer needs the exec-bp firehose (Tier 2); the poll's win here is the formation identity. No breakpoints means it runs under the recompiler at full speed (dynarec even sustains 3x), so it is the probe to hand to community volunteers. Output state_poll.csv carries no Sony bytes - only flag/item ids, scene names, ticks. Trade-off: it captures what changed and where, not the writer. Maintainer cruise knob (off by default, so volunteer runs stay pure read-only): LEGAIA_POINT_CARD_MAX=1 pins the Point Card counter _DAT_800845B4 at its cap every vsync (ported from the firehose), so a Point Card (item 0xFE) strike one-shots any boss when you want to blow through fights on your own capture pass. It writes only that counter, none of the CSV cells.
  • The poll also captures, per frame, several context streams (all default-on, each toggleable) so a single playthrough answers more at once: a pos row on every player tile-crossing in field mode (player ptr 0x8007C364, X +0x14 / Z +0x18 s16, tile = (pos-0x40)>>7) - which pins each flag beat to where it fired (door/trigger attribution with no second pass); a bgm row on each global BGM-id change (0x8007BAC8, the music_labels census join); an fmv row on each FMV-trigger-id change (0x8007BA78, the field-VM op 0x4C 0xE2 target - live-confirms the disc-mined per-scene trigger assignment man_field_scripts::scene_fmv_triggers); input rows for pad press/release edges (0x8007B850) and a pick row sampling the dialogue picker cursor (*(0x801C6EA4)+0x0C) at a confirm press (branch/answer attribution). It tags a flag frame note=bulkload when it flips >= LEGAIA_BULK_FLAGS flags (a save-load/init dump, not a beat) so the analyzer filters the noise. And it auto-snapshots a fingerprinted save state on rare events - a never-seen scene, a lone-boss formation, a first-time target-set flag (LEGAIA_SNAP_FLAGS, default the known spine gates plus the open writer-less gates 0x50A/0x5D6 (a non-bulk organic SET of either brackets a code-path-writer hunt) and the statically-resolved 0x370 (writer pinned: the doman variant P1[15] Usha self-latch - the autosnap here is a play-order confirmation of the known writer, not a writer hunt)), a first nonzero battle-id staging byte (which would settle the open "does any retail battle write DAT_8007B7FC?" question), a first-run 0x400 status set, or a first-per-slot arts-input press (an arts-command-input-open bracket on whatever card the volunteer plays) - capped at LEGAIA_SNAP_MAX, so every volunteer run grows the mid-beat state library for free (LEGAIA_AUTOSNAP=0 to disable). Character-progression cells beyond level/spell are diffed too: per-roster-slot cumulative xp (record +0x0) and equip bytes (+0x196..0x19D, one row per changed equip slot), plus persistent counter cells on change (fishing points 0x8008444C, casino coins 0x800845A4, Point Card 0x800845B4). While a battle scene is active a battle-detail stream diffs each battle-actor slot (pointer table 0x801C9370, 0..2 party / 3..7 monsters): a status row on each +0x16E mechanical-status-word change (Venom / Toxic / Stone / Rot / AI-delegation / Curse timelines; a first-run raise of the 0x400 guard-disable bit auto-snapshots - the exact before/after bracket the open applier hunt needs), an hp row on each +0x14C current-HP change (a per-hit damage/heal/DoT timeline), and an aq row on each change of a party actor's action-parameter queue window (+0x1DF..+0x1F2, slots 0..2; note = the full 20-byte window hex). Arts inputs land there as raw direction bytes 0x0C..0x0F and a commit rewrites the window into starter form (0x19 art starter / 0x1A Super-Art special starter), so any Super Art the player performs hands back its committed queue bytes - the byte-exact replace-string validation the Super-Art capture batch otherwise needs dedicated sessions for. Baselines drop on battle exit so cross-fight pointer reuse can't fake rows. On the kingdom overworld scenes (mapNN, field mode) a wmcam row captures the walk-view camera tuple - rotation trio 0x8007B790/92/94, projection H 0x8007B6F4, eye-space TR trio 0x800840B8/BC/C0 (low s16 halves), view-mode flag 0x801F2B94 - once on entry, then on any change (min 10-frame gap, so a held zoom logs a ~6/s trajectory). The retail pitch/TR zoom dynamics are pinned at only two save-state anchors; any volunteer zoom/rotate fills in the path between them. A dt row logs the scratchpad frame-step byte 0x1F800393 (the multiplier every dt-scaled timer/animation consumes: world-map CLUT cadence, fishing tension, battle-action waits) - one baseline row per run, then whenever a new value holds 30 consecutive frames. Toggles: LEGAIA_TRACE_POS/_BGM/_INPUT/_BATTLE (0 = off). Regression self-test: bash scripts/pcsx-redux/run_state_poll_selftest.sh - a wrapper lua dofile()s the real probe on a field state, pokes every watched RAM cell, walks via pad override for organic pos rows, then loads a battle state mid-run and pokes the battle-actor streams; check_state_poll_selftest.py asserts every CSV stream and every autosnap trigger fired. Minutes end to end, no human at the pad - run it after any edit to the poll probe or lib/probe/*, before volunteer handoff.
  • Tier 2 - autorun_flag_firehose.lua (slow, interpreter+debugger, ~10 fps): exec-breakpoints on FUN_8003CE08/_CE34 capture the writer ra for the specific flags Tier 1 fingered. Run in short targeted bursts, not a full playthrough.
  • Tier 2 provenance - autorun_flag_reader_watch.lua (slow, interpreter+debugger): the full story-flag provenance probe, superseding the firehose. Arms all three flag helpers unfiltered - FUN_8003CE64 (TEST, reader ra) plus FUN_8003CE08/_CE34 (SET/CLEAR, writer ra) - deduped per (kind, flag, ra), so one interpreter trek banks reader+writer provenance for every flag the segment touches. (The static census cannot backstop runtime reads: its bytecode walker desyncs in dialogue-heavy MANs.) LEGAIA_FLAG takes a comma list of targets, which additionally get per-byte read-watches, prioritized call-context detail, and first-hit auto-snapshots. Riding the same session: a write-watch allowlist for non-flag globals (LEGAIA_WATCH_WRITES, default battle-id staging + formation table; rows carry the pre-store value at the hit and the committed value re-read at the vsync drain, since the bp fires mid-store); a VRAM upload log (exec-bps on LoadImage FUN_800583C8 / MoveImage FUN_80058490, deduped per (ra, rect), auto-disarmed across the STR/FMV modes 0x1A/0x1B where a hot LoadImage bp segfaults the emulator); per-fight battle identity rows with the last field tile before the mode left field (the encounter spawn spot; boss-shaped lone formations auto-snapshot); overlay-residency checksums (512-byte FNV-1a at slot A 0x801CE818 and slot B 0x801F69D8, re-taken on scene/mode changes - names which VA-aliased sibling was resident when each hit fired); and field-VM script-PC capture - when a flag helper's caller is overlay-resident, the probe scans the saved registers for a pointer whose bytes decode as the very op being executed (at the primary flag-op cluster s0 = the opcode VA, s8 = the dispatcher's pc_offset), so hits carry the exact script-buffer VA + byte offset of the bytecode op - runtime hits map directly to man-scripts disassembly lines. New-scene auto-snapshots bank a resume state at the mouth of every area reached, and manifest.txt records each run's config + source sstate.

The flag window is capped at 0x200 bytes (idx 0..4095) deliberately: the char-record slot-3 tail ends exactly at the flag base and the item inventory begins exactly 0x200 above it, so 0x200 is the largest window that is pure story-flag bytes with no overlap onto volatile record/inventory cells.

Version guard (lib/probe/version.lua). Every probe hard-codes USA-SCUS_942.54 addresses; a JP/EU/PAL or wrong-revision disc would arm on the wrong code and log silent garbage. Both tiers call version.check(), which fingerprints 6 always-resident code words at each of 0x8003CE08/_CE34/_CE64. Residency is gated on the fingerprinted code being loaded (not merely a boot-time anchor string), so it never latches on the all-zero partial-load window. Modes: locked (shipped default; mismatch = hard refusal), unlocked (warns but fail-closes on a non-Legaia anchor), record (LEGAIA_FP_RECORD=1 - prints the fingerprint for relocking after a rebuild). Volunteer runbook: scripts/pcsx-redux/COMMUNITY-CAPTURE.md.

# Tier 1 - fast community sweep (verify top bar = CPU: Dynarec):
LEGAIA_NO_SSTATE=1 timeout --kill-after=15s 14400s \
  bash scripts/pcsx-redux/run_probe.sh --fast \
    --lua scripts/pcsx-redux/autorun_state_poll.lua

# Relock the version fingerprint after an emulator/disc change:
LEGAIA_FP_RECORD=1 LEGAIA_NO_SSTATE=1 \
  bash scripts/pcsx-redux/run_probe.sh --fast \
    --lua scripts/pcsx-redux/autorun_state_poll.lua
# -> paste [state_poll] fingerprint = <hex> into version.USA_FINGERPRINT

Offline analysis (analyze_state_poll.py). A state_poll.csv is a raw per-frame event log; scripts/pcsx-redux/analyze_state_poll.py <csv-or-dir> turns it into the timelines a reverse-engineer actually reads: a scene timeline (contiguous occupancy windows), battle windows (the frames the game-mode byte sat in the battle-orbit modes 0x14/0x15) annotated with the per-fight battle identity rows (formation ids, with a * on lone-enemy fights = the likely bosses), a story-flag census that separates one-off story beats from the bulk flag dumps a save-load / scene-init writes in a single frame (any tick flipping >= 20 flags or carrying the probe's note=bulkload tag is treated as a load frame and reported separately, not as a beat), and the item / gold / party change lists. Each story-flag beat is annotated with the player tile it fired at (joined from the pos stream, scene-scoped) and a known-flag label - a sticky beat with no label is surfaced as a [lead] (a candidate unmapped gate; extend the label map with --labels FILE). Extra sections cover the BGM timeline, picker choices, auto-snapshots, XP grants + equipment changes, the counters (fishing points / casino coins / Point Card), the FMV triggers (fmv-id changes joined to the firing scene - the live confirmation column for the disc-mined trigger corpus), the battle status timeline (each +0x16E word decoded to its named bits, with any raise of the open 0x400 guard-disable bit called out as a report-this lead), the arts commits (aq-stream rows whose queue window is in starter form, Super Arts flagged for tail-matching against crates/art/src/super_art.rs), and (opt-in) the per-scene walk track, raw input edges, and the per-hit HP timeline. --json emits the same structure for downstream tooling; --only scenes,battles,flags,status,counters,... selects sections. The analysis functions are pure and importable, exercised by test_analyze_state_poll.py on synthetic rows (no capture or disc needed).

Core contract (dynarec vs interpreter). Lua breakpoints fire only under -interpreter -debugger; the recompiler runs them past silently. Enforced in three layers so a mismatch can never produce a silently-empty capture: (1) run_probe.sh forces the core explicitly in both directions and exports LEGAIA_CORE; (2) the breakpoint probes hard-refuse to arm when LEGAIA_CORE=dynarec, and the poll tier logs an advisory when launched on the interpreter; (3) hand launches with no LEGAIA_CORE hit the provenance probe's liveness canary - with the unfiltered TEST bp armed, field-mode gate tests fire every frame, so a long post-arm silence triggers loud repeating relaunch warnings. Every run's manifest.txt records the core it actually used.

Offline analysis (analyze_reader_watch.py). Aggregates a provenance capture into per-flag site tables - each distinct (kind, flag, pc, ra) with suppression-aware counts - and labels every site against the already-cataloged functions (the flag helpers, the field-VM flag-op handler returns, the walk-on tile-trigger dispatch, the bulk save-scan memcpy); anything uncataloged prints [NEW] with its region. Watched writes, VRAM uploads (single-row narrow rects tagged [CLUT?]), battles, and captured script-ops get their own sections. Pass several csvs or run directories and the sites merge into one cumulative provenance map - counts sum per run, every site lists the runs that saw it, --merged-out persists the JSON. The committed overlay-map.txt (FNV-1a32 of each raw overlay's first 512 as-loaded bytes - derived checksums only, no Sony bytes) resolves the probe's residency checksums to labels, so overlay-region hits print resident=[menu (PROT 899 ...)]; regenerate with --gen-overlay-map <extracted-dir>. Pure functions, exercised by test_analyze_reader_watch.py on synthetic rows.

Catalogue

The committed scripts live in scripts/pcsx-redux/. Each Lua file documents its purpose in a header comment block; the catalogue here is the high-level index.

Runtime probes (Lua autorun)

One line per script - each Lua file documents its full probe set + invocation in a header comment block. The important families are unpacked in the disclosures below the table.

ScriptWhat it answers
autorun_world_map_probe.luaPins the world-map POLY_FT4 emitter's one-shot gate flag (_DAT_801F351C) + the three-param block driving it (_DAT_8007BCD0..D8).
autorun_ocean_moveimage.luaExec-BP trace on the three libgpu transfer wrappers (vsync tick + scratchpad-read source RECT + dest per call, per-vsync dt column, in-capture snapshots). Confirmed the kingdom-slot-5 CLUT-walk cadence: constant ceil(hold/dt)*dt intervals, reset-to-zero accumulators, shared spawn epoch. Interpreter mode only.
autorun_world_map_fog_probe.luaCaptures the per-Z fog-tint LUT the world-map overlay consults on every vertex (0x801F7644..0x801F8690).
autorun_prim_pool_writers.luaConfirms the eight overlay-resident high-mode renderers write the 341 KB GPU prim pool at 0x800AD400+ (matches FUN_80043390's dispatch table).
autorun_lzs_and_bundle_probe.luaPins which PROT entries get LZS-decoded for the world-map bundle (LZS entry + bundle dispatcher FUN_8001F05C).
autorun_slot4_consumer_pcs.luaKingdom-agnostic slot-4 consumer PCs (cluster A + cluster B fire identically across all three kingdoms).
autorun_slot4_dispatcher_args.luaCaptures the original cluster-A dispatcher call args (0x80043390) before the kind handlers clobber a1 / a2.
autorun_dump_slot4.luaDumps the slot-4 RAM region; ground truth for verify_slot4_in_ram.py.
autorun_state_poll.luaFast (dynarec, no BPs) per-vsync diff of all progression state (flags/battle-id/gold/items/party/level/spell/xp/equip/counters/scene/mode/bgm/fmv) plus per-fight battle formation identity, an in-battle detail stream - status/HP (actor +0x16E/+0x14C) and the party action-queue windows (+0x1DF..+0x1F2, arts inputs + committed combo/Super-Art queue bytes) - player pos tiles, bgm id, input/pick edges, bulk-load tagging, and rare-event auto-snapshots, for a whole-playthrough sweep. Tier 1 of the two-tier model; the community-handoff probe.
autorun_state_poll_selftest.luaRegression self-test wrapper for the poll probe: dofile()s the real probe, pokes every watched cell (field state, then a mid-run battle-state load for the battle streams), drives pad-override walks for organic pos rows. Launched by run_state_poll_selftest.sh; verdict via check_state_poll_selftest.py (every stream + autosnap must fire). Run before any volunteer handoff.
autorun_flag_firehose.luaSlow (interpreter) exec-bp capture of EVERY story-flag write with its writer ra + battle-id staging watch. Tier 2 - writer provenance for the flags the poll tier fingers.
autorun_flag_reader_watch.luaSlow (interpreter) full PROVENANCE probe: unfiltered test/set/clear helper exec-bps + per-target byte read-watches + write-watch allowlist + VRAM upload log + per-fight battle rows + overlay-residency checksums + field-VM script-PC capture, with auto-snapshots, manifest.txt, dynarec hard-refusal and a BP-liveness canary. Summarize (and cross-run merge) with analyze_reader_watch.py.
autorun_slot4_source_map.luaShows slot 4 is read in place by the world-map renderer - no transcode (see world-map-overlay).
autorun_dump_full_ram_hold.luaHolds a pad direction through a warp, then dumps the full 2 MiB main RAM post-warp; pairs with locate_slot4_base.py.
locate_slot4_base.pyByte-locates a kingdom's slot-4 resident base (Drake 0x8011A624 / Sebucus 0x80119CE4 / Karisto 0x80108D84).
autorun_xp_table_reader.luaSuperseded: the real XP curve is DAT_80076AF4 (see the world-map + slot-4 disclosure); re-target before re-running.
autorun_field_pack_projection.luaCaptures the scene-asset loader's on-disc → RAM projection a single save state can't observe.
autorun_dump_full_ram.luaOne-shot full 2 MiB RAM dump. One dump per launch only - see the readAt(2 MiB) caveat above.
autorun_boot_walk_snapshots.luaMulti-snapshot RAM+register walk across LEGAIA_TARGETS vsyncs; the chunked-read workaround degrades past ~10 chunks - prefer chained single-shots.
autorun_countdown_trigger.luaWatchpoint-driven RAM + screenshot snapshot at the exact watched write; pinned FUN_801DD35C as the title-overlay tick (see boot - tick function).
autorun_player_pos_watch.luaPinned the town/field free-movement integrator (FUN_801d01b0, collision FUN_801cfe4c; see field-locomotion).
autorun_house_door_writer.luaCracked the intra-town house-door mechanism (field-VM 0x23 MOVE_TO) via probe.step.find_writer.
autorun_man_source.luaPinned a field scene's runtime MAN source (_DAT_8007b898).
autorun_title_overlay_writer_hunt.luaPins the SCUS-side title-overlay loader (write bps across the overlay window; run cold-boot with LEGAIA_NO_SSTATE=1).
autorun_monster_record_source.luaPinned the monster stat archive to PROT entry 0867_battle_data.
autorun_battle_reward_source.luaConfirmed the victory reward path (monster record +0x44..+0x49).
autorun_super_art_queue_builder.luactx[+0x274] is the turn-order active-actor index (FUN_801DABA4), NOT the art queue; kept as a turn-order diagnostic.
autorun_super_art_action_queue.luaReads the real Super/Miracle queue (actor[+0x1DF..+0x1F2] via the 0x801C9370 table); validated Noa's Miracle queue byte-exact.
autorun_super_art_input_replay.luaArts-input state loader + optional direction-sequence injector (LEGAIA_INPUT_SEQ, edge-only pad override) + per-frame CSV log of all three party queues. Injected directions verified to append 1:1 as raw queue bytes; empty sequence = crash-free observer for manual-input hybrid runs (the pad-override path can segfault after ~7-9 cycles, and catalogued arts-input states' bars are too short for any Super).
autorun_title_staging_capture.luaPins the PROT source of the title overlay via per-decode LZS source dumps (run cold-boot).
autorun_battle_palette_source.luaConfirms the scene bundle LZS-decodes into the shared work arena at load; does not pin the party palette (character-intrinsic, not a stored disc blob).
autorun_load_screen_dump.luaGround-truth framebuffer + RAM capture for the load-screen panel + slot-pill sprites (see save-screen - sprite asset sources).
autorun_town01_script_flow.luaPins a field scene's script execution model - which VM contexts run; collision walls paint at load time only.
autorun_battle_char_clut_source.luaPins the disc source of the battle-form party CLUT band (VRAM rows 490..497).
autorun_battle_party_mesh_install.luaPins the battle-form party-mesh install callsite (static SCUS FUN_800513F0 / FUN_800542C8).
autorun_battle_render_capture.luaLive-confirms the exact battle camera byte-exact (mode=0x15, pitch 32, TR=(0,1280,7680), H=256).
autorun_audio_trace.luaMulti-frame retail SPU trace for the audio-trace parity oracle (LEGSPU01 stream).
autorun_summon_model_base.luaTargets gp[0x754], the model_sel additive base read in the shared spawn stager FUN_80021B04 (unblocks summon + move-power effect-FX render).
autorun_battle_moveimage_trace.luaPinned move-VM op 0x40 as the animated-texture strip primitive (libgpu MoveImage wrapper FUN_80058490; see move-VM).
autorun_debug_bit_poke.luaLive-confirmed _DAT_8007B98F = 1 brings up the debug menu on SELECT+Δ in the NA retail build.
autorun_inventory_fill.luaRAM-fills all 72 consumable slots (0x80085958..0x800859E7) so the next item-add fires the unchecked add helper FUN_800421D4 out-of-bounds.
autorun_inventory_oob_writer.luaConfirmed the full-bag OOB id write via two live caller paths (casino exchange + equip-unequip).
autorun_flag_bank_watcher.luaLogs flag-bank SET/CLR/TST calls (FUN_8003CE08/CE34/CE64) with out-of-range bit indices.
autorun_spine_flag_writers.luaArms the three chapter-1 spine writes at once - a Write-watch on 0x8007b7fc (Zeto battle-id) plus exec-bps on setter FUN_8003CE08 filtered a0==322/a0==1154 (flags 0x142/0x482), each logging the caller ra. Bare Vsync listener, no self-quit; interactive card-save play. See spine-flag-writers-capture.
autorun_key_item_consumer_hunt.luaFinds every native reader of the OOB-writable key-item id bytes (0x800859E8..+0x18).
autorun_shiny_recon.luaShiny-Seru playtest recon: detour-site patch status + live shiny markers on a booted patched disc.
autorun_battle_state_stream.luaThe shared live battle-state EVENT SOURCE: per-VSync poll (breakpoint-free, --fast-safe) diffed into a newline-delimited JSON stream.
autorun_anim_node_tick_caller.luaPins the indirect (jalr) caller of the battle anim-node tick FUN_80047430 by deduping $ra at function entry.
autorun_minigame_fishing.lua / _dance / _slot_machine / _baka / _muscle_domePer-minigame controller + static-table probes (scenarios minigame_*_pcsx); see the minigames disclosure.
autorun_minigame_overlay_capture.luaThe mode-24 minigame overlay entry window; refuted the "PROT 0896 = mode-24 OTHER overlay" hypothesis.
autorun_play_from_boot.luaBoot-onward scripted driver for the trace-driven-coverage program (checkpoints reloadable .sstate anchors).
autorun_trace_segment.luaGap-set exec-bp segment tracer: which not-yet-understood functions actually ran during one opening segment.
trace_scenario.shRuns the whole gap-set against one catalogued checkpoint as a union of windowed trace passes (captures/trace/<label>/union.csv).
autorun_s3_recon.lua / _s3_pc / _s3_capturePinned the town01 opening block to the name-entry screen, then completed it and captured the free-roam anchor.
autorun_s4_gridrecon.lua / _s4_doornavGrid-BFS door navigation over the walkability grid at *(_DAT_1f8003ec)+0x4000; captures the door-transition anchor.
autorun_s4_recon.lua / _s4_capture / _s4_padmap / _s4_navsweepSuperseded S4 exploration probes (u32-position-read artifacts; see the trace-coverage disclosure); use autorun_s4_doornav.lua.
autorun_s5_encounter.lua / _s5_actors / _s5_tetsu / _s5_sparRim Elm's opening has no random encounters; the first battle is the scripted Tetsu spar (captured via input record/replay).
autorun_dump_storyflags.luaDumps the field-VM story-flag bank (0x80085758, 0x400 bytes) for diffing scripted beats between states.
autorun_record_inputs.lua / autorun_replay_inputs.luaManual input recorder (interactive) + deterministic headless replayer (drives pad.force, not RAM writes).
autorun_btnmap.luaPins the 0x8007B850 button-mask layout: the byte-swapped PSX controller word (UP=0x1000, CROSS=0x0040, …).
World map + slot 4
  • slot4_consumer_pcs is kingdom-agnostic: clusters A + B fire on Drake, Sebucus (town → map02) and Karisto (town → map03) with the same caller RAs (cluster B's RA 0x80059C00 byte-identical across all three; cluster A's RAs 0x8001B47C inside FUN_8001ada4 + 0x801F78D4 world-map overlay). CSV is probe_idx, cluster, pc, name, ra, a0..a3, s8; LEGAIA_PC_CAP=N raises the 200-hit-per-PC cap.
  • slot4_dispatcher_args captures caller RA, descriptor pointer a0, packed cmd_flags (a1), fade_flags (a2) + the first command word's kind/count, classifying the four dispatcher banks (0x00/0x50/0xA0/0xF0). LEGAIA_DISP_CAP=N raises the hit cap.
  • slot4_source_map tiles Read bps across the slot-4 window + an Exec bp on the FUN_8001E54C streaming dispatcher and drives the warp itself: slot 4 is consumed in place, no transcode. Tile at the per-kingdom base - locate it first with autorun_dump_full_ram_hold.lua + locate_slot4_base.py.
  • xp_table_reader: the real XP curve is DAT_80076AF4, read by the overlay applier FUN_801E9504; the old 0x8007123C target is an off-by-0x800 artefact over a sin-LUT slice (see level-up XP table). The tiled-read-bp CSV/detail shape stays reusable for any scan.
Field scenes, doors + locomotion
  • player_pos_watch: write-watch on *(0x8007C364)+0x14/+0x18 (armed lazily post-load - the target is a runtime pointer deref); hits land in FUN_801d01b0 (overlay 0897) at the four sh player[+0x14/0x18] stores, collision via FUN_801cfe4c. Run against a save parked in a walkable town.
  • house_door_writer: probe.step.find_writer over player+0x10..+0x20 while entering a house - the writer is FUN_801de840 case 0x23 (field-VM MOVE_TO): an intra-scene reposition, not a scene change. A width-2 watchpoint catches only a 2-byte no-op re-store; the range watch finds the real writer in one run.
  • man_source: Exec bp at the asset-type dispatcher FUN_8001F05C filtered to the MAN dispatch (a1 >> 24 == 3); caller is FUN_80020224, the scene_asset_table walker off _DAT_8007b85c. Caught a count=6 scene_asset_table variant a strict count-7 detector skipped.
  • town01_script_flow: dedupes per-context field-VM steps + watches the three nibble-7 collision-grid write sites; a parked scene runs one steady-state context and paints zero walls per frame (walls are load-time only). See field-locomotion.
  • field_pack_projection: Exec bp at FUN_8001F7C0 + a one-shot bp at its return address, driving the warp via LEGAIA_HOLD_BUTTON/LEGAIA_HOLD; diff with diff_field_pack_projection.py. World-map scenes are not field-pack-formatted - they produce a 75 KB GP0-primitive pool projection at _DAT_8007B8D0 - 0x12800 instead.
Battle
  • monster_record_source: per-id 0x14000 LZS slot at (id-1)*0x14000 inside PROT 0867_battle_data (extended footprint); decoded records match the live actors byte-for-byte; the monster_data label (PROT 869) is a stub. See battle - monster archive.
  • battle_reward_source: run against a lone-enemy fight that resolves without input; the gold write lands in FUN_8004E568 through the lone-enemy floor((gold>>1)/2) formula, pinning the reward fields to record +0x44..+0x49 (gold / EXP / drop id / drop %). The staged accumulator 0x80084440 is the minigame-winnings stage, and FUN_80026018 is the mode-24 minigame exit handler - a battle never calls it. See battle-formulas - victory spoils.
  • battle_party_mesh_install: the party meshes register through the generic tmd_register (FUN_80026B4C) from two static SCUS state-handlers - FUN_800513F0 (lead/active actors) and FUN_800542C8 (additional members) - dispatched indirectly, so a static xref finds no writer. Caveat: PCSX-Redux fires Write BPs pre-commit, so a watchpoint's value column shows the old pointer; the tmd_register-entry a0 is authoritative.
  • battle_char_clut_source: run against a field save where the row-490..497 CLUT band is not yet resident so battle-init forces a fresh disc load; map the logged LBAs with map_clut_disc_reads.py.
  • battle_render_capture: reads camera + actor state from inside the func_0x801d02c0 grid-render breakpoint (at frame 0 the globals hold stale field state): mode=0x15, pitch 32, TR=(0,1280,7680), H=256, 28×28 grid, actor scale +0x72=0x1000.
  • battle_state_stream: reads the typed battle state via lib/probe/battle_state.lua (actor table &DAT_801C9370, battle ctx _DAT_8007BD24, monster ids DAT_8007BD0C) and emits JSON deltas + periodic full sweeps; the shared event source for the diorama/spectator delivery targets. Caveat: resumed battle saves can segfault the emulator a few vsyncs in (save-resume instability, not the probe); flushed records survive.
  • battle_palette_source: the 0x800EBEE8 writes come from LZS-decoding the scene bundle into a shared work arena - scene data, not the party palette (which is character-intrinsic and not a stored disc blob; see character-mesh).
  • super_art_queue_builder / super_art_action_queue: ctx[+0x274] is the turn-order active-actor index; the real Super/Miracle queue is the per-actor +0x1DF..+0x1F2 action-parameter stream.
Title / boot + ground-truth captures
  • countdown_trigger: width-2 Write BP at LEGAIA_WATCH_ADDR (default 0x801EF16C, the title-attract countdown); LEGAIA_HIT_SKIP skips the boot-time DMA write; LEGAIA_DUMP_BASE/LEGAIA_DUMP_LEN restrict the dump window; optional PCSX.GPU.takeScreenShot() decoded via decode_pcsx_screen.py.
  • title_overlay_writer_hunt + title_staging_capture: run cold-boot (LEGAIA_NO_SSTATE=1) - in-game saves are past the load point. Writer-hunt bps identify the SCUS-side loader; the staging capture dumps each LZS decode's compressed source for offline byte-matching against PROT entries.
  • load_screen_dump: framebuffer + 2 MiB RAM at the Continue → Load screen; pair with extract_vram_from_sstate.py + decode_vram.py for full VRAM (that pipeline pinned the panel CLUT to row 2 of the system-UI TIM at PROT.DAT[0x018E0]). No breakpoints, so it runs with --fast.
  • audio_trace: PCSX.createSaveState() every LEGAIA_INTERVAL vsyncs, FFI-walked in place to slice only the SPU sub-message (~600 KiB vs 20 MiB per state); decode with extract_audio_trace_from_sstates.py into the JSONL AudioTraceFrame shape legaia-engine audio-trace --retail-jsonl consumes. createSaveState is the load-bearing primitive - the Lua API doesn't expose the SPU register file directly.
ACE (arbitrary-code-execution) hunt
  • debug_bit_poke: pokes _DAT_8007B98F = 1 from a stable field save; the debug menu comes up on SELECT+Δ. The consumer is overlay-resident and outside the captured corpus - zero static references in SCUS_942.54.
  • inventory_fill then inventory_oob_writer: fill the 72-slot consumable window, then arm probe.step.find_writer on the key-item window (0x800859E8..0x800859F8) and flag stores from 0x800422BC (the add helper's unguarded id store). Confirmed via casino-exchange CROSS (id 0x9C0x800859E8) and equip-unequip via the START menu (id 0xD00x800859EA); successive adds chain one slot at a time.
  • flag_bank_watcher: exec bps on the flag-bank entry points with an early-out below the OOB-reachable start; watch for out-of-range lines while driving the debug menu.
  • key_item_consumer_hunt: Read bps on the first 24 key-item bytes (0x800859E8..+0x18) + passive Write bps on the debug bytes (0x8007B8C2/0x8007B98F); the heartbeat unique-PC summary is the primary artefact - every native site that reads the OOB-writable id bytes is a candidate chain.
Minigames

Each targets its per-frame controller state machine plus the scoring/payout write and a static-table dump, driven from the matching minigame_*_pcsx scenario:

  • Fishing (autorun_minigame_fishing.lua): mode SM FUN_801cf3bc + tension tick FUN_801d4004 (samples gauge DAT_801d9168); write-watches the score _DAT_8008444c.
  • Dance (autorun_minigame_dance.lua): beat-clock SM FUN_801cf470 + hit judge FUN_801d1960 (live groove gauge DAT_801d544c); dumps step chart DAT_801d509c + bonus table.
  • Slot machine (autorun_minigame_slot_machine.lua): reel SM FUN_801cf0d8 + win eval FUN_801d13e8 + LCG FUN_801d30cc; write-watches coin bank _DAT_800845A4; dumps payout table DAT_801d3598.
  • Baka Fighter (autorun_minigame_baka.lua): round SM FUN_801d3468 + RPS resolver FUN_801d3a14; write-watches gold _DAT_80084440; dumps AI move-pattern table DAT_801d76e8.
  • Muscle Dome (autorun_minigame_muscle_dome.lua): match SM FUN_801d0748 (phase ctx+6) + card driver FUN_801d388c; reads the +0x1df card queue; dumps deck tables DAT_801f4b8c/DAT_801f4b94.
  • autorun_minigame_overlay_capture.lua: live-confirmed the 0x3E operand−100 sub-id model and refuted the "PROT 0896 = mode-24 OTHER overlay" hypothesis - the SCUS-resident init streams each minigame overlay straight into slot A.
Trace-driven-coverage program (boot driver + S3-S5)
  • autorun_play_from_boot.lua: boot-onward scripted driver (bespoke per-VSync listener) - mashes START+CROSS to skip logos / "PRESS START" / FMV, confirms NEW GAME, and checkpoints a reloadable field save at a target mode. Cold boot works with -interpreter -debugger -fastboot + a non-vsync title-tick exec-bp past the title's vsync-blind window.
  • autorun_trace_segment.lua + trace_scenario.sh: arm a non-pausing exec-bp on every not-yet-understood function entry (the gap-set worklist), play one opening segment, and record which gap-set functions ran + a game-mode timeline; trace_scenario.sh unions windowed passes against one catalogued checkpoint into captures/trace/<label>/union.csv.
  • S3 (autorun_s3_*.lua): pinned the town01-opening deadlock to STATE_RESUME (op 0x49) in town01 P2[3] +0x02C6 - the opening is the name-entry screen; autorun_s3_capture.lua completes name entry and captures the free-roam anchor.
  • S4 (autorun_s4_doornav.lua): grid-BFS door-nav walks Vahn out of his house over the walkability grid at *(_DAT_1f8003ec)+0x4000. Player position must be read as 16-bit signed at +0x14/+0x18 (a u32 read folds the +0x16 facing word into the X high half); the superseded recon probes' "dynamic camera-remap / 16.16 positions" were that artifact.
  • S5 (autorun_s5_*.lua): Rim Elm's opening has no random encounters; the first battle is the scripted Tetsu spar (a 4-item list whose 3rd entry is the training fight), captured by record/replay of a human playthrough rather than mash-only navigation.
  • Input record/replay: autorun_record_inputs.lua logs the per-frame button mask 0x8007B850; autorun_replay_inputs.lua replays it via pad.force/pad.release (NOT RAM writes - FUN_8001822C rebuilds the mask from the actual pad); autorun_btnmap.lua pinned the mask as the byte-swapped PSX controller word.

Save-state to Python (offline analysis)

ScriptInputOutput
dump_kingdom_ram_layout.py.sstate files for the three kingdomsPer-kingdom RAM-layout JSON used by the world-overview page.
walk_actor_lists.py.sstate for a world-map sessionWalks the seven actor-list heads + dumps per-actor records (used by resolve_actor_tmds.py).
resolve_actor_tmds.py.sstate + the kingdom slot-1 TMD packWalks actor[+0x44] mesh-head chains, finds the containing TMD via backward magic-word search, maps to a pack slot. Output is site/world-overview-live.json.
verify_slot4_in_ram.pyautorun_dump_slot4.lua outputConfirms the live RAM region matches the disc-decoded slot-4 sub-bodies byte-for-byte.
diff_slot4_ram_vs_disc.pyLive + disc slot-4 bytesGenerates the byte-level diff visualisation.
match_prim_groups_to_disc.pyLive prim-pool dump + disc TMD packMatches POLY_FT4 prim groups back to their source TMD bodies.
diff_field_pack_projection.py.post.NN.bin + .meta from the field-pack projection probe; on-disc LZS-decoded PROT entryWalks the canonical 97-slot field-pack schema; for each slot, compares runtime RAM bytes against on-disc bytes and prints a per-slot diff sorted by changed-byte count, plus a hex preview of the first divergence per slot.
decode_pcsx_screen.py<OUT>.screen + .screen.meta from autorun_countdown_trigger.lua (or any probe that calls PCSX.GPU.takeScreenShot())PNG of the visible framebuffer at the capture moment. Decodes BGR555 (bpp=16) or BGR888 (bpp=24). Pillow required for PNG output; falls back to raw RGB888 if Pillow is missing.
decode_load_screen.pyload_screen_fb.raw + .meta from autorun_load_screen_dump.luaPNG of the rendered load-screen framebuffer. Dependency-free (uses stdlib zlib + manual PNG chunks); pixel coordinates match PSX 320×240 framebuffer 1:1. Pairs with the panel-source RE in subsystems/save-screen.md.
extract_audio_trace_from_sstates.pyThe LEGSPU01-magic binary stream from autorun_audio_trace.luaJSONL stream of AudioTraceFrame records consumed by legaia-engine audio-trace --retail-jsonl and the disc-gated audio_trace_multi integration test. Walks PCSX-Redux's SPU protobuf schema: 24 × Channel sub-messages (Chan::Data + ADSRInfo + ADSRInfoEx) plus the 512-byte SPU register file (MainVol_L / MainVol_R at offset 0x180/0x182, Reverb_Mode at 0x1AA). Voice "audible" = Chan::Data.on || Chan::Data.stop; ADSRInfoEx.state is the configured envelope shape and reads as Sustain for unused voices, so it is not a reliable audibility signal.
extract_vram_from_sstate.pyA PCSX-Redux .sstate* file1 MiB raw BGR555 VRAM blob (vram.bin). Gunzips the save state and finds the GPU.vram protobuf field (canonical tag 0x1A 0x80 0x80 0x40 = field 3, wire-type 2, length 0x100000). Dependency-free. The PCSX-Redux equivalent of mednafen-state vram-dump: ground-truth VRAM at any parked state, useful for back-referencing sprite sources and CLUT rows against the extracted TIM corpus.
decode_vram.pyvram.bin from extract_vram_from_sstate.py1024×512 PNG of the BGR555 VRAM. Stdlib-only. Pixel coords map 1:1 to PSX VRAM (fb_x, fb_y), so CLUT rows at fb_y=480+ and texture pages at fb_x≥640 are visible at a glance.
scan_panel_prims.pyA 2 MiB main-RAM dump (e.g. load_screen_ram.bin) + optional --rect X0 Y0 X1 Y1 framebuffer rectLists every GP0 textured-sprite primitive (cmd byte 0x64..0x67) whose dst falls in the rect, decoded into (dst_x, dst_y, u, v, clut_x, clut_y, w, h). Groups by CLUT so the unique source tiles each CLUT references stand out. Used to pin the 9-slice tile geometry of the load-screen panel (14 prims sampling CLUT row 2 of the system-UI TIM) - see subsystems/save-screen.md.

One-shot wrappers

run_probe.sh is the single canonical shell harness for every probe. It accepts both env vars (LEGAIA_LUA, LEGAIA_SSTATE, LEGAIA_OUT, …) and matching --lua / --sstate / --out / --scenario / --fast flags. Output defaults to captures/<probe-stem>/<iso-timestamp>/ so each run gets a fresh per-run subtree.

# Default world-map probe (interpreter mode, Lua BPs fire).
bash scripts/pcsx-redux/run_probe.sh

# Pick a different probe.
bash scripts/pcsx-redux/run_probe.sh --lua scripts/pcsx-redux/autorun_dump_slot4.lua

# Resolve the save state via a named scenario from scripts/scenarios.toml
# (a PCSX-Redux-backed scenario; mednafen-only backups can't load here -
# see `manage-states.py library --audit` for which scenarios qualify).
bash scripts/pcsx-redux/run_probe.sh --scenario party_basic_attack_vs_gobu_gobu \
    --lua scripts/pcsx-redux/autorun_battle_state_stream.lua

# Cold-boot a title/boot probe (no save state - runs from power-on).
LEGAIA_NO_SSTATE=1 bash scripts/pcsx-redux/run_probe.sh \
    --lua scripts/pcsx-redux/autorun_countdown_trigger.lua

# Fast (recompiler) mode - FORCES `-dynarec` (overriding the persisted
# interpreter+debugger config; confirm top bar = CPU: Dynarec). Lua **BPs
# do NOT fire** under the recompiler, so this is for vsync-event-only
# probes: full-RAM dumps and the poll-diff progression capture below.
bash scripts/pcsx-redux/run_probe.sh --fast \
    --lua scripts/pcsx-redux/autorun_state_poll.lua

The earlier run_world_map_probe.sh / run_fast_probe.sh / run_dump_slot4.sh wrappers were folded into this one runner.

GDB-stub bridge (gdb_probe.py)

gdb_probe.py is the one-shot escape hatch. PCSX-Redux exposes a GDB Remote Serial Protocol stub on TCP port 3333 (settings: Emulator → GDB server port); this script speaks the protocol directly. Use it when the .probe.toml state machine is overkill - ad-hoc reads, single-shot "break-here-read-there" investigations, register dumps.

SubcommandUse
read-mem ADDR LEN [--out F]Hex dump or raw bytes to file. ADDR is hex or a Ghidra symbol.
read-regsDump 38 PSX MIPS GPRs + PC.
write-mem ADDR HEXBYTESPatch memory in-flight.
when-pc-hits ADDR --read-mem A,L [--out F]One-shot: arm exec BP, continue, read on hit, disarm.
watch ADDR LEN --kind {read,write,access}Insert a watchpoint, print the stop reply when it fires.
selftestRun protocol-codec + client self-tests against an in-process mock server (no live emulator needed).

When to use this vs .probe.toml:

  • .probe.toml for repeatable captures that produce a CSV which probe.py regress can gate on.
  • gdb_probe.py for one-shot ad-hoc queries - no schema, no scenario, no state machine to author.
# Read 512 bytes of the kingdom slot-4 region in-flight:
scripts/pcsx-redux/gdb_probe.py read-mem 0x8011A624 512

# Dump registers right now:
scripts/pcsx-redux/gdb_probe.py read-regs

# One-shot break-and-read: when the title overlay tick fires, dump the
# attract-countdown register:
scripts/pcsx-redux/gdb_probe.py when-pc-hits FUN_801DD35C \
    --read-mem _DAT_801EF16C,16

Symbol names resolve via the same ghidra/scripts/symbols.json the Lua probe layer uses; misses raise with the regenerate-via hint. Hex (0x801DE840, 801de840) is always accepted.

Analysing probe outputs (probe.py)

probe.py is the Python-side companion to a .probe.toml run. It operates on the CSV outputs and provides four operations the Lua side intentionally doesn't try to do in-emulator:

SubcommandUse
probe.py summary RUNHeader + row count + canonical fingerprint.
probe.py fingerprint RUNSHA-256 over canonicalised rows. Independent of row order and of --ignored columns.
probe.py diff BASELINE CURRENTSet-diff: added / removed rows. Useful for inspecting why two runs differ.
probe.py regress BASELINE CURRENTFingerprint compare. Exits 0 on match, 1 on regression - the foundation for CI gating.

--ignore COL[,COL...] drops named columns before comparison / hashing. Use it for fields that naturally vary between runs without representing a regression - most commonly tick (the per-bp hit counter is order-dependent) and sometimes pc (when the same code path gets reached via different inlining decisions across overlay rebuilds).

# Re-run a probe spec, compare against a committed baseline:
bash scripts/pcsx-redux/run_probe.sh --spec scripts/pcsx-redux/probes/xp_table_readers.probe.toml
scripts/pcsx-redux/probe.py regress \
    captures/baselines/xp_table_readers.csv \
    captures/xp_table_readers/<latest>/xp_table_readers.csv \
    --ignore tick

Authoring a new probe

Two shapes are supported, in order of preference:

Declarative .probe.toml (simple probes)

For "arm N breakpoints, dump K columns to CSV" or "settle then dump a RAM region", the probe is a single TOML file under scripts/pcsx-redux/probes/ with no Lua code at all. The shared probes/_runner.lua parses the spec via lib/probe/toml.lua and dispatches into lib/probe/spec.lua.

Schema (see probes/xp_table_readers.probe.toml for the breakpoint-fan-out case and probes/dump_full_ram.probe.toml for the RAM-dump case):

scenario        = "title_attract"   # informational; LEGAIA_SSTATE wins
capture_frames  = 600
output_path     = "my_probe.csv"
capture_columns = ["tick", "addr", "pc", "ra", "value_u32"]

[detail]                            # optional: first N hits get full
hits = 8                            # register/code/stack snapshots in a
path = "my_probe.detail.txt"        # .detail.txt sidecar

[[breakpoint]]                      # individual breakpoint
addr  = 0x80017EC8
kind  = "Exec"                      # "Exec" | "Read" | "Write"
width = 4
name  = "world_map_tick"

[[breakpoint_range]]                # fan out N adjacent breakpoints
base     = 0x8007123C
length   = 196                      # bytes
stride   = 4                        # bytes per bp
kind     = "Read"
name_fmt = "xp+0x%03X"              # %X / %x / %d = byte offset from base

Capture-column vocab (built into lib/probe/spec.lua): tick, addr, offset, pc, ra, sp, width, value_u8 / value_u16 / value_u32.

Run it:

bash scripts/pcsx-redux/run_probe.sh \
    --spec scripts/pcsx-redux/probes/my_probe.probe.toml \
    --scenario title_attract     # or --sstate /path/to/state.sstate

Validate the schema (without launching PCSX-Redux):

python3 scripts/pcsx-redux/probes/_check_specs.py

If lua5.1 is available, the validator also parses each spec via lib/probe/toml.lua and asserts the structural output matches Python's tomllib - catches divergence between the Lua TOML reader and the canonical TOML spec.

Lua autorun (bespoke probes)

For anything more elaborate (per-hit logic that depends on register state, multi-state-machine probes, dynamic breakpoint arming, etc.), write a Lua autorun. The fastest path:

  1. Start from scripts/pcsx-redux/autorun_slot4_consumer_pcs.lua - the canonical thin probe (~145 lines) that uses the shared library for everything except the per-probe breakpoint body.
  2. Edit the PROBE_OFFSETS (or your own probe-address list), the CSV header, and the per-hit row written from inside the breakpoint callback. The boot-delay / capture-vsync / disarm state machine comes from probe.run({...}) - don't reimplement it.
  3. Run with the harness:
    LEGAIA_LUA=scripts/pcsx-redux/autorun_your_thing.lua \
    LEGAIA_OUT=/tmp/your_probe.csv \
        bash scripts/pcsx-redux/run_probe.sh
  4. Iterate on the live CSV. The harness re-launches the emulator per run; the CSV is overwritten each time. While the probe is running, the snapshot file (<probe>.hits.txt next to the CSV) is rewritten every 60 vsyncs - tail it from another shell to watch hit counts climb live.

When the probe surfaces a useful signal, commit the Lua file under scripts/pcsx-redux/ and update the catalogue table above. The CSV output itself is gitignored - it's a per-run artifact, not a project state.

See also