Mednafen automation
A recurring reverse-engineering question is "what code writes to this memory address?" - normally answered with a debugger watchpoint. Mednafen (a PlayStation emulator) has such a debugger, but it is keyboard-only and cannot be scripted. This toolkit gets the same answers offline: each mednafen save state is a frozen snapshot of the console's 2 MB RAM, so diffing two states lists every byte the game wrote between them - and the same states double as rendering and audio ground truth for the engine port.
At a glance
- Input
- Mednafen
.mc{0..9}save states under~/.mednafen/mcs/; PCSX-Redux.sstatewhere marked - Library + CLI
crates/mednafen(legaia_mednafen) and themednafen-statebinary; orchestrators inscripts/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
GPUsection holds VRAM, main RAM holds the frame's display list, theSPUsection 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.
Quick start
target/release/mednafen-state scenarios --manifest scripts/scenarios.tomltarget/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
| Task | Command | Notes |
|---|---|---|
| See what a state contains | mednafen-state info <state> | Section table (MAIN, GPU, SPU, ...), resolved PC, main-RAM offset |
| Slice a RAM window to a file | mednafen-state extract <state> --start --end --out | What overlay capture imports into Ghidra; MIPS-shape sanity check |
| What changed between two states | mednafen-state diff | Regions ranked by bytes changed; merge_gap / min_changed tune clustering |
| Diff every manifest pair | scripts/mednafen/auto-capture.sh | Runs each scenario's watchpoints against its diff_against sisters; JSON per scenario |
| Which gap a value first appeared in | watchpoint-bisect.py --addr X s1 s2 s3... | Reports BracketedAt, AlreadyBadFromStart or NeverBecameBad; --trace prints the value per state |
| Stage every scenario for Ghidra | state-walk.sh --import | Slices each overlay window and imports it as a labelled program |
| Dump VRAM as PNG | mednafen-state vram-dump --out --out-bin --regs | 1024×512 RGBA8 + GPU registers; pairs with legaia-engine info --runtime-vram for a per-pixel diff |
| Read the frame's display list | mednafen-state display-list <state|ram.bin> | Walks the libgpu ordering table in RAM; display-list.py wraps .sstate |
| Name every UI sprite in a frame | widget-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 corpus | state-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 VRAM | mednafen-state clut-trace --pack ... | Slides a 32-byte window past each TMD; see battle-data pack |
| Renderer dispatch tables | prim-dispatch-table, prim-dispatch-survey | Decodes the per-prim renderer tables; survey exits non-zero if the SCUS table drifts across saves |
| Pin a save-block field | save-tool sc-diff card1 card2 --save-index N | Diffs 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-otsmerges,--ot-addrselects. Merging makes every surface appear twice - the false positive--coincidentmust 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
.mcrnames two opposite things.~/.mednafen/sav/*.mcrandsaves/library/cards/*.mcrare 128 KiB memory-card images (no main RAM);saves/library/mednafen/*.mcrare 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 reportsfield-initwhile 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
| Question | Steps |
|---|---|
| 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 guard | What it is | Who honours it |
|---|---|---|
backup_fingerprint | sha256 of an immutable copy in saves/library/<emulator>/ (gitignored); resolved in preference to the live slot | manage-states.py, ScenarioManifest::mednafen_save_path |
ram_fingerprint_sha256 | sha256 of the first 64 KiB of main RAM, computed from the save itself | Mode-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
| Item | Address / function | What it proves | Source |
|---|---|---|---|
| Game anchors | scene name 0x8007050C, mode 0x8007B83C, player pointer 0x8007C364 | A state can be identified from its bytes alone | legaia_mednafen::game_anchors, memory map |
| Widget class table | 0x800732A4 in SCUS_942.54 | Both SCUS sprite draw routines copy (u,v,w,h) verbatim, so the sprite join is exact | battle - widget class table |
| Per-prim renderer dispatch | FUN_80043390; tables 0x8007657C, 0x801F8968 | Which routine draws each primitive mode | crates/mednafen/tests/dispatch_table.rs |
| Save-state layout | Mednafen section table; &GPURAM[0][0] blob | Where main RAM and VRAM sit in the file | crates/mednafen README |
| RAM fingerprint | legaia_engine_shell::mode_trace_oracle::save_ram_fingerprint | Slot drift is detectable without an emulator run | crates/engine-shell |