PCSX-Redux automation
Reading the game's code tells you what it could do; watching it run tells you what it does. PCSX-Redux is an open-source PlayStation emulator with a Lua scripting API and a breakpoint debugger over the live emulated CPU. A probe script boots Legaia, jumps to a saved moment, plants traps on the addresses in question and logs what the running game actually does - which routine writes a cell, which caller reaches an overlay function, what a patched disc really loads.
At a glance
- Where
scripts/pcsx-redux/- Lua autorun probes, the sharedlib/probe/library, declarativeprobes/*.probe.tomlspecs, Python analysers- Harness
run_probe.sh(every probe runs through it);run_probe.ps1on Windows- Input
- A PCSX-Redux
.sstatenamed by scenario label inscripts/scenarios.toml, or a cold boot; the disc image and BIOS stay local - Output
captures/<probe>/<timestamp>/: CSV rows, call-context dumps, RAM / screen snapshots. Gitignored - never Sony bytes in the repo- Needs
-interpreter -debuggerfor breakpoints; the recompiler (--fast) runs them past silently- Used by
- overlay capture, playthrough coverage, spine flag-writers capture, the engine's audio / VRAM parity oracles
- Sibling
- Mednafen automation answers "what changed" offline from save states; this page answers "who did it" live
What this solves
Most of Legaia's logic lives in overlays - code the game streams into RAM on demand - so a static search of the executable often finds no caller at all. A live probe sidesteps that: it lets the game run and records the program counter and return address at the moment of interest. Every probe is the same short lifecycle, factored into a shared library so a new one is a few dozen lines.
lib/probe/sm.lua through probe.run{...}.Quick start
bash scripts/pcsx-redux/run_probe.sh --scenario party_basic_attack_vs_gobu_gobu --lua scripts/pcsx-redux/autorun_battle_state_stream.luaLEGAIA_NO_SSTATE=1 bash scripts/pcsx-redux/run_probe.sh --lua scripts/pcsx-redux/autorun_countdown_trigger.luaThe first resolves a catalogued save state by label and runs a breakpoint probe on the interpreter; the second cold-boots for a title-screen question. Output lands in a fresh captures/<probe-stem>/<timestamp>/ directory and the wrapper echoes the probe's own hit summary when the emulator exits. Wrap long runs in timeout --kill-after=10s - PCSX.quit(0) does not always end the process, and the data is already on disk by then.
Wrapper flags and environment variables
| Wrapper flag / env | Meaning |
|---|---|
--lua / LEGAIA_LUA | The autorun to run (default: the world-map probe) |
--scenario / --sstate | A manifest label (library copy preferred over the live slot) or a state path; LEGAIA_NO_SSTATE=1 cold-boots |
--spec | A declarative .probe.toml instead of a Lua file |
--fast / --timing | Recompiler / interpreter-without-debugger cores; breakpoints do not fire in either (see cores) |
--out-dir / --out / --log | Redirect every artifact of the run, pin one output file, or pin the emulator log |
LEGAIA_FRAMES | Capture budget in vsyncs |
LEGAIA_MCD1 / LEGAIA_MCD2 | Memory cards (env-only: this build's -memcard2 flag is broken) |
PCSX_REDUX, LEGAIA_BIOS, LEGAIA_ISO | Binary, BIOS and disc paths when they differ from the defaults under ~/Tools/pcsx-redux/ |
Setup and save states
Quicksave slots (<TITLE_ID>.sstate<N>, F-keys in the emulator) are ephemeral: the next save in that slot erases the state a probe was written against. Back up anything worth keeping into the fingerprint-named library and reference it by label.
scripts/manage-states.py backup pcsx-redux ~/Tools/pcsx-redux/SCUS94254.sstate6 --label field_walled_collision_pinscripts/manage-states.py library --audit- Library copies are immutable -
saves/library/<emulator>/<sha256>.<ext>, gitignored; the manifest carries only thebackup_fingerprint. - A mednafen-only backup cannot run here.
library --auditsays which scenarios have a PCSX-Redux state; it is the usual answer to "but it is backed up". - Two
.sstateshapes exist. Quicksaves are gzipped; states a probe writes are bare protobuf (~19 MB).legaia_pcsxr::SaveStateopens both; a gzip-only reader silently drops most of the corpus. - Check identity before blaming the probe.
autorun_identify_state.luareports the mode, scene and ticking overlay of any state.
Which CPU core fires breakpoints
Lua breakpoints reach the CPU only through the interpreter's debug hook. The wrapper forces the core explicitly in every mode and exports LEGAIA_CORE; breakpoint probes refuse to arm on the wrong core, and the provenance probe adds a liveness canary for hand launches.
| Mode | Flags passed | Breakpoints | Speed | Use for |
|---|---|---|---|---|
| default | -interpreter -debugger | fire | ~10 fps | Every exec / read / write breakpoint probe |
--fast | -dynarec (forced - the persisted pcsx.json otherwise wins) | silent | full speed, 3x with LEGAIA_SCALER=300 | Vsync-poll probes: RAM dumps, autorun_state_poll.lua, screenshots |
--timing | -interpreter -no-debugger | silent | fast interpreter | Is a repro timing-sensitive across cores? |
--fast also isolates the config: a minimal profile (dynarec on, debugger off, ship-default renderer, absolute memory-card paths) is written to captures/.pcsx-profile and launched with -portable, so a volunteer's saved settings cannot ride in. --no-isolate-config opts out. Always confirm the emulator's top bar reads CPU: Dynarec.
Probe catalogue
Grouped by the kind of question a probe answers. Each Lua file's header comment carries its full invocation; a probe marked superseded is kept for provenance only. Longer write-ups are in the per-probe detail disclosure.
| Family | Script | What it answers |
|---|---|---|
| Navigation + identity | autorun_identify_state.lua | What a save state is: game mode, scene, which overlay ticks |
autorun_pad_walk.lua | Drives a scripted pad sequence and traces the mode transitions it reaches; the distinct-pad-word census proves presses arrived | |
autorun_confirm_dialog_dump.lua | Captures the save screen's confirm prompt at rest plus the panel drawer's live arguments | |
| World map + slot 4 | autorun_world_map_probe.lua | The world-map sprite emitter's one-shot gate flag and its three-parameter block |
autorun_ocean_moveimage.lua | Every VRAM transfer with tick + rects; confirms the ocean CLUT-walk cadence on all three kingdoms | |
autorun_world_map_fog_probe.lua | Dumps the per-depth fog-tint lookup table the overlay consults per vertex | |
autorun_prim_pool_writers.lua | Which renderers write the GPU primitive pool (the eight overlay-resident high-mode routines) | |
autorun_lzs_and_bundle_probe.lua | Which PROT entries are LZS-decoded during a world-map load | |
autorun_slot4_consumer_pcs.lua, _dispatcher_args, _source_map | Who reads the slot-4 mesh library: kingdom-agnostic consumer PCs, dispatcher call arguments, and proof it is read in place with no transcode | |
autorun_dump_slot4.lua, autorun_dump_full_ram_hold.lua, locate_slot4_base.py | Dump the slot-4 region or the post-warp RAM, then byte-locate the per-kingdom slot-4 base | |
autorun_xp_table_reader.lua | Superseded - the XP curve lives elsewhere; re-target before re-running | |
| Field scenes, doors, locomotion | autorun_player_pos_watch.lua | Who moves the player: the free-movement integrator and its collision helper |
autorun_house_door_writer.lua | How a house door works: a field-VM MOVE_TO, not a scene change | |
autorun_man_source.lua | Where a scene's runtime MAN (script-and-data bundle) is streamed from | |
autorun_town01_script_flow.lua | Which script contexts run in a parked scene; walls paint at load only | |
autorun_field_pack_projection.lua | The scene loader's disc-to-RAM projection, diffed slot by slot against the disc | |
| Battle | autorun_monster_record_source.lua | The monster stat archive's PROT entry and per-id slot size |
autorun_battle_reward_source.lua | The victory reward path and the record fields it reads | |
autorun_super_art_action_queue.lua, _input_replay, _queue_builder | The per-actor action-parameter queue Super / Miracle Arts expand into; injected directions append 1:1. _queue_builder is a turn-order diagnostic only | |
autorun_battle_char_clut_source.lua | Disc source of the party CLUT band (VRAM rows 490..497) via the disc-read primitives | |
autorun_battle_party_mesh_install.lua | The call site that installs the party's battle meshes (two static SCUS handlers, dispatched indirectly) | |
autorun_battle_render_capture.lua | The exact battle camera and grid, read from inside the render breakpoint | |
autorun_battle_palette_source.lua | Shows the scene bundle decompresses into a shared arena; does not pin the party palette | |
autorun_summon_model_base.lua, autorun_battle_moveimage_trace.lua | The summon model-select base in the shared spawn stager; the animated-texture strip primitive (move-VM op 0x40) | |
autorun_battle_state_stream.lua | Breakpoint-free typed battle state as a JSON delta stream - the event source for the VR / VRChat spectators | |
autorun_juggle_window.lua, autorun_anim_node_tick_caller.lua | The juggle-window byte and its writer; the indirect caller of the animation-node tick | |
autorun_tile_shatter_page.lua | Mid-transition capture of the battle-entry tile-shatter effect and its shade page | |
autorun_player_special_cast.lua + the _special_cast_* and _cast_hook_live family | Can a party actor run a capture-class boss cast, and where a forced cast hangs; live test of the cast-route hook | |
| Story-flag provenance | autorun_state_poll.lua (+ _selftest) | Tier 1: full-speed per-frame diff of every progression cell across a whole playthrough - see two tiers |
autorun_flag_reader_watch.lua | Tier 2: reader + writer return address for every flag touched, plus VRAM uploads, battle identity and overlay residency | |
autorun_flag_firehose.lua, autorun_spine_flag_writers.lua | Writer of every flag SET / CLEAR; the three chapter-1 spine writes in one interactive session | |
autorun_dump_storyflags.lua | Dump the flag bank so two states can be diffed for a missed story beat | |
| Title, boot, ground truth | autorun_countdown_trigger.lua | RAM + screenshot at the exact write of a watched cell; pinned the title-overlay tick |
autorun_title_overlay_writer_hunt.lua, autorun_title_staging_capture.lua | The SCUS-side title-overlay loader and its PROT source (cold boot) | |
autorun_dump_full_ram.lua, autorun_boot_walk_snapshots.lua | One-shot 2 MiB dump; multi-point dumps across a boot (degrades past ~10 chunks) | |
autorun_load_screen_dump.lua | Framebuffer + RAM at the load screen for sprite-source pinning | |
autorun_audio_trace.lua | Per-vsync SPU state stream for the audio parity oracle | |
| Patched-disc verification | autorun_battle_mesh_dump.lua | Cold boot, memory-card load, forced battle, RAM dump - the card tier |
autorun_super_arts_pack_load.lua | Does the Super Arts Pack stub stream its block at battle start on the patched disc | |
autorun_delilas_battle_load.lua, _formation_cell_writers, _natural_encounter_cells, the _delilas_* family | Can the loader stage three distinct bosses (battle-heap budget); who rewrites the formation cells; the Delilas dome-course instrumentation | |
| ACE hunt + recon | autorun_debug_bit_poke.lua | Poking the debug-menu byte brings up the dev menu on SELECT + Triangle |
autorun_inventory_fill.lua, _inventory_oob_writer, _key_item_consumer_hunt, _flag_bank_watcher | Fill the bag, catch the out-of-bounds item-id store and every native reader of the bytes it reaches | |
autorun_shiny_recon.lua | Detour-site status and live shiny-Seru markers on a patched disc | |
| Minigames | autorun_minigame_fishing.lua, _dance, _slot_machine, _baka, _muscle_dome | Each minigame's controller state machine, scoring write and static tables (scenarios minigame_*_pcsx) |
autorun_minigame_overlay_capture.lua, autorun_muscle_arena_shots.lua | The minigame overlay entry window (refutes "PROT 0896 is the mode-24 overlay"); arena screenshots | |
| Trace-driven coverage | autorun_play_from_boot.lua, autorun_trace_segment.lua, trace_scenario.sh | Play an opening segment with a breakpoint on every not-yet-understood function; which ones ran |
autorun_s3_recon.lua, _s3_pc, _s3_capture | What a stalled scene is parked on (field-VM dispatcher histogram); completes name entry | |
autorun_s4_gridrecon.lua, _s4_doornav | Grid-BFS door navigation over the walkability grid. The other _s4_* probes are superseded | |
autorun_s5_encounter.lua, _s5_actors, _s5_tetsu, _s5_spar | Rim Elm's opening has no random encounters; the first fight is the scripted Tetsu spar | |
autorun_record_inputs.lua, autorun_replay_inputs.lua, autorun_btnmap.lua | Record human input, replay it headlessly through the pad (not RAM writes); the pad-mask layout |
Per-probe detail: what the longer probes arm, what they answered, and the caveat that bit
| Probe | Arms | Answer / caveat |
|---|---|---|
slot4_consumer_pcs | Exec bps at the cluster-A and cluster-B load PCs | Same SCUS PCs and caller RAs on all three kingdoms (cluster B RA 0x80059C00; cluster A RAs 0x8001B47C in FUN_8001ada4 + 0x801F78D4). LEGAIA_PC_CAP=N lifts the 200-hit cap |
slot4_dispatcher_args | Exec bp at 0x80043390 | Original a0..a2 before the kind handlers clobber them; classifies the four dispatcher banks 0x00/0x50/0xA0/0xF0 |
slot4_source_map | Read bps tiled over the slot-4 window + exec bp on FUN_8001E54C | Slot 4 is consumed in place. Tile at the per-kingdom base: Drake 0x8011A624, Sebucus 0x80119CE4, Karisto 0x80108D84 |
xp_table_reader | Read bps over 0x8007123C..0x80071300 | Superseded: the XP curve is DAT_80076AF4, read by FUN_801E9504; the old target is a sine-table slice off by 0x800 |
field_pack_projection | Exec bp at FUN_8001F7C0 + one-shot bp at its return | Diff with diff_field_pack_projection.py. World-map scenes are not field-pack formatted - they yield a GP0 primitive pool instead |
player_pos_watch | Write watch on *(0x8007C364)+0x14/+0x18, armed after load | Hits at the four stores in FUN_801d01b0 (0x801D0684/06E4/0744/07B4), collision via FUN_801cfe4c |
house_door_writer | probe.step.find_writer over player+0x10..+0x20 | Writer is FUN_801de840 case 0x23 at 0x801debc4. A width-2 watch saw only a no-op re-store; the range watch found it in one run |
man_source | Exec bp at dispatcher FUN_8001F05C, MAN filter a1 >> 24 == 3 | Caller FUN_80020224 walks the scene asset table from _DAT_8007b85c; caught a count-6 table variant |
town01_script_flow | Exec bps at FUN_8003aeb0, FUN_8003ab2c, FUN_801de840 + three collision-grid write sites | One steady-state context (script 0xFB, pc loop 0x102..0x297); zero wall paints while standing |
monster_record_source | Exec bps at FUN_80054CB0, FUN_800542C8, seek FUN_8003E964, read FUN_8003E800 | PROT 0867, one 0x14000 LZS slot per id at (id-1)*0x14000; decoded records match live actors byte-for-byte |
battle_reward_source | Write bps on gold 0x8008459C, coins 0x800845A4, stage 0x80084440 | Gold write in FUN_8004E568; reward fields at record +0x44..+0x49. FUN_80026018 is the minigame exit, not a battle commit |
battle_party_mesh_install | Write watch on DAT_8007C018[0..2], exec bp on tmd_register FUN_80026B4C | Callers FUN_800513F0 (ra 0x8005148C) and FUN_800542C8 (ra 0x80054804). The watch's value column is pre-write; trust the entry a0 |
battle_char_clut_source | Exec bps on FUN_8003E8A8 / FUN_8003E964 / FUN_8003E800 | Run from a field state where the band is not yet resident; map LBAs with map_clut_disc_reads.py |
battle_render_capture | Read inside the func_0x801d02c0 grid-render bp | mode 0x15, pitch 32, TR (0, 1280, 7680), H 256, 28x28 grid, actor scale +0x72 = 0x1000. Scratchpad needs read_scratch_u32 |
battle_palette_source | Write bps on 0x800EBEE8 / 0x800EC0C8 / 0x800EC2A8 | Writes come from LZS-decoding the scene bundle into a shared arena - scene data, not the palette (which is character-intrinsic, not a disc blob) |
battle_state_stream | Per-vsync poll via lib/probe/battle_state.lua (table &DAT_801C9370, ctx _DAT_8007BD24, ids DAT_8007BD0C) | Delta records + full sweep every LEGAIA_STREAM_SWEEP vsyncs and on battle entry. Resumed battle saves often segfault - the production path is a live session |
countdown_trigger | Width-2 write bp at LEGAIA_WATCH_ADDR (default 0x801EF16C) | LEGAIA_HIT_SKIP skips the boot DMA write; decode screens with decode_pcsx_screen.py |
title_overlay_writer_hunt, title_staging_capture | Write bps across 0x801CC000..0x801EF018; exec bp at LZS FUN_8001A55C | Cold boot only - in-game saves are past the load. Each decode's source is dumped for offline byte-match |
load_screen_dump | Settle, then screenshot + 2 MiB RAM | No breakpoints, so --fast. Pair with extract_vram_from_sstate.py for full VRAM (panel CLUT = row 2 of the system-UI TIM at PROT.DAT[0x018E0]) |
audio_trace | PCSX.createSaveState() every LEGAIA_INTERVAL vsyncs, FFI-walked to slice the SPU message | ~600 KiB per capture instead of 20 MiB; decode with extract_audio_trace_from_sstates.py |
minigame_overlay_capture | Polls game mode; dumps the overlay window at trigger-relative vsyncs | SCUS-resident init streams the minigame overlay into slot A; PROT 0896's bytes appear nowhere. Keep dump offsets early - the emulator never exits on its own |
| Minigame probes | Fishing FUN_801cf3bc + FUN_801d4004, score _DAT_8008444c; dance FUN_801cf470 + FUN_801d1960; slots FUN_801cf0d8 + FUN_801d13e8 + LCG FUN_801d30cc; Baka FUN_801d3468 + FUN_801d3a14; dome FUN_801d0748 + FUN_801d388c | Each pairs a state-machine exec bp with a write watch on the score / coin / gold cell and a one-shot static-table dump |
| ACE family | Fill 72 consumable slots; find_writer on 0x800859E8..0x800859F8; read bps on the first 24 key-item bytes | Two live OOB writers from 0x800422BC: casino exchange (id 0x9C) and equip-unequip (id 0xD0). Flag helpers FUN_8003CE08/CE34/CE64 are watched for indices >= 5248 |
| S3 - S5 recon | FUN_8001698C field tick; FUN_801DE840 dispatcher histogram; walkability grid at *(_DAT_1f8003ec)+0x4000 | Player X/Z are signed 16-bit at +0x14/+0x18 with facing at +0x16 - reading them as u32 produced the retracted "dynamic camera remap" conclusion. Tetsu's spar is item 3 of a 4-row list a CROSS mash never reaches |
Whole-playthrough capture: two tiers
"Which story flag, item or party change happens where" over a long play session needs a human at the pad. Two probes split the cost: a full-speed poll that says what changed and where, then a slow breakpoint run aimed only at the flags the poll fingered.
| Tier | Probe | Core | Captures | Analyse with |
|---|---|---|---|---|
| 1 | autorun_state_poll.lua | --fast, no breakpoints | Per-frame diff of flags, gold, items, party, XP, equipment, counters, scene, mode, BGM, FMV, player tile, pad edges, picker choices; in-battle status / HP / arts-queue rows; auto-snapshots on rare events | analyze_state_poll.py |
| 2 | autorun_flag_reader_watch.lua | interpreter | Reader + writer return address per flag, deduped; write-watch allowlist; VRAM upload log; per-fight identity with spawn tile; overlay-residency checksums; field-VM script offset of the flag op | analyze_reader_watch.py (merges runs into one provenance map) |
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- Version guard. Both tiers fingerprint six code words at each flag helper and refuse a non-USA or wrong-revision disc (
LEGAIA_FP_RECORD=1prints the fingerprint for relocking). - The flag window is exactly
0x200bytes - the largest span that is pure story flags, with the character records ending at its base and the inventory starting at its end. - Self-test before any volunteer handoff:
run_state_poll_selftest.shpokes every watched cell and asserts every stream fired. The volunteer runbook isscripts/pcsx-redux/COMMUNITY-CAPTURE.md. - The poll cannot see the battle-id staging byte - it is written and consumed within one frame; Tier 2 catches its writer.
Details: the cells each tier watches, and the analyser sections
| Address | Stream | Meaning |
|---|---|---|
0x80085758 | flag | Story-flag bank, 0x200 bytes; a frame flipping >= LEGAIA_BULK_FLAGS is tagged bulkload |
0x8007050C / 0x8007B83C | scene / mode | Active scene name and game mode |
0x8007BD0C[4] / 0x8007B7FC | battle | Formation ids sampled once the battle scene is active; staging byte (usually 0 here) |
0x8008459C, 0x80085958, 0x80084594/98 | gold, item, party | Gold, inventory page, party count and ids |
player +0x14 / +0x18 | pos | Tile crossings, tile = (pos - 0x40) >> 7; attributes each flag beat to a spot |
0x8007BAC8, 0x8007BA78, 0x8007B850 | bgm, fmv, input | BGM id, FMV trigger id, pad press / release edges |
*(0x801C6EA4)+0x0C | pick | Dialogue picker cursor at a confirm press |
record +0x0, +0x196..0x19D | xp, equip | Cumulative XP and equipment slots per roster member |
0x8008444C, 0x800845A4, 0x800845B4 | counter | Fishing points, casino coins, Point Card |
actor +0x16E, +0x14C, +0x1DF..+0x1F2 | status, hp, aq | Mechanical status word, current HP, party action-queue window (arts inputs, committed Super-Art queues) |
0x8007B790/92/94, 0x8007B6F4, 0x800840B8..C0, 0x801F2B94 | wmcam | Overworld walk-view camera tuple on entry and on change |
0x1F800393 | dt | Scratchpad frame-step multiplier, logged when a value holds 30 frames |
slots A 0x801CE818 / B 0x801F69D8 | overlay (Tier 2) | 512-byte FNV-1a checksum per overlay slot, resolved to labels by the committed overlay-map.txt |
FUN_800583C8 / FUN_80058490 | vram / vrammove (Tier 2) | LoadImage / MoveImage rect + uploader ra; auto-disarmed during FMV modes 0x1A/0x1B (a hot bp there segfaults) |
Toggles: LEGAIA_TRACE_POS/_BGM/_INPUT/_BATTLE/_OVERLAY/_VRAM=0, LEGAIA_AUTOSNAP=0, LEGAIA_SNAP_FLAGS, LEGAIA_SNAP_MAX, LEGAIA_WATCH_WRITES="0xADDR:width[:name],...", LEGAIA_FLAG (comma list of target flags for read-watches and first-hit snapshots). LEGAIA_POINT_CARD_MAX=1 pins the Point Card counter so a strike one-shots any boss on a maintainer's own pass.
analyze_state_poll.py renders a scene timeline, battle windows with formation ids (lone-enemy fights starred as likely bosses), a story-flag census that separates beats from bulk load frames, XP / equipment / counter changes, BGM and FMV timelines, arts commits and unlabelled sticky beats as [lead]s. analyze_reader_watch.py builds per-flag site tables labelled against the catalogued helpers (0x801E35E8 field-VM TEST return, 0x801D218C walk-on trigger dispatch, FUN_8003BDE0 gate reads), prints uncatalogued sites as [NEW] with overlay residency, and merges runs with --merged-out. Both are pure functions with synthetic-row tests.
Patched discs: the memory-card tier
A save state replays the RAM of the disc that produced it, so anything the loader builds at load time - battle meshes, staged records, rebased modules - comes from the old disc until the game reloads it. To observe what a patched disc really loads, cold-boot it, load a save from a memory card, force the battle and dump RAM: capture_battle_mesh.sh chains this and decode_battle_mesh.py decodes the result.
- PCSX-Redux auto-applies a sibling
.ppfnext to the image it is handed. Point--isoat a scratch copy in a directory you control. - Cards are env-only (
LEGAIA_MCD1/2);isolate_card_save.pycopies one save onto an otherwise blank card. - Bridge SCUS mismatches with
legaia-patcher scus-pokesoutput (LEGAIA_POKES) when a retail state must resume on a patched disc.
One-shot queries: the GDB bridge
PCSX-Redux also exposes a GDB remote stub on TCP 3333. gdb_probe.py speaks it directly for ad-hoc reads, register dumps and single "break here, read there" questions - no scenario, no state machine. Use a .probe.toml instead when the capture must be repeatable and gated.
scripts/pcsx-redux/gdb_probe.py when-pc-hits FUN_801DD35C --read-mem _DAT_801EF16C,16Subcommands
| Subcommand | Does |
|---|---|
read-mem ADDR LEN / write-mem ADDR HEX | Read or patch memory in flight; addresses accept Ghidra symbol names |
read-regs | Dump the 38 MIPS registers + PC |
when-pc-hits ADDR --read-mem A,L | Arm an exec breakpoint, continue, read on hit, disarm |
watch ADDR LEN --kind read|write|access | Insert a watchpoint and print the stop reply |
selftest | Protocol codec tests against an in-process mock server |
Authoring a new probe
Two shapes, in order of preference. A declarative spec covers "arm N breakpoints, log K columns" and "settle then dump"; a Lua autorun covers per-hit logic that depends on register state or dynamic arming.
.probe.toml under probes/. Anything conditional: copy autorun_slot4_consumer_pcs.lua, the canonical ~145-line thin probe.probes/ · lib/probe/spec
2Name addresses symbolicallyWrite FUN_801DA51C / _DAT_801EF16C, not hex; the resolver fails loudly on a typo, where a hex slip arms nil and logs zero hits. Regenerate with build-symbols.py after new dumps.lib/probe/symbols
3Arm in on_armRead gp from the live registers after the load, compute relative targets, arm with the width the access uses (1/2/4). Return the descriptor list.lib/probe/bp
4Log in the callbackA write breakpoint fires at the store with live registers - read PCSX.getRegisters() directly. Write rows through csv:row; stack first-hit context with append_call_context.lib/probe/csv · snapshot · watch
5Quit earlySet ctx.request_quit = true in on_capture once the stop condition is met instead of burning the frame budget.lib/probe/sm
6Run and iteraterun_probe.sh --lua ...; the .hits.txt snapshot rewrites every 60 vsyncs, so tail it from another shell. Commit the Lua file and add a catalogue row; outputs stay gitignored.run_probe.sh
Details: the declarative .probe.toml schema
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
path = "my_probe.detail.txt"
[[breakpoint]]
addr = "FUN_801DD35C" # int or symbol name
kind = "Exec" # "Exec" | "Read" | "Write"
width = 4
name = "title_tick"
[[breakpoint_range]] # fan out N adjacent breakpoints
base = 0x80076AF4
length = 196
stride = 4
kind = "Read"
name_fmt = "xp+0x%03X"
Column vocabulary: tick, addr, offset, pc, ra, sp, width, value_u8/u16/u32. probes/_check_specs.py validates every spec's schema and symbol references without launching the emulator; with lua5.1 present it also cross-checks the Lua TOML reader against Python's. probe.py summary | fingerprint | diff | regress operate on the CSVs (--ignore tick drops order-dependent columns) so a spec can gate against a committed baseline.
Details: the shared library modules and the two single-step substitutes
| Module | Provides |
|---|---|
env, sstate, pad | getenv helpers; save-state load; pad force / release (RAM writes to the pad word do not stick - the game rebuilds it each tick) |
mem | Typed RAM readers and writers; ram_offset strips the KSEG selector so 0x80.. and 0xA0.. alias; read_scratch_u32 for the 0x1F80xxxx scratchpad |
bp, csv, snapshot | Breakpoint arming; CSV writer; live .hits.txt and call-context dumps (32 GPRs, code around PC, 32 stack words - the visible ra chain) |
sm | The lifecycle state machine behind probe.run |
watch | The "what writes this address" closure: write bp + CSV of (elapsed, label, addr, pc, ra, prev_value) + first-N context |
step | The Lua API binds no single-step. step.trace(lo, hi) arms an exec bp on every instruction of a region (execution-ordered, live registers); step.find_writer(addr, len) tiles unit-width write bps so a store of unknown width or alignment is caught |
symbols, version, battle_state | Ghidra-name resolver; USA disc fingerprint guard; typed battle-state extraction shared by the stream probe |
Things that catch people out
| Trap | Symptom | Do this |
|---|---|---|
| Breakpoint width | A width-2 watch at +0x14 misses a wider or offset store into the same struct; lbu misses a width-4 watch | Match the access width, or cover the range with step.find_writer |
| Listener garbage-collected | Probe goes silent mid-run with no error - the createEventListener proxy's finaliser deletes the C++ listener | Anchor every handle in the global PROBE_LISTENER_ANCHORS table |
| 2 MiB read inside a vsync callback | Later vsync callbacks fire rarely or never (the garbage burst triggers the collect above) | One full dump per launch, or 64 KiB chunks |
| ~32.7k vsync ceiling | Unpatched PCSX-Redux leaks two Lua stack slots per event and dies at tick ~32716 | The local build carries a stack-rebalance patch; re-apply after any rebuild |
| Vsync is game-driven | A boot-phase "wait 600 vsyncs" sits for minutes - events fire on the game's VSync(0) calls, sparse during CD loads | Trigger on a write watchpoint at a known transition cell instead |
| Screenshot lags the draw | takeScreenShot() returns the displayed buffer, one game tick behind mid-animation | Capture something static, or settle a dozen vsyncs and confirm it stopped moving |
More traps: sign-extended registers, moving overlay entry points, stale globals
| Trap | Symptom | Do this |
|---|---|---|
| Stale global read as a flag | An overlay timer holds garbage until its screen first runs, so "is this UI up?" polls compare against noise | Exec-break on the code that draws the thing |
| Sign-extended registers | gp prints as 0xFFFFFFFF8007B318; a ~= against 0x80000000 is true even when equal | bit.band(v, 0xFFFFFFFF); use the library's in-RAM predicate, not a fresh one |
| Overlay entry points move | A hex breakpoint from an older dump arms at nothing | Symbolic names; gp-relative targets computed after the load |
How we know
| Item | Address / source | What it proves |
|---|---|---|
| Only the interpreter fires Lua breakpoints | PCSX-Redux psxinterpreter.cc:1652 (if constexpr (debug)) | -interpreter -debugger are both required; the recompiler has no hook |
| Listener finaliser | src/core/eventslua.cc | An unanchored listener dies at the next GC; the per-event stack leak sets the ~32.7k ceiling |
Config location and -portable | src/core/system.cc, src/core/arguments.cc | Why --fast must force -dynarec and how the isolated profile is mounted |
| Sibling PPF auto-apply | cdrom/ppf.cc | A .ppf beside the image patches it silently |
| Flag helpers | FUN_8003CE08 SET, FUN_8003CE34 CLEAR, FUN_8003CE64 TEST; bank 0x80085758 | Where both provenance tiers break; the six-word version fingerprint lives at these entries |
| Pad mask layout | 0x8007B850, rebuilt by FUN_8001822C | Byte-swapped controller word (UP 0x1000, CROSS 0x0040); replay must drive the pad, not RAM |
| Game anchors | scene 0x8007050C, mode 0x8007B83C, player pointer 0x8007C364, battle ctx 0x8007BD24, actor table 0x801C9370 | The cells every poll-tier probe keys on; see the memory map |
| Overlay slot bases | A 0x801CE818, B 0x801F69D8 (crates/asset/data/static-overlays.toml) | Residency checksums disambiguate the VA-aliased field / menu / battle overlays |