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 jal call 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; CLI asset 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.

1PROT entrythe overlay's on-disc bytes, exactly [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.DAT
asset overlay extract extracted/PROT.DAT --out extracted/overlays
asset overlay ghidra --out extracted/overlays
cp 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 0x28800 entry bytes match the resident RAM image. Where an entry carries a runtime-written .bss tail (PROT 0899), clean_copy_bytes records 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 capture or cross_ref base 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.pak bundle; 0896 is a Japanese options / character-status image that links at 0x801D4DF0 and 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 jal alone (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 at base + 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 rangeBytes belong to
0x801CE818 .. +0x1800PROT 0971, the overlay actually loaded
+0x1800 .. +0xB000PROT 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)CastExtraction entries
0x81..=0x8BPlayer Seru magic (Gimard = 0x81, ...)0903..=0913
0x99Evil Seru Magic (Juggernaut)0927
0x9A..=0x9DSim-Seru summons Palma / Mule / Horn / Jedo0928..=0931
0x9E..=0xA0Ra-Seru trio Meta / Terra / Ozma0932..=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 0x4000 over-read prefix" - a phantom base; the canonical entry is 0975, which recovers 0x801CE818 and is what the warp streams.
  • "Gimard = 0905" - an off-by-2 from reading the loader math as param + 0x381; the correct math is param + 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:

FieldMeaning
prot_indexPROT.DAT entry the overlay is extracted from (the identity)
base_vaLoad base inside the overlay window; statically recovered, RAM-confirmed where a capture exists
formraw or lzs (decompress; needs decompressed_size)
content_bytesThe 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_bytesHow 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
eligibilityverified (RAM byte-matched) / static (base-recovered, not RAM-verified) / ineligible (runtime-relocated - keep on the dynamic path)
base_sourcejal (call-graph recovery, default) / capture (byte-matched RAM anchor) / cross_ref (another pinned RE result)
anchor_vaOptional known function VA that must land on a prologue at base_va - a capture-free base cross-check
fingerprint_sha256sha256 of the as-loaded bytes - the disc-derived reproducibility anchor
notesWhich 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_entry returns [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 ineligible and 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.

See also