At a glance

Emulator
PCSX-Redux, interpreter + debugger (run_probe.sh default). Never --fast: Lua breakpoints do not fire under the recompiler
Probe
scripts/pcsx-redux/autorun_spine_flag_writers.lua (spine targets) or autorun_flag_firehose.lua (every flag write). Neither self-quits - wrap in timeout
Watched
Execution breakpoint on the flag setter, filtered by the flag index in its first argument; the hit's return address names the caller
Flag bank
SCUS-resident bitmap at 0x80085758; byte = base + (idx >> 3), mask = 0x80 >> (idx & 7)
Start point
Chapter-1 ladder card saves/library/cards/playthrough-ladder-pro00-14.mcr, slot loaded by name on the in-game load screen
Output
captures/spine_flag_writers/<run-ts>/ - CSV rows tick,label,addr,pc,ra,value; a caught write has a non-zero ra
Used by
script VM flag-op census, open RE threads

What this solves

A story flag is set by one line of one script somewhere on the disc, and a save-state diff only says the bit flipped - not who flipped it. The writes happen inside overlays (code paged into RAM on demand), so the static executable cannot name them either. The trick: break on the game's flag-setter function filtered by flag index. Every hit names the calling code directly, and that return address is attributed to an overlay and then to a script carrier.

Scene script op field VM 0x5x SET Flag setter FUN_8003CE08(a0=idx) Exec breakpoint a0 == 322 / 1154 CSV row tick,label,pc,ra,value Attribute ra attribute_overlay_hits.py Overlay, then MAN script bytes 51 42
The breakpoint sits on the setter, so the return address is the script dispatcher arm that requested the flag.

Quick start

Localise first, then hunt. The breakpoints force the interpreter (~10 fps), so a --fast run of autorun_state_poll.lua (the two-tier model) first logs which scene each flag changes in at full speed; then point the interpreter session there.

LEGAIA_NO_SSTATE=1 xvfb-run -a timeout --kill-after=15s 1800s bash scripts/pcsx-redux/run_probe.sh --lua scripts/pcsx-redux/autorun_spine_flag_writers.lua

Card-save play is the primary mode: configure the ladder .mcr as memory card 1, cold-boot, load the slot from the in-game load screen, play forward. Drop xvfb-run -a to see and drive the window; raise the budget for a long run. Holding a .sstate near a beat, seed from it instead:

LEGAIA_SSTATE=/path/to/pre-zeto.sstate xvfb-run -a timeout --kill-after=15s 900s bash scripts/pcsx-redux/run_probe.sh --lua scripts/pcsx-redux/autorun_spine_flag_writers.lua

The [spine] HIT ... lines in pcsx.log mirror each CSV row live.

What gets watched

TargetWhat it gatesWatchBank byte / maskStatus
flag 0x142 (322)rikuroa post-Caruban beat (probe label flag_0x142_dolk_clear)exec-bp, a0 == 3220x80085780 / 0x20caught, closed statically
flag 0x482 (1154)Drake mist wallsexec-bp, a0 == 11540x800857E8 / 0x20standing target
flag 0x225 (549)direct code pathexec-bp, a0 == 549-standing target
DAT_8007b7fc = 0x4BZeto battle-idwrite-watch, width 1 (LEGAIA_ZETO_WIDTH=4)-falsified - armed, never fires

The setter breakpoint isolates the exact flag and names the caller; a raw byte watch cannot, because eight flags share one bank byte. LEGAIA_FLAG_FALLBACK=1 additionally arms the byte watches in the table as a safety net.

Zeto fights in scene garmel, and its formation is installed by the encounter record path, not the battle-id global - which reads 0 everywhere and may be vestigial. Detail: encounter - battle-id path.

Which save for which beat

The ladder card holds 15 active slots (PRO-00..14). Load the slot nearest the beat, then play forward; the flags fall in story order so one armed session sweeps several.

