Capturing the chapter-1 spine story-flag writers
Legaia records story progress as story flags - single bits in a RAM bitmap that scene scripts check to decide what happens next: whether a door opens, whether a boss is beaten, whether the mist walls around Drake Castle have fallen. The "spine" is the chain of mandatory chapter-1 beats, and this runbook answers which code sets each spine flag: one PCSX-Redux play-forward session with a human at the controls, a breakpoint on the game's own flag-setter, and a log of every caller. Kept as the worked example of a live writer hunt.
At a glance
- Emulator
- PCSX-Redux, interpreter + debugger (
run_probe.shdefault). Never--fast: Lua breakpoints do not fire under the recompiler - Probe
scripts/pcsx-redux/autorun_spine_flag_writers.lua(spine targets) orautorun_flag_firehose.lua(every flag write). Neither self-quits - wrap intimeout- 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 rowstick,label,addr,pc,ra,value; a caught write has a non-zerora- 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.
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.luaCard-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.luaThe [spine] HIT ... lines in pcsx.log mirror each CSV row live.
What gets watched
| Target | What it gates | Watch | Bank byte / mask | Status |
|---|---|---|---|---|
flag 0x142 (322) | rikuroa post-Caruban beat (probe label flag_0x142_dolk_clear) | exec-bp, a0 == 322 | 0x80085780 / 0x20 | caught, closed statically |
flag 0x482 (1154) | Drake mist walls | exec-bp, a0 == 1154 | 0x800857E8 / 0x20 | standing target |
flag 0x225 (549) | direct code path | exec-bp, a0 == 549 | - | standing target |
DAT_8007b7fc = 0x4B | Zeto battle-id | write-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.
| Beat | Load | Why | Then |
|---|---|---|---|
| keikoku (Kikoku Cliff) | PRO-00 | everything unset | sets 0x193 - not watched, but the marker that you are on the spine |
| rikuroa - Caruban trigger and victory | PRO-00 | walk forward | the post-victory record sets 0x142 |
| Zeto (garmel) | PRO-01 (fallback PRO-05) | keikoku + dolk done, Zeto not beaten | continue to the Drake mist-wall event |
Drake mist walls (0x482) | none on this card | unset in all 15 slots | keep 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
| File | Contents |
|---|---|
spine_flag_writers.csv | one row per hit: tick,label,addr,pc,ra,value; pc = the setter, value = flag index, ra = the game-logic caller |
spine_flag_writers.detail.txt | registers + 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 (recordsP1[10..12]plus the self-latching post-victoryP2[50]). Bracket catalogued asrikuroa_pre_caruban/rikuroa_post_caruban. - Provenance model. Across every chapter-1 scene traversed, story flags are written exclusively by the field VM's
0x5x/0x6xscript 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
0x225and0x482, both direct code paths.
History: readings this runbook once targeted
- "
DAT_8007b7fc = 0x4Bis the Zeto battle-id write" - falsified live: a firehose fromchapter2_garmel_pre_zetowith 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. - "
0x1BEis 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
| Item | Address / function | What it proves | Source |
|---|---|---|---|
| Flag setter / clearer | FUN_8003CE08 / FUN_8003CE34, SCUS-resident | Every story-flag write passes through one function; breaking on it names the caller | ghidra/scripts/funcs/8003CE08.txt |
| Flag bank | 0x80085758, bit 0x80 >> (idx & 7) | Byte / mask for a fallback write-watch | memory map |
0x142 writer | ra 0x801E3598 in the field-VM dispatcher; script bytes 51 42 in PROT 0157_rikuroa | Flag set by script op, not engine code | capture rikuroa_post_caruban; script VM |
| Zeto formation | FUN_801DA51C, actor[+0x94] record | The battle-id global is not the Zeto path | encounter |
| Battle-end victory flag | FUN_8004E568 sets index 0x35 | An engine-side setter caller outside the script ops | firehose capture |
| Slot bitmap in a save block | block offset 0x14C0 (mirrors RAM 0x80085600) | Which ladder slot has which flags set | save record |