Tier F overview
Tier F of the A–F ladder - the most advanced technique, where the project's system-level mods (and most of the roadmap) live. If you haven't, read the series index first for the editing model and the technique ladder.

Mechanism at a glance

Host overlay
Menu overlay (PROT 0899), load base 0x801CE818
Dead region
Reference-free all-zero run 0x801E74E00x801E83E0 (~3.8 KB), resident whenever a shop is open
Picker edits
cursor clamp 0x801DB098 (3→4), box height 0x801E49E2 (0x260x34), renderer + routing detours
The swap
character record +0x13D (Seru id) / +0x161 (level); records at 0x80084708, stride 0x414
Edit class
in-place / same-size; the SCUS rodata gap is left untouched (only a 24-byte engine config blob lives there)
Provenance
static jal base recovery + RAM byte-match (0x15e8c clean 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) at 0x801DB098 - file offset 0x2880. Bumping it to 4 lets the cursor reach a fourth line.
  • The window box is a sprite def with a height byte. The frame height at +0xa of a sprite def (0x801E49E2) encodes rows×0x0e plus margins; three rows is 0x26, four needs 0x34 (+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 0x801DB098 goes 3 → 4; box height at 0x801E49E2 goes 0x26 → 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 at 0x801E7E20. The next-frame entry detour (0x801DAFD4) checks the flag and jumps to the handler at 0x801E74E0 - 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 stepping 0x18px/frame from -0xF0 to 0 - 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 at 0x80084708, stride 0x414). The Seru count at +0x13C never 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.

See also