BeatLoadWhyThen
keikoku (Kikoku Cliff)PRO-00everything unsetsets 0x193 - not watched, but the marker that you are on the spine
rikuroa - Caruban trigger and victoryPRO-00walk forwardthe post-victory record sets 0x142
Zeto (garmel)PRO-01 (fallback PRO-05)keikoku + dolk done, Zeto not beatencontinue to the Drake mist-wall event
Drake mist walls (0x482)none on this cardunset in all 15 slotskeep the watch armed and play past Zeto (chapter2_garmel_pre_zeto brackets it), or source a nearer card

The firehose variant

autorun_flag_firehose.lua logs every story-flag SET and CLEAR with the caller, every battle-id staging write, and every scene / mode transition as a timeline - one CSV (flag_firehose.csv: tick,kind,value,pc,ra,mode,scene,count). Per-key repeat suppression (first 8 of each (kind,flag,ra), then every 64th) keeps hot callers from flooding it. Prefer it for any play-forward longer than one beat: the same session banks the whole flag-provenance stream. Same launch shape, same post-pass.

Reading a hit

FileContents
spine_flag_writers.csvone row per hit: tick,label,addr,pc,ra,value; pc = the setter, value = flag index, ra = the game-logic caller
spine_flag_writers.detail.txtregisters + code + stack for the first N hits per label (LEGAIA_MAX_DETAIL, default 8)

Each beat boundary is also a chance to close a gap in the state library: save a PCSX-Redux state at pre-Zeto and pre/post-mist, then scripts/manage-states.py fingerprint and library so the bracket is catalogued. Load states by fingerprint, not slot.

What the captures settled

  • Flag 0x142. The SET fires at the rikuroa post-Caruban beat from the field-VM dispatcher's own SET arm; the source is script bytes in the scene's streaming-variant MAN (records P1[10..12] plus the self-latching post-victory P2[50]). Bracket catalogued as rikuroa_pre_caruban / rikuroa_post_caruban.
  • Provenance model. Across every chapter-1 scene traversed, story flags are written exclusively by the field VM's 0x5x/0x6x script ops. Every other setter caller is an engine system touching low indices - entity-SM staging, battle-end victory, interaction locks, spawn ops.
  • Standing targets: flags 0x225 and 0x482, both direct code paths.
History: readings this runbook once targeted
  • "DAT_8007b7fc = 0x4B is the Zeto battle-id write" - falsified live: a firehose from chapter2_garmel_pre_zeto with the watch at width 1 and 4 stayed silent across three Zeto fights.
  • "0x142 = dolk-dungeon clear" - a mislabel; the probe's row label keeps the old name.
  • "0x1BE is a Zeto gate" - closed statically as geremi's Jeremi-arrival one-shot.
  • "The census closed 0x482" - every census site for it is a desynced-walker alias of text bytes; the census flags decode coherence per site (GFlagSite::clean).

Project-wide list: do-not-re-walk.

How we know

ItemAddress / functionWhat it provesSource
Flag setter / clearerFUN_8003CE08 / FUN_8003CE34, SCUS-residentEvery story-flag write passes through one function; breaking on it names the callerghidra/scripts/funcs/8003CE08.txt
Flag bank0x80085758, bit 0x80 >> (idx & 7)Byte / mask for a fallback write-watchmemory map
0x142 writerra 0x801E3598 in the field-VM dispatcher; script bytes 51 42 in PROT 0157_rikuroaFlag set by script op, not engine codecapture rikuroa_post_caruban; script VM
Zeto formationFUN_801DA51C, actor[+0x94] recordThe battle-id global is not the Zeto pathencounter
Battle-end victory flagFUN_8004E568 sets index 0x35An engine-side setter caller outside the script opsfirehose capture
Slot bitmap in a save blockblock offset 0x14C0 (mirrors RAM 0x80085600)Which ladder slot has which flags setsave record

See also