At a glance

Where
scripts/pcsx-redux/ - Lua autorun probes, the shared lib/probe/ library, declarative probes/*.probe.toml specs, Python analysers
Harness
run_probe.sh (every probe runs through it); run_probe.ps1 on Windows
Input
A PCSX-Redux .sstate named by scenario label in scripts/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 -debugger for 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.

WAIT_BOOT BIOS boots, ~60 vsyncs ARMED_LOADED load state, arm breakpoints capture N vsyncs hits append CSV rows request_quit or frames elapsed DONE disarm, quit
The state machine every autorun shares, provided by 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.lua
LEGAIA_NO_SSTATE=1 bash scripts/pcsx-redux/run_probe.sh --lua scripts/pcsx-redux/autorun_countdown_trigger.lua

The 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 / envMeaning
--lua / LEGAIA_LUAThe autorun to run (default: the world-map probe)
--scenario / --sstateA manifest label (library copy preferred over the live slot) or a state path; LEGAIA_NO_SSTATE=1 cold-boots
--specA declarative .probe.toml instead of a Lua file
--fast / --timingRecompiler / interpreter-without-debugger cores; breakpoints do not fire in either (see cores)
--out-dir / --out / --logRedirect every artifact of the run, pin one output file, or pin the emulator log
LEGAIA_FRAMESCapture budget in vsyncs
LEGAIA_MCD1 / LEGAIA_MCD2Memory cards (env-only: this build's -memcard2 flag is broken)
PCSX_REDUX, LEGAIA_BIOS, LEGAIA_ISOBinary, 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_pin
scripts/manage-states.py library --audit
  • Library copies are immutable - saves/library/<emulator>/<sha256>.<ext>, gitignored; the manifest carries only the backup_fingerprint.
  • A mednafen-only backup cannot run here. library --audit says which scenarios have a PCSX-Redux state; it is the usual answer to "but it is backed up".
  • Two .sstate shapes exist. Quicksaves are gzipped; states a probe writes are bare protobuf (~19 MB). legaia_pcsxr::SaveState opens both; a gzip-only reader silently drops most of the corpus.
  • Check identity before blaming the probe. autorun_identify_state.lua reports 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.

ModeFlags passedBreakpointsSpeedUse for
default-interpreter -debuggerfire~10 fpsEvery exec / read / write breakpoint probe
--fast-dynarec (forced - the persisted pcsx.json otherwise wins)silentfull speed, 3x with LEGAIA_SCALER=300Vsync-poll probes: RAM dumps, autorun_state_poll.lua, screenshots
--timing-interpreter -no-debuggersilentfast interpreterIs 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.

FamilyScriptWhat it answers
Navigation + identityautorun_identify_state.luaWhat a save state is: game mode, scene, which overlay ticks
autorun_pad_walk.luaDrives a scripted pad sequence and traces the mode transitions it reaches; the distinct-pad-word census proves presses arrived
autorun_confirm_dialog_dump.luaCaptures the save screen's confirm prompt at rest plus the panel drawer's live arguments
World map + slot 4autorun_world_map_probe.luaThe world-map sprite emitter's one-shot gate flag and its three-parameter block
autorun_ocean_moveimage.luaEvery VRAM transfer with tick + rects; confirms the ocean CLUT-walk cadence on all three kingdoms
autorun_world_map_fog_probe.luaDumps the per-depth fog-tint lookup table the overlay consults per vertex
autorun_prim_pool_writers.luaWhich renderers write the GPU primitive pool (the eight overlay-resident high-mode routines)
autorun_lzs_and_bundle_probe.luaWhich PROT entries are LZS-decoded during a world-map load
autorun_slot4_consumer_pcs.lua, _dispatcher_args, _source_mapWho 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.pyDump the slot-4 region or the post-warp RAM, then byte-locate the per-kingdom slot-4 base
autorun_xp_table_reader.luaSuperseded - the XP curve lives elsewhere; re-target before re-running
Field scenes, doors, locomotionautorun_player_pos_watch.luaWho moves the player: the free-movement integrator and its collision helper
autorun_house_door_writer.luaHow a house door works: a field-VM MOVE_TO, not a scene change
autorun_man_source.luaWhere a scene's runtime MAN (script-and-data bundle) is streamed from
autorun_town01_script_flow.luaWhich script contexts run in a parked scene; walls paint at load only
autorun_field_pack_projection.luaThe scene loader's disc-to-RAM projection, diffed slot by slot against the disc
Battleautorun_monster_record_source.luaThe monster stat archive's PROT entry and per-id slot size
autorun_battle_reward_source.luaThe victory reward path and the record fields it reads
autorun_super_art_action_queue.lua, _input_replay, _queue_builderThe 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.luaDisc source of the party CLUT band (VRAM rows 490..497) via the disc-read primitives
autorun_battle_party_mesh_install.luaThe call site that installs the party's battle meshes (two static SCUS handlers, dispatched indirectly)
autorun_battle_render_capture.luaThe exact battle camera and grid, read from inside the render breakpoint
autorun_battle_palette_source.luaShows the scene bundle decompresses into a shared arena; does not pin the party palette
autorun_summon_model_base.lua, autorun_battle_moveimage_trace.luaThe summon model-select base in the shared spawn stager; the animated-texture strip primitive (move-VM op 0x40)
autorun_battle_state_stream.luaBreakpoint-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.luaThe juggle-window byte and its writer; the indirect caller of the animation-node tick
autorun_tile_shatter_page.luaMid-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 familyCan a party actor run a capture-class boss cast, and where a forced cast hangs; live test of the cast-route hook
Story-flag provenanceautorun_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.luaTier 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.luaWriter of every flag SET / CLEAR; the three chapter-1 spine writes in one interactive session
autorun_dump_storyflags.luaDump the flag bank so two states can be diffed for a missed story beat
Title, boot, ground truthautorun_countdown_trigger.luaRAM + screenshot at the exact write of a watched cell; pinned the title-overlay tick
autorun_title_overlay_writer_hunt.lua, autorun_title_staging_capture.luaThe SCUS-side title-overlay loader and its PROT source (cold boot)
autorun_dump_full_ram.lua, autorun_boot_walk_snapshots.luaOne-shot 2 MiB dump; multi-point dumps across a boot (degrades past ~10 chunks)
autorun_load_screen_dump.luaFramebuffer + RAM at the load screen for sprite-source pinning
autorun_audio_trace.luaPer-vsync SPU state stream for the audio parity oracle
Patched-disc verificationautorun_battle_mesh_dump.luaCold boot, memory-card load, forced battle, RAM dump - the card tier
autorun_super_arts_pack_load.luaDoes 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_* familyCan the loader stage three distinct bosses (battle-heap budget); who rewrites the formation cells; the Delilas dome-course instrumentation
ACE hunt + reconautorun_debug_bit_poke.luaPoking 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_watcherFill the bag, catch the out-of-bounds item-id store and every native reader of the bytes it reaches
autorun_shiny_recon.luaDetour-site status and live shiny-Seru markers on a patched disc
Minigamesautorun_minigame_fishing.lua, _dance, _slot_machine, _baka, _muscle_domeEach minigame's controller state machine, scoring write and static tables (scenarios minigame_*_pcsx)
autorun_minigame_overlay_capture.lua, autorun_muscle_arena_shots.luaThe minigame overlay entry window (refutes "PROT 0896 is the mode-24 overlay"); arena screenshots
Trace-driven coverageautorun_play_from_boot.lua, autorun_trace_segment.lua, trace_scenario.shPlay an opening segment with a breakpoint on every not-yet-understood function; which ones ran
autorun_s3_recon.lua, _s3_pc, _s3_captureWhat a stalled scene is parked on (field-VM dispatcher histogram); completes name entry
autorun_s4_gridrecon.lua, _s4_doornavGrid-BFS door navigation over the walkability grid. The other _s4_* probes are superseded
autorun_s5_encounter.lua, _s5_actors, _s5_tetsu, _s5_sparRim 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.luaRecord 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
ProbeArmsAnswer / caveat
slot4_consumer_pcsExec bps at the cluster-A and cluster-B load PCsSame 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_argsExec bp at 0x80043390Original a0..a2 before the kind handlers clobber them; classifies the four dispatcher banks 0x00/0x50/0xA0/0xF0
slot4_source_mapRead bps tiled over the slot-4 window + exec bp on FUN_8001E54CSlot 4 is consumed in place. Tile at the per-kingdom base: Drake 0x8011A624, Sebucus 0x80119CE4, Karisto 0x80108D84
xp_table_readerRead bps over 0x8007123C..0x80071300Superseded: the XP curve is DAT_80076AF4, read by FUN_801E9504; the old target is a sine-table slice off by 0x800
field_pack_projectionExec bp at FUN_8001F7C0 + one-shot bp at its returnDiff with diff_field_pack_projection.py. World-map scenes are not field-pack formatted - they yield a GP0 primitive pool instead
player_pos_watchWrite watch on *(0x8007C364)+0x14/+0x18, armed after loadHits at the four stores in FUN_801d01b0 (0x801D0684/06E4/0744/07B4), collision via FUN_801cfe4c
house_door_writerprobe.step.find_writer over player+0x10..+0x20Writer 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_sourceExec bp at dispatcher FUN_8001F05C, MAN filter a1 >> 24 == 3Caller FUN_80020224 walks the scene asset table from _DAT_8007b85c; caught a count-6 table variant
town01_script_flowExec bps at FUN_8003aeb0, FUN_8003ab2c, FUN_801de840 + three collision-grid write sitesOne steady-state context (script 0xFB, pc loop 0x102..0x297); zero wall paints while standing
monster_record_sourceExec bps at FUN_80054CB0, FUN_800542C8, seek FUN_8003E964, read FUN_8003E800PROT 0867, one 0x14000 LZS slot per id at (id-1)*0x14000; decoded records match live actors byte-for-byte
battle_reward_sourceWrite bps on gold 0x8008459C, coins 0x800845A4, stage 0x80084440Gold write in FUN_8004E568; reward fields at record +0x44..+0x49. FUN_80026018 is the minigame exit, not a battle commit
battle_party_mesh_installWrite watch on DAT_8007C018[0..2], exec bp on tmd_register FUN_80026B4CCallers FUN_800513F0 (ra 0x8005148C) and FUN_800542C8 (ra 0x80054804). The watch's value column is pre-write; trust the entry a0
battle_char_clut_sourceExec bps on FUN_8003E8A8 / FUN_8003E964 / FUN_8003E800Run from a field state where the band is not yet resident; map LBAs with map_clut_disc_reads.py
battle_render_captureRead inside the func_0x801d02c0 grid-render bpmode 0x15, pitch 32, TR (0, 1280, 7680), H 256, 28x28 grid, actor scale +0x72 = 0x1000. Scratchpad needs read_scratch_u32
battle_palette_sourceWrite bps on 0x800EBEE8 / 0x800EC0C8 / 0x800EC2A8Writes 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_streamPer-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_triggerWidth-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_captureWrite bps across 0x801CC000..0x801EF018; exec bp at LZS FUN_8001A55CCold boot only - in-game saves are past the load. Each decode's source is dumped for offline byte-match
load_screen_dumpSettle, then screenshot + 2 MiB RAMNo 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_tracePCSX.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_capturePolls game mode; dumps the overlay window at trigger-relative vsyncsSCUS-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 probesFishing 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_801d388cEach pairs a state-machine exec bp with a write watch on the score / coin / gold cell and a one-shot static-table dump
ACE familyFill 72 consumable slots; find_writer on 0x800859E8..0x800859F8; read bps on the first 24 key-item bytesTwo 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 reconFUN_8001698C field tick; FUN_801DE840 dispatcher histogram; walkability grid at *(_DAT_1f8003ec)+0x4000Player 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.

TierProbeCoreCapturesAnalyse with
1autorun_state_poll.lua--fast, no breakpointsPer-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 eventsanalyze_state_poll.py
2autorun_flag_reader_watch.luainterpreterReader + 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 opanalyze_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=1 prints the fingerprint for relocking).
  • The flag window is exactly 0x200 bytes - 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.sh pokes every watched cell and asserts every stream fired. The volunteer runbook is scripts/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
AddressStreamMeaning
0x80085758flagStory-flag bank, 0x200 bytes; a frame flipping >= LEGAIA_BULK_FLAGS is tagged bulkload
0x8007050C / 0x8007B83Cscene / modeActive scene name and game mode
0x8007BD0C[4] / 0x8007B7FCbattleFormation ids sampled once the battle scene is active; staging byte (usually 0 here)
0x8008459C, 0x80085958, 0x80084594/98gold, item, partyGold, inventory page, party count and ids
player +0x14 / +0x18posTile crossings, tile = (pos - 0x40) >> 7; attributes each flag beat to a spot
0x8007BAC8, 0x8007BA78, 0x8007B850bgm, fmv, inputBGM id, FMV trigger id, pad press / release edges
*(0x801C6EA4)+0x0CpickDialogue picker cursor at a confirm press
record +0x0, +0x196..0x19Dxp, equipCumulative XP and equipment slots per roster member
0x8008444C, 0x800845A4, 0x800845B4counterFishing points, casino coins, Point Card
actor +0x16E, +0x14C, +0x1DF..+0x1F2status, hp, aqMechanical status word, current HP, party action-queue window (arts inputs, committed Super-Art queues)
0x8007B790/92/94, 0x8007B6F4, 0x800840B8..C0, 0x801F2B94wmcamOverworld walk-view camera tuple on entry and on change
0x1F800393dtScratchpad frame-step multiplier, logged when a value holds 30 frames
slots A 0x801CE818 / B 0x801F69D8overlay (Tier 2)512-byte FNV-1a checksum per overlay slot, resolved to labels by the committed overlay-map.txt
FUN_800583C8 / FUN_80058490vram / 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 .ppf next to the image it is handed. Point --iso at a scratch copy in a directory you control.
  • Cards are env-only (LEGAIA_MCD1/2); isolate_card_save.py copies one save onto an otherwise blank card.
  • Bridge SCUS mismatches with legaia-patcher scus-pokes output (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,16
Subcommands
SubcommandDoes
read-mem ADDR LEN / write-mem ADDR HEXRead or patch memory in flight; addresses accept Ghidra symbol names
read-regsDump the 38 MIPS registers + PC
when-pc-hits ADDR --read-mem A,LArm an exec breakpoint, continue, read on hit, disarm
watch ADDR LEN --kind read|write|accessInsert a watchpoint and print the stop reply
selftestProtocol 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.

1Pick the shapeFixed breakpoints and CSV columns: a .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
ModuleProvides
env, sstate, padgetenv helpers; save-state load; pad force / release (RAM writes to the pad word do not stick - the game rebuilds it each tick)
memTyped RAM readers and writers; ram_offset strips the KSEG selector so 0x80.. and 0xA0.. alias; read_scratch_u32 for the 0x1F80xxxx scratchpad
bp, csv, snapshotBreakpoint arming; CSV writer; live .hits.txt and call-context dumps (32 GPRs, code around PC, 32 stack words - the visible ra chain)
smThe lifecycle state machine behind probe.run
watchThe "what writes this address" closure: write bp + CSV of (elapsed, label, addr, pc, ra, prev_value) + first-N context
stepThe 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_stateGhidra-name resolver; USA disc fingerprint guard; typed battle-state extraction shared by the stream probe

Things that catch people out

TrapSymptomDo this
Breakpoint widthA width-2 watch at +0x14 misses a wider or offset store into the same struct; lbu misses a width-4 watchMatch the access width, or cover the range with step.find_writer
Listener garbage-collectedProbe goes silent mid-run with no error - the createEventListener proxy's finaliser deletes the C++ listenerAnchor every handle in the global PROBE_LISTENER_ANCHORS table
2 MiB read inside a vsync callbackLater 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 ceilingUnpatched PCSX-Redux leaks two Lua stack slots per event and dies at tick ~32716The local build carries a stack-rebalance patch; re-apply after any rebuild
Vsync is game-drivenA boot-phase "wait 600 vsyncs" sits for minutes - events fire on the game's VSync(0) calls, sparse during CD loadsTrigger on a write watchpoint at a known transition cell instead
Screenshot lags the drawtakeScreenShot() returns the displayed buffer, one game tick behind mid-animationCapture something static, or settle a dozen vsyncs and confirm it stopped moving
More traps: sign-extended registers, moving overlay entry points, stale globals
TrapSymptomDo this
Stale global read as a flagAn overlay timer holds garbage until its screen first runs, so "is this UI up?" polls compare against noiseExec-break on the code that draws the thing
Sign-extended registersgp prints as 0xFFFFFFFF8007B318; a ~= against 0x80000000 is true even when equalbit.band(v, 0xFFFFFFFF); use the library's in-RAM predicate, not a fresh one
Overlay entry points moveA hex breakpoint from an older dump arms at nothingSymbolic names; gp-relative targets computed after the load

How we know

ItemAddress / sourceWhat it proves
Only the interpreter fires Lua breakpointsPCSX-Redux psxinterpreter.cc:1652 (if constexpr (debug))-interpreter -debugger are both required; the recompiler has no hook
Listener finalisersrc/core/eventslua.ccAn unanchored listener dies at the next GC; the per-event stack leak sets the ~32.7k ceiling
Config location and -portablesrc/core/system.cc, src/core/arguments.ccWhy --fast must force -dynarec and how the isolated profile is mounted
Sibling PPF auto-applycdrom/ppf.ccA .ppf beside the image patches it silently
Flag helpersFUN_8003CE08 SET, FUN_8003CE34 CLEAR, FUN_8003CE64 TEST; bank 0x80085758Where both provenance tiers break; the six-word version fingerprint lives at these entries
Pad mask layout0x8007B850, rebuilt by FUN_8001822CByte-swapped controller word (UP 0x1000, CROSS 0x0040); replay must drive the pad, not RAM
Game anchorsscene 0x8007050C, mode 0x8007B83C, player pointer 0x8007C364, battle ctx 0x8007BD24, actor table 0x801C9370The cells every poll-tier probe keys on; see the memory map
Overlay slot basesA 0x801CE818, B 0x801F69D8 (crates/asset/data/static-overlays.toml)Residency checksums disambiguate the VA-aliased field / menu / battle overlays

See also