At a glance

Input
Mednafen .mc{0..9} save states under ~/.mednafen/mcs/; PCSX-Redux .sstate where marked
Library + CLI
crates/mednafen (legaia_mednafen) and the mednafen-state binary; orchestrators in scripts/mednafen/
Manifest
scripts/scenarios.toml - names each state by label, never slot number; shared with the PCSX-Redux harness
Unit of analysis
A sister pair of states bracketing one gameplay event
Beyond RAM
The GPU section holds VRAM, main RAM holds the frame's display list, the SPU section feeds the audio oracle
Cannot do
Name a writer's return address - for that use the live PCSX-Redux probes
Used by
overlay capture, the engine's VRAM / mode-trace oracles, save record field pinning

What this solves

You have played to a moment - an item pickup, a level-up, the step before an encounter - and want to know which bytes of RAM that moment changed. Save a state before, a state after, diff them: every differing byte was written by code that ran in the gap. Cluster the changes into regions and you have a ranked list of structures to look up writers for in Ghidra.

State: before 2 MiB RAM State: after 2 MiB RAM mednafen-state diff --start --end --top Changed regions ranked by bytes Writer in Ghidra find_lui_writers.py
One sister pair, one event: the largest changed region is the structure to look up writers for.

Quick start

target/release/mednafen-state scenarios --manifest scripts/scenarios.toml
target/release/mednafen-state diff "$HOME/.mednafen/mcs/Legend of Legaia (USA).<HASH>.<BEFORE>" "$HOME/.mednafen/mcs/Legend of Legaia (USA).<HASH>.<AFTER>" --start 0x801C0000 --end 0x80200000 --top 8
[diff] window 0x801C0000..0x80200000  merge_gap=16  min_changed=4
[diff] 20 regions, 10029 bytes changed total
[diff] top 8 by bytes_changed:
       start           end     changed   left -> right (16 bytes)
    0x801F69D8  0x801F8F02   8631       90FFBD27... -> 147B1F80...
    0x801FFC28  0x801FFFBE    542       C42B0880... -> FFFFFFFF...
    ...

For a pair bracketing a field → battle transition, the 9 KB region is the overlay window the area-load wrote into; the small regions are scattered global-state updates. Then find the writer in Ghidra (Search → For Direct References, or find_lui_writers.py for LUI+ADDIU pairs - see Ghidra in Docker).

Capture conventions

  • One event per pair. A state that captures both an encounter trigger and a level-up forces the reader to disentangle two write streams.
  • Keep one state per game mode - town, field, battle-intro, battle-active each hold a distinct RAM layout.
  • Save before and after any one-shot event (item use, rank-up, level-up, scene transition).
  • Label, not slot. Live slots are overwritten every play; the manifest keys scenarios by label and fingerprint.

Command reference

TaskCommandNotes
See what a state containsmednafen-state info <state>Section table (MAIN, GPU, SPU, ...), resolved PC, main-RAM offset
Slice a RAM window to a filemednafen-state extract <state> --start --end --outWhat overlay capture imports into Ghidra; MIPS-shape sanity check
What changed between two statesmednafen-state diffRegions ranked by bytes changed; merge_gap / min_changed tune clustering
Diff every manifest pairscripts/mednafen/auto-capture.shRuns each scenario's watchpoints against its diff_against sisters; JSON per scenario
Which gap a value first appeared inwatchpoint-bisect.py --addr X s1 s2 s3...Reports BracketedAt, AlreadyBadFromStart or NeverBecameBad; --trace prints the value per state
Stage every scenario for Ghidrastate-walk.sh --importSlices each overlay window and imports it as a labelled program
Dump VRAM as PNGmednafen-state vram-dump --out --out-bin --regs1024×512 RGBA8 + GPU registers; pairs with legaia-engine info --runtime-vram for a per-pixel diff
Read the frame's display listmednafen-state display-list <state|ram.bin>Walks the libgpu ordering table in RAM; display-list.py wraps .sstate
Name every UI sprite in a framewidget-draw-sweep.py <state>Joins each SPRT packet's (u,v,w,h) to the SCUS widget-class table - exact, not heuristic
Find a state in the corpusstate-index.py [--scene X] [--json]Reads scene / mode / position anchors from the bytes; indexes both emulators into one table
Byte-match a battle file against VRAMmednafen-state clut-trace --pack ...Slides a 32-byte window past each TMD; see battle-data pack
Renderer dispatch tablesprim-dispatch-table, prim-dispatch-surveyDecodes the per-prim renderer tables; survey exits non-zero if the SCUS table drifts across saves
Pin a save-block fieldsave-tool sc-diff card1 card2 --save-index NDiffs SC blocks, annotated against the save-record layout
Reading a display list: three properties that decide whether a report means anything

libgpu builds each frame's primitive packets in a main-RAM work buffer and links them into an ordering table, so a RAM image is that frame's display list - "does retail draw this?" is a read, not an inference.

  • The pool is not the frame. The packet pool holds stale packets from earlier frames; only the chain reachable from an ordering table is live. A pool census over-counts, sometimes wildly.
  • Draw order is chain order. The PSX has no depth buffer; the ordering table is the depth policy and a later-linked packet overwrites an earlier one.
  • Retail double-buffers. Ordering tables come in pairs (frame N and N-1). The command walks one by default; --all-ots merges, --ot-addr selects. Merging makes every surface appear twice - the false positive --coincident must not report.

