Static overlay pipeline
Most of Legaia's gameplay code lives in RAM overlays - chunks of MIPS code the game pages into the 0x801C0000+ window per game mode. The dynamic capture workflow reverses them from emulator save states; this is the static complement: pull each overlay straight out of PROT.DAT (the disc's main archive, where overlay code ships as ordinary numbered entries), work out where in RAM it loads, and disassemble it there - with its identity attached from the first byte. No emulator, no save state, reproducible from any copy of the disc.
At a glance
- Identity
- An overlay is "PROT entry N at base X" - identity from the disc entry, not a capture label
- The map
crates/asset/data/static-overlays.toml: one row per overlay - entry, base, how the base was proved, sha256 of the as-loaded bytes (hashes only, no Sony bytes)- Base recovery
- Voted from the overlay's own
jalcall graph: every internal call target must land on a function prologue - Slots
- Slot A (
0x801CE818) - the big scene overlays; slot B (0x801F69D8) - summon / effect blobs that timeshare one buffer - Implementation
legaia_asset::static_overlay; CLIasset overlay ...- Limit
- No runtime values - filled tables,
$gp-relative globals, watchpoints stay with dynamic capture and live probes
What this solves
A save-state capture answers "what is in RAM right now" - and that is also its weakness. The 256 KB overlay window is a buffer, not a container: a capture holds the overlay that is up plus the tails of whatever loaded before it, and several overlays load at the very same address, so a bare FUN_801xxxxx can name a battle routine in one capture and a menu routine in another. The static pipeline gives you an overlay's clean bytes (exactly one PROT entry), a disc-pinned identity for every function, and a result anyone can reproduce from their own disc - including overlays nobody has captured live.
[entry start, next entry start)legaia-prot
2Recover the basevote B = jal_target - prologue_offset over the internal call graph; cross-check with an anchor functionasset overlay scan
3Pin identityrow in static-overlays.toml: entry, base, eligibility, sha256 of the as-loaded bytesasset overlay generate / verify
4Import into Ghidraeach blob at its base, program named overlay_<label>; functions land at their real addressesasset overlay ghidra
Quick start
Extract every eligible overlay from your own PROT.DAT, check the bytes reproduce the committed fingerprints, and import them into the Ghidra project at their bases:
asset overlay verify extracted/PROT.DATasset overlay extract extracted/PROT.DAT --out extracted/overlaysasset overlay ghidra --out extracted/overlayscp extracted/overlays/*.bin extracted/ && bash extracted/overlays/import_static_overlays.sh/data inside the container is ./extracted bind-mounted read-only, so the blobs reach Ghidra by being placed in extracted/ on the host, not by docker compose cp. Each overlay imports at its recovered base with the program named overlay_<label>.
Why static extraction works
PSX overlays are normally clean copies of a fixed-VA-linked blob: the loader DMAs the bytes into the window, runs FlushCache, and jumps in - no per-load relocation. So the on-disc entry is the loaded code, modulo the runtime-written .bss. Two proofs:
- Static reproducibility. The as-loaded bytes extracted from any copy of the disc hash to a committed sha256 (
asset overlay verify). - Runtime byte-match (disc + save-state gated). The battle overlay (PROT 0898) is the clean case: all
0x28800entry bytes match the resident RAM image. Where an entry carries a runtime-written.bsstail (PROT 0899),clean_copy_bytesrecords the verified prefix. Test:crates/mednafen/tests/static_overlay_clean_copy.rs.
Base recovery
The load base is recovered from the overlay's own internal jal call graph (static_overlay::recover_base): for the true base B, every internal call target T maps to file offset T - B, which must begin a function prologue (addiu sp, sp, -X). Tallying B = T - prologue_offset over every pair, the true base wins by a landslide - the field overlay recovers 0x801CE818 with dozens of corroborating targets. asset overlay scan prints the winning base and vote count per entry, so the recovery is reproducible rather than taken on trust.
Some images call nothing inside themselves, so there is no call graph to vote with and no entry point for analysis to follow - most of the slot-B cast / summon modules are like this. Their functions still come out of the bytes, by frame matching: walk from each prologue to the first jr ra whose delay slot restores that same frame. What makes the result evidence rather than a guess is that each recovered head is named by a row of the host overlay's own entry tables, and named in that image and no other. Two cautions: an entry can sit a few instructions above its prologue (where the routine materialises a global before setting up the frame), so the table is the authority and a prologue scan reports the later address; and a pointer that leaves the image - into the host overlay's data, or the .bss past the image's own end - is not evidence against the base.
The recovery is decisive enough to pin overlay identity. The menu overlay is PROT 0899 at 0x801CE818 - found by byte-searching the corpus for a function signature, corroborated by jal-recovery, RAM-verified across six menu-open states. 0899 and the field overlay 0897 are VA-alias siblings: both load at 0x801CE818 at different times, so 0x801CF650 is a string in 0897 but the equipment aggregator in 0899. That is the exact ambiguity this pipeline exists to resolve.
- Subtract aliased regions first. When an image carries bytes of a known overlay - a previous slot occupant, or a neighbour in an over-read window - those bytes are self-consistent at their own base and fix the vote by construction. Restricted to its own entry, a sparse-call-graph overlay may yield no landslide; the map then records a
captureorcross_refbase instead. Do not read a low resolution ratio as "no base fits": that metric rewards a base that catches few pairs. - Loader census. A full-image scan of both overlay loaders' call sites finds no site that can produce param 0 or 1, so extraction entries 0895/0896 are unreachable from any static loader call. 0895 is the boot
init.pakbundle; 0896 is a Japanese options / character-status image that links at0x801D4DF0and that no loader on this disc reaches - a foreign build, not an unidentified one: zero of its 322 calls into the executable's address range land on a function entry here.
History: the PROT 0896 cautionary tale
PROT 0896 was once labelled the options/menu overlay: recovered over the old over-reading PROT window it returned a convincing 60-vote base - but the votes came from the field overlay's bytes carried in 0896's over-read tail, whose self-consistency at 0x801CE818 fixes the result by construction. A live mode-24 capture also refuted the "mode-24 OTHER overlay" reading: 0896's bytes appear nowhere in RAM across the window. Moral: subtract the aliased region before trusting a recovered base.
Two later readings of the same image were wrong in the other direction, and both are corrected now. "Scanned over its own entry it yields no landslide, so 0896's true link base is unrecovered" was measured over the old over-reading window; over the corrected 0x9000-byte entry the call graph recovers 0x801D4DF0 on ten of eleven distinct internal jal targets, all 218 internal j targets land in file there, and three in-image pointer regions resolve every word. And "a lui-pair resolution ratio shows no base fits" is not a base test at all: it is one-sided, and on this image it ranks the refuted slot-A base first. The image is imported at 0x801D4DF0 and its whole code region is dumped. Falsified readings: do-not-re-walk.
Slot A vs slot B
The overlay loaders manage two independently swappable slots (*DAT_8001038C and *DAT_80010390; see prot).
Slot A - the big scene overlays
Slot A (0x801CE818) holds field (0897), battle (0898), menu (0899), the STR/MDEC cutscene overlay (0970), DEBUG MODE (0971), GAME OVER (0902), and the minigames (fishing 0972, slot machine 0975, Baka Fighter 0976, dance 0980). All are VA-alias siblings: same base, resident at different times.
- Field / battle / menu / cutscene recover from
jalalone (base_source = jal). - The minigame rows are cross-checked by a documented function landing on a prologue at the base (
anchor_va), because one minigame's code is duplicated across consecutive entries atbase + N×0x800, so jal-recovery can latch a phantom base. - The "world-map", "save" and "shop" UIs are not separate entries: the overworld controller lives in field 0897; save and shop sessions live in menu 0899.
A small overlay does not clear the slot
A load DMAs size bytes to 0x801CE818 and nothing zeroes the remainder, so an overlay smaller than its predecessor leaves that predecessor's tail resident - a save state taken while it is up captures a stack of strata. PROT 0971 (debug_menu) is the clear case, its own content only 0x1800 bytes:
| Capture VA range | Bytes belong to |
|---|---|
0x801CE818 .. +0x1800 | PROT 0971, the overlay actually loaded |
+0x1800 .. +0xB000 | PROT 0972 (fishing), a previous load |
+0xB000 .. | PROT 0897 (field), an older load still |
Each boundary is exactly the previous occupant's length, which makes the reading structural. Consequences: a capture's slot-A bytes are not one overlay's - resolve the bytes per VA; and the strata are the same mechanism that makes jal-recovery mis-fire on mixed images.
Slot B - summon / effect / minigame-data blobs
Slot B (link base 0x801F69D8) holds the player-summon / effect / minigame-data blobs from the 0900..0969 PROT cluster. These timeshare one buffer, so a save state catches an inseparable mix of two overlays - there is no clean whole-overlay RAM prefix, and most have too sparse a call graph to recover. Their base comes from a capture anchor or a cross-referenced RE result, cross-checked the slot-B way: a high fraction of the overlay's internal absolute self-pointers (lui+addiu) must resolve in-file at the committed base. This is where static extraction earns its keep: the disc entry disassembles cleanly at the link base even though the runtime buffer is unusable.
The summon-stager entries follow one loader arithmetic - extraction entry = loader param + 0x37F - and both spell blocks are capture-pinned per spell id, zero exceptions:
| Spell id(s) | Cast | Extraction entries |
|---|---|---|
0x81..=0x8B | Player Seru magic (Gimard = 0x81, ...) | 0903..=0913 |
0x99 | Evil Seru Magic (Juggernaut) | 0927 |
0x9A..=0x9D | Sim-Seru summons Palma / Mule / Horn / Jedo | 0928..=0931 |
0x9E..=0xA0 | Ra-Seru trio Meta / Terra / Ozma | 0932..=0934 |
Non-spell residents: summon-effect data (0957, a summon string table), the rare-Seru flute summons 0924/0925 (Lippian / Spikefish), the unused stub 0926, and the Delilas cast modules 0958..0960 (cast-module.md). Which action ids drive the attack-titled 0924 / 0927 stagers is the open piece (open threads).
Why pointer resolution alone can pin the wrong slot
pointer_resolution is one-sided: it counts any in-window hit for pointers whose lui half matches the candidate base, so an overlay that densely references fixed structures of a co-resident overlay in the rival slot's VA band can score high at a base it never loads to. The GAME OVER overlay (0902) proved it: 44/48 of its pointers are external references to battle-band structures that fall inside the slot-B window, scoring 91% there - but its true base is slot A 0x801CE818, pinned by the mode-18 loader chain and in-file string anchors. The hardened check, static_overlay::string_anchor_votes, counts pointers that decode to the start of one of the file's own string literals - a vote only the true base can win - and the reproducibility test fails any anchor-less row the rival slot out-votes. Caveat: a co-resident overlay's head string table can alias onto this file's own head strings at matching small offsets, so a pinned prologue anchor outranks the raw vote count.
History: how the slot labels were corrected (do not re-walk)
- "Slot machine = 0973 with a
0x4000over-read prefix" - a phantom base; the canonical entry is 0975, which recovers0x801CE818and is what the warp streams. - "Gimard = 0905" - an off-by-2 from reading the loader math as
param + 0x381; the correct math isparam + 0x37F, Gimard's stager is 0903. - "0907 = Disco King dance song" - refuted; the head title is Nighto's attack display name, and the dance overlay has no slot-B loader callsite.
- "GAME OVER (0902) is a slot-B overlay" - the pointer-resolution false positive dissected above; it is a slot-A row.
- "0957 = a dance song" - its head is a summon string table.
The project-wide list is do-not-re-walk.
The committed map
crates/asset/data/static-overlays.toml - one record per overlay:
| Field | Meaning |
|---|---|
prot_index | PROT.DAT entry the overlay is extracted from (the identity) |
base_va | Load base inside the overlay window; statically recovered, RAM-confirmed where a capture exists |
form | raw or lzs (decompress; needs decompressed_size) |
content_bytes | The overlay's own length: its PROT.DAT entry's sector extent. Present on every row, and the denominator every byte-denominated measurement over the image uses |
clean_copy_bytes | How much of the image a resident RAM capture has byte-verified - a strength-of-evidence figure, not a length. The two coincide on PROT 0898 and differ by 0xF174 on PROT 0899; using the shorter one as a denominator makes coverage look better while measuring less, with nothing in the result to signal it |
eligibility | verified (RAM byte-matched) / static (base-recovered, not RAM-verified) / ineligible (runtime-relocated - keep on the dynamic path) |
base_source | jal (call-graph recovery, default) / capture (byte-matched RAM anchor) / cross_ref (another pinned RE result) |
anchor_va | Optional known function VA that must land on a prologue at base_va - a capture-free base cross-check |
fingerprint_sha256 | sha256 of the as-loaded bytes - the disc-derived reproducibility anchor |
notes | Which subsystems / entry points live here |
CLI reference
asset overlay list # inspect the map
asset overlay scan extracted/PROT.DAT --from 895 --to 985 # recover bases + leading dev strings
asset overlay find-sig extracted/PROT.DAT "1e80043c a046838c e0ffbd27" --anchor-va 0x801DC6B4
# locate a function-head signature, infer host base
asset overlay verify extracted/PROT.DAT # assert every committed fingerprint reproduces
asset overlay extract extracted/PROT.DAT --out extracted/overlays
asset overlay ghidra --out extracted/overlays # emit per-overlay import scripts + driver
asset overlay generate extracted/PROT.DAT --index 897 # regenerate map rows; review before committing
The extracted .bins are Sony overlay code (gitignored); only the map and docs are committed. Verification closes the loop: a statically-extracted overlay reproduces the same functions at the same addresses as the captured dumps - anchors asserted against the disc bytes in crates/asset/tests/static_overlay_extract.rs and against live RAM in the clean-copy test.
Scope + limits
- An overlay image is exactly its entry.
Archive::read_entryreturns[entry start, next entry start)(see prot), so an extracted image ends where the overlay ends. - Only clean-copy overlays qualify. An overlay whose on-disc bytes do not match the resident image is runtime-relocated or runtime-constructed: mark it
ineligibleand keep it on the dynamic path. - The fingerprint covers exactly the entry span - it changes if and only if the entry's own bytes change; sector padding is harmless noise in the disassembly.
- It does not address runtime values. For
$gp-relative globals, watchpoint results, and anything the overlay constructs at run time, the dynamic captures and PCSX-Redux probes remain authoritative.
History: what the over-reading entry window cost
The superseded PROT entry-size expression read past each entry into its neighbour. FUN_801F5748 was long read as the field overlay's inventory hub: 0x801F5748 - 0x801CE818 = 0x26F30 is 0x1F30 past where 0897 ends, so those bytes are PROT 0898's battle dispatcher FUN_801D0748 - a real routine at a phantom address. The dump was correctly based; the over-read image answered for a VA it does not own. Any function dump or (entry, offset) coordinate taken from an over-read image carries that flaw - re-derive it from the entry rather than trusting the printed VA.