Tier F - overlay dead-region injection
The top of the ladder. Park hand-assembled MIPS inside a resident overlay's reference-free dead space - not the executable's contended rodata gap (tier E) - so a whole new screen or system runs on real hardware, costs nothing, reloads with the overlay, and composes with every other feature. The worked example is the randomizer's Seru-trading vendor: a fourth Buy / Sell / Trade / Quit row whose entire trade screen lives in dead bytes inside the menu overlay.
Mechanism at a glance
- Host overlay
- Menu overlay (PROT 0899), load base
0x801CE818 - Dead region
- Reference-free all-zero run
0x801E74E0–0x801E83E0(~3.8 KB), resident whenever a shop is open - Picker edits
- cursor clamp
0x801DB098(3→4), box height0x801E49E2(0x26→0x34), renderer + routing detours - The swap
- character record
+0x13D(Seru id) /+0x161(level); records at0x80084708, stride0x414 - Edit class
- in-place / same-size; the SCUS rodata gap is left untouched (only a 24-byte engine config blob lives there)
- Provenance
- static
jalbase recovery + RAM byte-match (0x15e8cclean prefix) + hardware-confirmed render→swap - Oracle
seru_trade_real.rs
Why this tier exists
Tier E parks new code in the executable's reclaimed rodata gap - a finite ~1 KB region already shared by the bonus-drop routine, the flee-EXP routine, a name string, and an engine config blob, each guarded as all-zero. That works for a small detour, but it doesn't scale to a whole interactive screen, and every new tenant competes for the same bytes. Tier F sidesteps the contention entirely: the overlay that's already in RAM has dead space of its own. Code placed there is present exactly when the host mode runs, reloads with the overlay every time, and consumes zero bytes of the shared gap - so it composes with all of tier E at once. Custom MIPS hooks are routine in serious PSX hacking; the result here isn't “we wrote assembly,” it's reversing a menu thoroughly enough to splice into it cleanly, then finding a host for the new routine that costs nothing and conflicts with nothing.
First: where the shop menu lives
Legaia keeps almost no gameplay code in the main executable (SCUS_942.54). The menus are RAM overlays paged into the 0x801C0000+ window per game mode; a static cross-reference for a menu function returns nothing because the function isn't in the executable. The menu overlay is PROT entry 0899 at base 0x801CE818. Pinning that identity is its own result, because several overlays link to the same VA range - the field overlay (0897) and the menu overlay (0899) are aliases that both load at 0x801CE818 at different times, so the same address is a "Give" string in one and an equip aggregator in the other. The static overlay pipeline disambiguates them structurally and verifies 0899's bytes against live RAM (a 0x15e8c-byte clean prefix across six menu-open save states), so the on-disc entry can be treated as the loaded code and every byte reasoned about.
Reversing the shop picker
The Buy / Sell / Quit chooser is what we extend, so it's what we reverse. Three facts had to be pinned before a fourth row could exist, each read from the overlay's disassembly at its recovered base and corroborated against runtime capture:
- The cursor's row count is a literal immediate. The picker clamps to three rows with
addiu a1, zero, 3(0x24050003) at0x801DB098- file offset0x2880. Bumping it to4lets the cursor reach a fourth line. - The window box is a sprite def with a height byte. The frame height at
+0xaof a sprite def (0x801E49E2) encodes rows×0x0eplus margins; three rows is0x26, four needs0x34(+14px), or the new row renders outside its box. - Draw order is text-first, box-last. The renderer (
FUN_801D4868) emits each label's text before the opaque window box, so the box lands behind the glyphs. The fourth row's label draw has to obey the same order or the text vanishes - a bug found and fixed once, by matching the native sequence.
These are the load-bearing details. They look trivial written down; recovering them meant identifying the renderer and its dispatcher (FUN_801DAFD4) in a symbol-free overlay, reading the primitive emission to learn the draw order, and confirming the constants against the live menu. That's the gap between “patch a byte” and “understand the screen.”
The structural key: a reference-free dead region
Knowing how to draw a fourth row is useless without somewhere to put the trade screen's code. Inside the resident 0899 image is an all-zero run of ~3.8 KB - about 0x801E74E0 to 0x801E83E0 - that no instruction or pointer references. It's dead space: present in RAM whenever a shop is open, reloaded with the overlay, read or written by nothing. The whole trade screen lives there:
0x801E74E0 trade-screen handler (render + input + slide + swap)
0x801E7B00 entry-detour stub (per-frame gate on the active flag)
0x801E7B60 dispatch-detour stub (route the Trade row to its sub-mode)
0x801E7C20 row-4 label-draw stub (text-first, per the native order)
0x801E7D20 "Trade" / "SERU TRADE" / "Trade?" / "Yes" / "No" strings
0x801E7D60 seed-derived offer schedule (64 buckets × 3 bytes = 192 B)
… ends before 0x801E83E0 (compile-time disjointness check)
Because nothing lands in the executable's rodata gap, the vendor composes with every gap-based feature - enable it alongside all of tier E and they never collide. A compile-time assertion (trade_0899_layout_is_disjoint) proves the handler, stubs, strings, and table never overlap and fit under the dead region's ceiling.
Splicing it in
With the picker understood and a host found, the wiring is a handful of small, byte-verified edits to 0899 plus the hosted routine:
- Make the picker four rows. Cursor clamp at
0x801DB098goes3 → 4; box height at0x801E49E2goes0x26 → 0x34; a one-instruction detour in the renderer body jumps to the row-4 label stub (which draws “Trade” text-first, then returns). - Route a confirmed Trade. A detour at the confirm dispatch (
0x801DB0C8) sends cursor row 2 into the trade sub-mode, setting an active flag at0x801E7E20. The next-frame entry detour (0x801DAFD4) checks the flag and jumps to the handler at0x801E74E0- so the trade screen runs as a sub-mode of the shop: no scene warp, no overlay reload, no mode change. - Run the screen. The handler re-polls the pad each frame (
FUN_8001822C), slides the window in (a signed offset stepping0x18px/frame from-0xF0to0- exactly 10 frames), moves a line cursor over one row per owner of the wanted Seru, and draws with the game's own primitives: text (FUN_80036888), number formatter (FUN_80034B78), window box (FUN_8002C69C), animated cursor (FUN_8002B994). × raises a real “Trade?” Yes/No confirm; ○ backs out. - Do the swap. On confirm, the handler rewrites the owner's spell list in place: Seru id at
+0x13D, level at+0x161(records based at0x80084708, stride0x414). The Seru count at+0x13Cnever changes - always one-for-one, never a duplicate.
Every piece is written into 0899 via patch_prot_entry(899, …), each target guarded as all-zero dead space first, so an already-patched or unexpectedly-structured disc is rejected rather than corrupted.
Deterministic offers
The trade rotates over play time but is a pure function of the seed, so a preview and the live game always agree. The shared kernel (legaia_asset::seru_trade) precomputes a 64-bucket schedule: each bucket is one (want, give, give_level) triple - the wanted Seru, the different Seru handed back, and its fixed level (4..=9, shown before you commit). That's the 192-byte table at 0x801E7D60. At runtime the handler indexes it with the play-time counter at 0x80084570, which advances roughly per frame (≈60/s), so it divides by a frame-denominated period (32400 frames, ≈9 min, immediate fits one addiu) and masks to 64 buckets - a full cycle in ~9.6 h. The bucket expands to one line per member who owns the wanted Seru, excluding anyone who already owns the give-back. The id space is the player Seru block 0x81..=0x95.
This is the tier's variant J: the same kernel feeds the clean-room engine through a 24-byte SeruTradeConfig blob at 0x8007AF00 - the only gap-resident byte the feature uses, and the bridge from the disc patcher to the reimplementation.
Verification - and no Sony bytes
The retail screen is hardware-confirmed end to end: row appears → window slides → cursor moves → “Trade?” confirms → the spell list changes. The disc oracle seru_trade_real.rs asserts the round trip - a vanilla disc reports no config; a patched one decodes the embedded schedule back to the kernel's offers; every byte lands inside 0899 and the rodata gap is untouched; the edit is tiny and localized; a fixed seed is byte-deterministic. Throughout, the project's discipline holds: no Sony-owned bytes are committed - only addresses, offsets, struct layouts, and the project's own clean-room MIPS and Rust. The overlay image the handler lives in is the user's, reconstructed from their own disc at patch time.
Mods using this technique
- Seru-trading vendor (
--seru-trade) - shipped; the worked example above. - Enemy battle assist - roadmap; a random/defeated enemy joins as a non-controllable ally actor.
- Seru racing - roadmap; a new horse-racing-shaped minigame (race SM + odds + betting) hosted in a minigame overlay's dead space.
- Muscle Dome spectate + betting - roadmap; watch two NPCs fight and wager before the match.
- Blood Moon - roadmap; a periodic overworld event (red tint + stronger enemies + better loot) on a ~30-minute cycle.
Adding a tier-F mod: find a reference-free dead region in the resident host overlay (verify it's all-zero in live RAM), keep the hosted routine + data under the region ceiling with a compile-time disjointness assertion, guard every write target as dead space, and add a disc-gated oracle.