Tables are located by their ClearOTagR signature (a cleared table is a run of self-referential words). Detection is not exhaustive: a densely occupied table has few empty buckets to key on. Compare the walked count against the pool count the command prints.

Corpus indexing: three classifications easy to get wrong by hand
  • .mcr names two opposite things. ~/.mednafen/sav/*.mcr and saves/library/cards/*.mcr are 128 KiB memory-card images (no main RAM); saves/library/mednafen/*.mcr are save states because the backup helper keeps the source extension. Classify on content, never suffix.
  • Content sniffing needs a size floor - an emulator source tree carries "PCSX" in its license headers; every real state embeds 2 MiB of main RAM.
  • A snap_*_scene_<name> filename is the scene the probe tagged, not proof of the frame: a snapshot at the scene-change trigger reports field-init while its display list still holds the previous scene.
Renderer dispatch tables and SC-block pinning

prim-dispatch-table decodes the SCUS-resident per-prim renderer table at 0x8007657C (4 alpha rows × 20 slots) and the overlay-resident variant at 0x801F8968 (one row), surfacing the eight overlay-resident high-mode renderers at 0x801F7644..0x801F8690 the world-map top view routes its TMD prims through. --overlay-targets-only restricts the report. prim-dispatch-survey compares several saves: the SCUS table must be byte-identical (it lives in code), overlay status reads POP / stale / empty, and any target outside 0x801C0000..0x801F9000 flags (OTHER!). The same invariants are disc-gated tests in crates/mednafen/tests/dispatch_table.rs.

save-tool sc-diff takes two cards or raw 8192-byte SC blocks, --coalesce N (default 8) merges nearby differing bytes, and --range LO..HI defaults to 0x0000..0x05C8 - the global header before the per-character records. Cluster width inside the header band tells you a field's type: 4 bytes → a u32 story-flag word, 2-byte stride → an inventory array.

Workflow patterns

QuestionSteps
What writes to X between two points?Pick a bracketing pair → diff --start --end → largest region is the candidate → search stores to it in Ghidra → dump the writer
When did X become populated?Progressive states → watchpoint-bisect.py --addr X s0 s1 ... → tighten the BracketedAt gap with more states (an .mcm movie replays the same action)
Where is a cursor / camera global?Two saves differing only in cursor or camera state - the diff surfaces globals that show up nowhere in static analysis
Recording deterministic input movies

Mednafen .mcm movies record bit-exact controller input; replaying one reproduces emulator state at every frame. Shift+F5 starts and stops recording (file lands in ~/.mednafen/mcm/); replay with scripts/mednafen/run-mednafen.sh disc.bin --state <slot> --movie movie.mcm, then F5 at the frame you want to keep. The resulting slots are interchangeable with the manifest.

The scenarios manifest

scripts/scenarios.toml declares every scenario: its slot or library backup, overlay slice, watchpoint regions and diff_against sisters. mednafen-state watch <label> runs one scenario's watchpoints against each sister and writes a JSON report.

[[scenarios]]
slot = 1
label = "pre_encounter"
description = "Walking the field, one step before an encounter triggers"
diff_against = [2, 3]
expected_game_mode = 0x03            # asserted at load
expected_active_scene = "map01"
backup_fingerprint = "<sha256>"      # immutable saves/library copy, preferred over the live slot
ram_fingerprint_sha256 = "<sha256>"  # first 64 KiB of main RAM

[scenarios.overlay_slice]
start = 0x801C0000
end = 0x80200000

[[scenarios.watchpoints]]
label = "battle_overlay_window"
start = 0x801CE808
end = 0x801F3818
Drift guardWhat it isWho honours it
backup_fingerprintsha256 of an immutable copy in saves/library/<emulator>/ (gitignored); resolved in preference to the live slotmanage-states.py, ScenarioManifest::mednafen_save_path
ram_fingerprint_sha256sha256 of the first 64 KiB of main RAM, computed from the save itselfMode-trace oracle tests: a mismatching live slot is skipped, not failed

To add a scenario: capture a state (F5), add a [[scenarios]] block with label and description, optional watchpoints and diff_against, then mednafen-state watch <label>. Optional pins (phase, pcsx_redux_sstate, duckstation_sav) are listed in the manifest's own header comment.

How we know

ItemAddress / functionWhat it provesSource
Game anchorsscene name 0x8007050C, mode 0x8007B83C, player pointer 0x8007C364A state can be identified from its bytes alonelegaia_mednafen::game_anchors, memory map
Widget class table0x800732A4 in SCUS_942.54Both SCUS sprite draw routines copy (u,v,w,h) verbatim, so the sprite join is exactbattle - widget class table
Per-prim renderer dispatchFUN_80043390; tables 0x8007657C, 0x801F8968Which routine draws each primitive modecrates/mednafen/tests/dispatch_table.rs
Save-state layoutMednafen section table; &GPURAM[0][0] blobWhere main RAM and VRAM sit in the filecrates/mednafen README
RAM fingerprintlegaia_engine_shell::mode_trace_oracle::save_ram_fingerprintSlot drift is detectable without an emulator runcrates/engine-shell

See also