PCSX-Redux automation
PCSX-Redux is an open-source PSX emulator with a Lua scripting API and a breakpoint debugger over the live emulated CPU - which means a script can boot the game, jump to a saved moment, plant traps on addresses of interest, and log what the running game actually does with them. The scripts/pcsx-redux/ directory contains closed-loop probes built on that combination, answering questions static disassembly can't: "what code reads this address?", "when does this RAM region get populated?", "what's the dispatch path between two functions?". This is maintainer infrastructure - you only need it if you are extending the reverse-engineering itself.
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 whenDebugSettings::Debugis 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.sstatefile 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:
- Verifies the binary / BIOS / save state / Lua file all exist (fails early with a clear error if any one is missing).
- Launches PCSX-Redux with
-interpreter -debugger -run -bios <SCPH> -iso <bin> -dofile <lua> -stdoutand pipes the emulator log tologs/pcsx_<probe>.log. - 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:
- WAIT_BOOT - vsync listener counts up while the emulator boots the BIOS to a known state (typically 60 vsyncs = 1s).
- 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_FRAMESvsyncs while breakpoints log hits to the CSV. - 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<- pcmarker 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 savesrainto 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
WriteBP only matches accesses overlapping[addr, addr+width), and PCSX supports widths 1/2/4 only - a width-2 watch at exactly+0x14misses a wider/offset store into the same struct.find_writercovers 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-VM0x23 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-4k10_sharedlabels, named overlays). Source: per-function dump headers underghidra/scripts/funcs/*.txt. - Global data labels (
DAT_8007078C/_DAT_8007BCD0, both case forms accepted). Source: the same dump-header walk, plus a regex harvest ofDAT_xxxxxxxxreferences from the decomp body content (so DAT names show up even beforedump_globals.pyhas been run for a given program), plus a dedicateddump_globals.pyJython 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.
lbufrom 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
gpfromPCSX.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 = 0xFFFFFFFF8007B318is the sign-extended display of0x8007B318. Usebit.band(v, 0xFFFFFFFF)to normalise before formatting. - In-RAM guard predicates. Pure bitwise comparisons against literals like
0x80000000interact with Lua's 32-bit signed return shape frombit.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 explicitbit.band(addr, 0x1FFFFFFF) < RAM_SIZEform from the existing helpers; don't reinvent it. GPU::Vsyncevents fire on game-drivenVSync(0)calls, not 60 Hz hardware. PCSX-Redux deliversGPU::Vsyncwhen the game calls libcd'sVSync(0)syscall, which is sparse during boot init / CD-DMA phases. A probe waiting onvsync_count >= 600to 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_801EF16Ctitle countdown) instead of a vsync-count target - the watchpoint fires precisely when the game writes the state transition.- Keep the
createEventListenerreturn value alive - a GC'd handle silently kills the listener.PCSX.Events.createEventListenerreturns a proxy object whose__gcdeletes the underlying C++ listener (seesrc/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-collectgarbageA/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 (theeventslua.ccnested-GC comment). The shared probes anchor every handle in the globalPROBE_LISTENER_ANCHORStable - 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.ccpushes theEVENT_LISTENERStable + 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 ofN: (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 tobaseafter; 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 MiBPCSX.getMemoryAsFile():readAt(...)call permanently degrades subsequentGPU::Vsyncevent 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 withtimeout --kill-after=10s <budget>so a hung emulator gets reliably killed. The captured data is already on disk by the timePCSX.quitfires - the timeout-kill is purely cleanup.--fastmust FORCE-dynarec, not just omit-interpreter. PCSX-Redux persists CPU + debugger choice inpcsx.json("Dynarec": false,"Debug": trueonce the debugger has ever been used). With no CPU flag on the command line those saved values win, so a run launched--faststill comes up on the interpreter with the debugger enabled - the top bar readsCPU: Interpretedand fps stays at the slow-core rate.run_probe.sh --fasttherefore passes-dynarecexplicitly per-run. Always confirm the top bar readsCPU: 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
-dynarecoverride above pins the CPU, but a volunteer's persistedpcsx.jsonstill 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) fromgetPersistentDir()(src/core/system.cc):$HOME/.config/pcsx-reduxon Linux,%APPDATA%\pcsx-reduxon Windows. It does not honourXDG_CONFIG_HOME. The override hook is the-portable <PATH>flag, which repointsgetPersistentDir()at any dir (src/core/arguments.cc: the flag's value sets bothm_portableandm_portablePath). Sorun_probe.sh --fast(andrun_probe.ps1 -Fast) default to isolation: they write a minimal fast profile -Dynarecon,Debugoff, ship-default renderer,Scaler100, auto-update off; every unset key falls back to the emulator's compile-time ship default (src/core/psxemulator.h) - intoLEGAIA_PCSX_PROFILE_DIR(defaultcaptures/.pcsx-profile) and launch-portableat it. Memory cards are pointed at the real config dir via absoluteMcd1/Mcd2paths (memorycard.cconly prepends the persistent dir to relative names), so card saves still load and save. Opt out with--no-isolate-config/-NoIsolateConfig(orLEGAIA_NO_ISOLATE=1); force the OpenGL renderer withLEGAIA_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. EveryGPU::Vsyncit diffs a fixed set of progression cells against the previous frame - the story-flag bank (0x80085758, idx space identical to the exec-bp writer'sa0), 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 onebattlerow 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 thebattleiddiff comes up empty and thebattlerow'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. Outputstate_poll.csvcarries 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=1pins the Point Card counter_DAT_800845B4at its cap every vsync (ported from the firehose), so a Point Card (item0xFE) 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
posrow on every player tile-crossing in field mode (player ptr0x8007C364, X+0x14/ Z+0x18s16,tile = (pos-0x40)>>7) - which pins each flag beat to where it fired (door/trigger attribution with no second pass); abgmrow on each global BGM-id change (0x8007BAC8, the music_labels census join); anfmvrow on each FMV-trigger-id change (0x8007BA78, the field-VM op0x4C 0xE2target - live-confirms the disc-mined per-scene trigger assignmentman_field_scripts::scene_fmv_triggers);inputrows for pad press/release edges (0x8007B850) and apickrow sampling the dialogue picker cursor (*(0x801C6EA4)+0x0C) at a confirm press (branch/answer attribution). It tags a flag framenote=bulkloadwhen it flips>= LEGAIA_BULK_FLAGSflags (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 gates0x50A/0x5D6(a non-bulk organic SET of either brackets a code-path-writer hunt) and the statically-resolved0x370(writer pinned: thedomanvariant 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 writeDAT_8007B7FC?" question), a first-run0x400status set, or a first-per-slot arts-input press (an arts-command-input-open bracket on whatever card the volunteer plays) - capped atLEGAIA_SNAP_MAX, so every volunteer run grows the mid-beat state library for free (LEGAIA_AUTOSNAP=0to disable). Character-progression cells beyond level/spell are diffed too: per-roster-slot cumulativexp(record+0x0) andequipbytes (+0x196..0x19D, one row per changed equip slot), plus persistentcountercells on change (fishing points0x8008444C, casino coins0x800845A4, Point Card0x800845B4). While a battle scene is active a battle-detail stream diffs each battle-actor slot (pointer table0x801C9370, 0..2 party / 3..7 monsters): astatusrow on each+0x16Emechanical-status-word change (Venom / Toxic / Stone / Rot / AI-delegation / Curse timelines; a first-run raise of the0x400guard-disable bit auto-snapshots - the exact before/after bracket the open applier hunt needs), anhprow on each+0x14Ccurrent-HP change (a per-hit damage/heal/DoT timeline), and anaqrow 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 bytes0x0C..0x0Fand a commit rewrites the window into starter form (0x19art starter /0x1ASuper-Art special starter), so any Super Art the player performs hands back its committed queue bytes - the byte-exactreplace-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) awmcamrow captures the walk-view camera tuple - rotation trio0x8007B790/92/94, projection H0x8007B6F4, eye-space TR trio0x800840B8/BC/C0(low s16 halves), view-mode flag0x801F2B94- 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. Adtrow logs the scratchpad frame-step byte0x1F800393(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 luadofile()s the real probe on a field state, pokes every watched RAM cell, walks via pad override for organicposrows, then loads a battle state mid-run and pokes the battle-actor streams;check_state_poll_selftest.pyasserts 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 orlib/probe/*, before volunteer handoff. - Tier 2 -
autorun_flag_firehose.lua(slow, interpreter+debugger, ~10 fps): exec-breakpoints onFUN_8003CE08/_CE34capture the writerrafor 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, readerra) plusFUN_8003CE08/_CE34(SET/CLEAR, writerra) - 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_FLAGtakes 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 LoadImageFUN_800583C8/ MoveImageFUN_80058490, deduped per(ra, rect), auto-disarmed across the STR/FMV modes0x1A/0x1Bwhere a hot LoadImage bp segfaults the emulator); per-fightbattleidentity 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 A0x801CE818and slot B0x801F69D8, 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 clusters0= the opcode VA,s8= the dispatcher'spc_offset), so hits carry the exact script-buffer VA + byte offset of the bytecode op - runtime hits map directly toman-scriptsdisassembly lines. New-scene auto-snapshots bank a resume state at the mouth of every area reached, andmanifest.txtrecords 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.
| Script | What it answers |
|---|---|
autorun_world_map_probe.lua | Pins 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.lua | Exec-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.lua | Captures the per-Z fog-tint LUT the world-map overlay consults on every vertex (0x801F7644..0x801F8690). |
autorun_prim_pool_writers.lua | Confirms 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.lua | Pins which PROT entries get LZS-decoded for the world-map bundle (LZS entry + bundle dispatcher FUN_8001F05C). |
autorun_slot4_consumer_pcs.lua | Kingdom-agnostic slot-4 consumer PCs (cluster A + cluster B fire identically across all three kingdoms). |
autorun_slot4_dispatcher_args.lua | Captures the original cluster-A dispatcher call args (0x80043390) before the kind handlers clobber a1 / a2. |
autorun_dump_slot4.lua | Dumps the slot-4 RAM region; ground truth for verify_slot4_in_ram.py. |
autorun_state_poll.lua | Fast (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.lua | Regression 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.lua | Slow (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.lua | Slow (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.lua | Shows slot 4 is read in place by the world-map renderer - no transcode (see world-map-overlay). |
autorun_dump_full_ram_hold.lua | Holds 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.py | Byte-locates a kingdom's slot-4 resident base (Drake 0x8011A624 / Sebucus 0x80119CE4 / Karisto 0x80108D84). |
autorun_xp_table_reader.lua | Superseded: the real XP curve is DAT_80076AF4 (see the world-map + slot-4 disclosure); re-target before re-running. |
autorun_field_pack_projection.lua | Captures the scene-asset loader's on-disc → RAM projection a single save state can't observe. |
autorun_dump_full_ram.lua | One-shot full 2 MiB RAM dump. One dump per launch only - see the readAt(2 MiB) caveat above. |
autorun_boot_walk_snapshots.lua | Multi-snapshot RAM+register walk across LEGAIA_TARGETS vsyncs; the chunked-read workaround degrades past ~10 chunks - prefer chained single-shots. |
autorun_countdown_trigger.lua | Watchpoint-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.lua | Pinned the town/field free-movement integrator (FUN_801d01b0, collision FUN_801cfe4c; see field-locomotion). |
autorun_house_door_writer.lua | Cracked the intra-town house-door mechanism (field-VM 0x23 MOVE_TO) via probe.step.find_writer. |
autorun_man_source.lua | Pinned a field scene's runtime MAN source (_DAT_8007b898). |
autorun_title_overlay_writer_hunt.lua | Pins the SCUS-side title-overlay loader (write bps across the overlay window; run cold-boot with LEGAIA_NO_SSTATE=1). |
autorun_monster_record_source.lua | Pinned the monster stat archive to PROT entry 0867_battle_data. |
autorun_battle_reward_source.lua | Confirmed the victory reward path (monster record +0x44..+0x49). |
autorun_super_art_queue_builder.lua | ctx[+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.lua | Reads the real Super/Miracle queue (actor[+0x1DF..+0x1F2] via the 0x801C9370 table); validated Noa's Miracle queue byte-exact. |
autorun_super_art_input_replay.lua | Arts-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.lua | Pins the PROT source of the title overlay via per-decode LZS source dumps (run cold-boot). |
autorun_battle_palette_source.lua | Confirms 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.lua | Ground-truth framebuffer + RAM capture for the load-screen panel + slot-pill sprites (see save-screen - sprite asset sources). |
autorun_town01_script_flow.lua | Pins a field scene's script execution model - which VM contexts run; collision walls paint at load time only. |
autorun_battle_char_clut_source.lua | Pins the disc source of the battle-form party CLUT band (VRAM rows 490..497). |
autorun_battle_party_mesh_install.lua | Pins the battle-form party-mesh install callsite (static SCUS FUN_800513F0 / FUN_800542C8). |
autorun_battle_render_capture.lua | Live-confirms the exact battle camera byte-exact (mode=0x15, pitch 32, TR=(0,1280,7680), H=256). |
autorun_audio_trace.lua | Multi-frame retail SPU trace for the audio-trace parity oracle (LEGSPU01 stream). |
autorun_summon_model_base.lua | Targets 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.lua | Pinned move-VM op 0x40 as the animated-texture strip primitive (libgpu MoveImage wrapper FUN_80058490; see move-VM). |
autorun_debug_bit_poke.lua | Live-confirmed _DAT_8007B98F = 1 brings up the debug menu on SELECT+Δ in the NA retail build. |
autorun_inventory_fill.lua | RAM-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.lua | Confirmed the full-bag OOB id write via two live caller paths (casino exchange + equip-unequip). |
autorun_flag_bank_watcher.lua | Logs flag-bank SET/CLR/TST calls (FUN_8003CE08/CE34/CE64) with out-of-range bit indices. |
autorun_spine_flag_writers.lua | Arms 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.lua | Finds every native reader of the OOB-writable key-item id bytes (0x800859E8..+0x18). |
autorun_shiny_recon.lua | Shiny-Seru playtest recon: detour-site patch status + live shiny markers on a booted patched disc. |
autorun_battle_state_stream.lua | The 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.lua | Pins 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_dome | Per-minigame controller + static-table probes (scenarios minigame_*_pcsx); see the minigames disclosure. |
autorun_minigame_overlay_capture.lua | The mode-24 minigame overlay entry window; refuted the "PROT 0896 = mode-24 OTHER overlay" hypothesis. |
autorun_play_from_boot.lua | Boot-onward scripted driver for the trace-driven-coverage program (checkpoints reloadable .sstate anchors). |
autorun_trace_segment.lua | Gap-set exec-bp segment tracer: which not-yet-understood functions actually ran during one opening segment. |
trace_scenario.sh | Runs 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_capture | Pinned the town01 opening block to the name-entry screen, then completed it and captured the free-roam anchor. |
autorun_s4_gridrecon.lua / _s4_doornav | Grid-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_navsweep | Superseded 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_spar | Rim Elm's opening has no random encounters; the first battle is the scripted Tetsu spar (captured via input record/replay). |
autorun_dump_storyflags.lua | Dumps the field-VM story-flag bank (0x80085758, 0x400 bytes) for diffing scripted beats between states. |
autorun_record_inputs.lua / autorun_replay_inputs.lua | Manual input recorder (interactive) + deterministic headless replayer (drives pad.force, not RAM writes). |
autorun_btnmap.lua | Pins the 0x8007B850 button-mask layout: the byte-swapped PSX controller word (UP=0x1000, CROSS=0x0040, …). |
World map + slot 4
slot4_consumer_pcsis kingdom-agnostic: clusters A + B fire on Drake, Sebucus (town → map02) and Karisto (town → map03) with the same caller RAs (cluster B's RA0x80059C00byte-identical across all three; cluster A's RAs0x8001B47CinsideFUN_8001ada4+0x801F78D4world-map overlay). CSV isprobe_idx, cluster, pc, name, ra, a0..a3, s8;LEGAIA_PC_CAP=Nraises the 200-hit-per-PC cap.slot4_dispatcher_argscaptures caller RA, descriptor pointera0, packedcmd_flags(a1),fade_flags(a2) + the first command word'skind/count, classifying the four dispatcher banks (0x00/0x50/0xA0/0xF0).LEGAIA_DISP_CAP=Nraises the hit cap.slot4_source_maptiles Read bps across the slot-4 window + an Exec bp on theFUN_8001E54Cstreaming dispatcher and drives the warp itself: slot 4 is consumed in place, no transcode. Tile at the per-kingdom base - locate it first withautorun_dump_full_ram_hold.lua+locate_slot4_base.py.xp_table_reader: the real XP curve isDAT_80076AF4, read by the overlay applierFUN_801E9504; the old0x8007123Ctarget is an off-by-0x800artefact 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 inFUN_801d01b0(overlay 0897) at the foursh player[+0x14/0x18]stores, collision viaFUN_801cfe4c. Run against a save parked in a walkable town.house_door_writer:probe.step.find_writeroverplayer+0x10..+0x20while entering a house - the writer isFUN_801de840case 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 dispatcherFUN_8001F05Cfiltered to the MAN dispatch (a1 >> 24 == 3); caller isFUN_80020224, thescene_asset_tablewalker 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 atFUN_8001F7C0+ a one-shot bp at its return address, driving the warp viaLEGAIA_HOLD_BUTTON/LEGAIA_HOLD; diff withdiff_field_pack_projection.py. World-map scenes are not field-pack-formatted - they produce a 75 KB GP0-primitive pool projection at_DAT_8007B8D0 - 0x12800instead.
Battle
monster_record_source: per-id0x14000LZS slot at(id-1)*0x14000inside PROT0867_battle_data(extended footprint); decoded records match the live actors byte-for-byte; themonster_datalabel (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 inFUN_8004E568through the lone-enemyfloor((gold>>1)/2)formula, pinning the reward fields to record+0x44..+0x49(gold / EXP / drop id / drop %). The staged accumulator0x80084440is the minigame-winnings stage, andFUN_80026018is 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 generictmd_register(FUN_80026B4C) from two static SCUS state-handlers -FUN_800513F0(lead/active actors) andFUN_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; thetmd_register-entrya0is 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 withmap_clut_disc_reads.py.battle_render_capture: reads camera + actor state from inside thefunc_0x801d02c0grid-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 vialib/probe/battle_state.lua(actor table&DAT_801C9370, battle ctx_DAT_8007BD24, monster idsDAT_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: the0x800EBEE8writes 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..+0x1F2action-parameter stream.
Title / boot + ground-truth captures
countdown_trigger: width-2 Write BP atLEGAIA_WATCH_ADDR(default0x801EF16C, the title-attract countdown);LEGAIA_HIT_SKIPskips the boot-time DMA write;LEGAIA_DUMP_BASE/LEGAIA_DUMP_LENrestrict the dump window; optionalPCSX.GPU.takeScreenShot()decoded viadecode_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 withextract_vram_from_sstate.py+decode_vram.pyfor full VRAM (that pipeline pinned the panel CLUT to row 2 of the system-UI TIM atPROT.DAT[0x018E0]). No breakpoints, so it runs with--fast.audio_trace:PCSX.createSaveState()everyLEGAIA_INTERVALvsyncs, FFI-walked in place to slice only the SPU sub-message (~600 KiB vs 20 MiB per state); decode withextract_audio_trace_from_sstates.pyinto the JSONLAudioTraceFrameshapelegaia-engine audio-trace --retail-jsonlconsumes.createSaveStateis 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 = 1from a stable field save; the debug menu comes up on SELECT+Δ. The consumer is overlay-resident and outside the captured corpus - zero static references inSCUS_942.54.inventory_filltheninventory_oob_writer: fill the 72-slot consumable window, then armprobe.step.find_writeron the key-item window (0x800859E8..0x800859F8) and flag stores from0x800422BC(the add helper's unguarded id store). Confirmed via casino-exchange CROSS (id0x9C→0x800859E8) and equip-unequip via the START menu (id0xD0→0x800859EA); 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 SMFUN_801cf3bc+ tension tickFUN_801d4004(samples gaugeDAT_801d9168); write-watches the score_DAT_8008444c. - Dance (
autorun_minigame_dance.lua): beat-clock SMFUN_801cf470+ hit judgeFUN_801d1960(live groove gaugeDAT_801d544c); dumps step chartDAT_801d509c+ bonus table. - Slot machine (
autorun_minigame_slot_machine.lua): reel SMFUN_801cf0d8+ win evalFUN_801d13e8+ LCGFUN_801d30cc; write-watches coin bank_DAT_800845A4; dumps payout tableDAT_801d3598. - Baka Fighter (
autorun_minigame_baka.lua): round SMFUN_801d3468+ RPS resolverFUN_801d3a14; write-watches gold_DAT_80084440; dumps AI move-pattern tableDAT_801d76e8. - Muscle Dome (
autorun_minigame_muscle_dome.lua): match SMFUN_801d0748(phasectx+6) + card driverFUN_801d388c; reads the+0x1dfcard queue; dumps deck tablesDAT_801f4b8c/DAT_801f4b94. autorun_minigame_overlay_capture.lua: live-confirmed the0x3Eoperand−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.shunions windowed passes against one catalogued checkpoint intocaptures/trace/<label>/union.csv.- S3 (
autorun_s3_*.lua): pinned the town01-opening deadlock toSTATE_RESUME(op 0x49) in town01 P2[3]+0x02C6- the opening is the name-entry screen;autorun_s3_capture.luacompletes 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+0x16facing 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.lualogs the per-frame button mask0x8007B850;autorun_replay_inputs.luareplays it viapad.force/pad.release(NOT RAM writes -FUN_8001822Crebuilds the mask from the actual pad);autorun_btnmap.luapinned the mask as the byte-swapped PSX controller word.
Save-state to Python (offline analysis)
| Script | Input | Output |
|---|---|---|
dump_kingdom_ram_layout.py | .sstate files for the three kingdoms | Per-kingdom RAM-layout JSON used by the world-overview page. |
walk_actor_lists.py | .sstate for a world-map session | Walks 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 pack | Walks 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.py | autorun_dump_slot4.lua output | Confirms the live RAM region matches the disc-decoded slot-4 sub-bodies byte-for-byte. |
diff_slot4_ram_vs_disc.py | Live + disc slot-4 bytes | Generates the byte-level diff visualisation. |
match_prim_groups_to_disc.py | Live prim-pool dump + disc TMD pack | Matches 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 entry | Walks 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.py | load_screen_fb.raw + .meta from autorun_load_screen_dump.lua | PNG 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.py | The LEGSPU01-magic binary stream from autorun_audio_trace.lua | JSONL 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.py | A PCSX-Redux .sstate* file | 1 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.py | vram.bin from extract_vram_from_sstate.py | 1024×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.py | A 2 MiB main-RAM dump (e.g. load_screen_ram.bin) + optional --rect X0 Y0 X1 Y1 framebuffer rect | Lists 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.
| Subcommand | Use |
|---|---|
read-mem ADDR LEN [--out F] | Hex dump or raw bytes to file. ADDR is hex or a Ghidra symbol. |
read-regs | Dump 38 PSX MIPS GPRs + PC. |
write-mem ADDR HEXBYTES | Patch 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. |
selftest | Run protocol-codec + client self-tests against an in-process mock server (no live emulator needed). |
When to use this vs .probe.toml:
.probe.tomlfor repeatable captures that produce a CSV whichprobe.py regresscan gate on.gdb_probe.pyfor 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:
| Subcommand | Use |
|---|---|
probe.py summary RUN | Header + row count + canonical fingerprint. |
probe.py fingerprint RUN | SHA-256 over canonicalised rows. Independent of row order and of --ignored columns. |
probe.py diff BASELINE CURRENT | Set-diff: added / removed rows. Useful for inspecting why two runs differ. |
probe.py regress BASELINE CURRENT | Fingerprint 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:
- 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. - 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 fromprobe.run({...})- don't reimplement it. - 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 - 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.txtnext 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.