Save Screen
The save-slot selection and write flow used whenever the game persists progress to the PSX memory card. Lives inside the menu overlay (same 129-function binary as shop, inn, and status screens - not a separate overlay). Outer dispatcher: FUN_801DC6B4 (856 bytes). Sources: overlay_save_ui_select.bin and overlay_save_ui_saving.bin mednafen captures (slot-select and writing-in-progress states), both confirmed as the menu overlay by function-address identity.
How it works
FUN_801DC6B4 drives a 9-case state machine on _DAT_8007B43C. It returns true when the save flow terminates (state ≥ 6). The entry-context pointer _DAT_8007B450 is decoded on state 0 to select which sub-screen opens:
_DAT_8007B450 | Sub-screen ID | Meaning |
|---|---|---|
(char*)1 sentinel | 0x2 | Save from menu entry |
*ptr == '\x01' | 0x19 | Load from slot |
*ptr == '\x07' | 0x20 | Auto-save path |
*ptr == '\r' | 0x4 | Post-save return |
*ptr == '\x00' | 0x1a | Cancel / back |
State 2 dispatches via the sub-screen function pointer table at 0x801E4F40: (*(DAT_801E46A4 * 4 + 0x801E4F40))(_DAT_8007B874). Input is suppressed while _DAT_8007B440 > 0x79 (mid-fade). States 3–5 handle the fade-out sequence before the terminal return.
Key functions
FUN_801DAEF4 - save-slot selector (sub-screens 0x1 / 0x2)
Runs an actor VM with bytecode at &DAT_801E4E30 (slot-select menu), waits on _DAT_8007BB80 != 0 (menu-active flag). State 2 calls FUN_801DD35C(1, 1) - the saving-overlay main in load direction (param_2 selects op: 1 = load, 2 = save). Each step calls FUN_80031D00 (text-actor tick / MES advance).
FUN_801DAFD4 - slot confirm + saving in progress
Runs the 3-item slot scrolling list via FUN_801D688C(&DAT_801E46BC, 3, 1). On slot 1 confirmed: validates against the save-block existence table at &DAT_80084140 + slot * 2 + 0x1818 (byte 0 = slot present, byte 1 = slot valid), then advances DAT_801E46A4 to 0x1E (the write sub-screen). On slot 2: cancel SFX. Slot 0 routes to the card-full/error sub-screen (0x1B).
Load/save dispatch (FUN_801DD35C)
Sub-screens 0x18 and 0x19 are structurally identical 3-state drivers that share the saving-overlay main routine, distinguished only by the op selector passed as param_2. Both install _DAT_8007B44C = DAT_801C6EA0 (card handle) on state 0.
| Sub-screen | Driver | Display actor | Call | Direction |
|---|---|---|---|---|
0x18 | FUN_801DAE24 | &DAT_801E4E28 | FUN_801DD35C(1, 2) | save (RAM→card) |
0x19 | FUN_801DAEF4 | &DAT_801E4E30 | FUN_801DD35C(1, 1) | load (card→RAM) |
Sub-screen pointer table at 0x801E4F40
| ID | Address | Function |
|---|---|---|
0x1 | 0x801E4F44 | FUN_801DAEF4 - slot selector |
0x2 | 0x801E4F48 | Save entry (from menu) |
0x4 | 0x801E4F50 | Post-save return path |
0x18 | 0x801E4F80 | FUN_801DAE24 - save-card driver entry; installs card handle and dispatches into FUN_801DD35C(1, 2) |
0x19 | - | Load-from-slot path |
0x1B | - | Card-full / error screen |
0x1E | 0x801E4FB8 | FUN_801DBC5C - 4-state spinner; advances to 0x1F on user-confirm |
0x1F | 0x801E4FBC | FUN_801DBD94 - D-pad quantity-input screen (money/inventory effect; NOT the card writer - actual card I/O lives in the overlay_save_ui_saving_* overlay via libcd write syscalls through the _DAT_8007B44C card handle) |
See docs/subsystems/save-screen.md for the full pointer-table audit (most 5–15 ms state machines following the same actor-invoke / pad-input / advance pattern as FUN_801DA9F8).
Libcd I/O state machine (FUN_801E3294)
The actual PSX memory-card calls live in FUN_801E3294 - a 5-state libcd state-machine driver in the menu overlay. The per-frame ticker FUN_801E1114 is the static caller wiring it together: every frame it calls FUN_801E3294(DAT_801EF18C, 0); when _DAT_801F021C == 3 (save commit) it sequences FUN_801E3AF0 (sprintf "bu%d_%d" + libcd channel open) → FUN_801E3BA0 (block-count query) → FUN_801E1208 (directory walk).
FUN_801E1208 walks the 15-entry libcd directory table at 0x801F32A8 (stride 0x28), matching each filename against the region-specific Legend of Legaia prefix using BIOS-A(0x18) strncmp:
BASCUS-94254PRO_- USA (Legend of Legaia, SCUS-94254)BISCPS-10059PRO_- JP (Legend of Legaia, SCPS-10059)
The actual BIOS card-write thunk is FUN_8006EE34: it calls FUN_8006EE7C (BIOS-B(0x50)) then FUN_8006EE6C (BIOS-B(0x4E)) with channel argument (port, 0x3F, 0). The channel encoding is chan = port * 16 + sub_op.
Engine port
legaia_save::SaveFile (with SaveExt) is the clean-room counterpart. It stores party + story flags + money + inventory in the LGSF v1 format and round-trips via engine-core's save_full / load_full calls. The legaia-engine save / load subcommands exercise this path end-to-end.
Retail-save offsets are pinned (story_flags at SC +0x14C0, inventory at SC +0x1818); see docs/subsystems/save-screen.md for the full block layout. The libcd I/O state machine that drives both load and save is pinned at FUN_801E3294; the actual BIOS card-write thunk is FUN_8006EE34 (which calls BIOS-B(0x4E) _card_write via the wrapper at FUN_8006EE6C). FUN_8001A8B0 is plain memcpy used to stage data into / out of the staging buffer at 0x801E5120.
Story-flag persistence vs. scratchpad word
Two distinct stores share the name “story flags” but live in unrelated regions, and the SC save/load path does not sync between them:
| Store | Address | Size | Persists in SC? |
|---|---|---|---|
| Wide bitmap | RAM 0x80085600..0x80085800 | 512 B (4096 bits) | Yes - at SC offset 0x14C0, via the bulk RAM→card transfer |
| Scratchpad word | RAM 0x1F800394 | 4 B (32 bits) | No |
The scratchpad word _DAT_1F800394 is the field-VM transient that opcodes 0x2E (set bit), 0x2F (clear bit), and 0x30 (test bit) operate on. A static sweep across all dumped functions finds one non-RMW writer: FUN_8001DCF8 at PC 0x8001E17C, which seeds the lower 16 bits from mode_table[mode_idx].param on mode init. No retail code path copies between the wide bitmap and the scratchpad word, so the engine's SaveExt::story_flags (mirroring the scratchpad word) and SaveExt::story_flag_bits (mirroring the bitmap) round-trip independently - that matches retail behaviour.
Continue → Load screen sprite sources
The retail Continue → Load screen overlays a “Load” header panel and N blue SLOT pills on top of dimmed title art, with a pointing-finger cursor next to the active pill. Every sprite source is byte-confirmed via VRAM dump + GP0 primitive scan at PCSX-Redux save state 9:
| Element | Source | Notes |
|---|---|---|
| Title art behind | PROT 0888 title TIM, dst (33, 6) - (287, 154) |
Same atlas the title menu samples; rendered dimmed during SaveSelect. |
| “Load” panel chrome (9-slice) | PROT.DAT[0x018E0] system-UI sprite sheet, CLUT row 2 |
4bpp 256×192 TIM in the unindexed pre-init_data PROT.DAT gap. The 32-byte CLUT signature appears at exactly one place in the disc corpus (PROT.DAT offset 0x1934). Retail composes the 81×29 panel at dst (6, 4) from 14 textured-sprite primitives (cmd 0x64): 4 corners (4×4 each), top/bottom edges (24×4 tiles repeated 3× with a 1×4 remainder), and left/right edges (4×21). |
| Panel interior fill | Same TIM, CLUT row 2, source (128, 0, 32, 29) | 3 gouraud-shaded textured-quad primitives (cmd 0x3C) with a vertical gray gradient rgb(64,64,64) → rgb(136,136,136) tile the marbled-blue stippled pattern across the panel interior (2 full 32-wide copies + 1 17-wide remainder). |
| “Load” text glyphs | PROT.DAT[0x11218] menu-glyph atlas, CLUT row 13 |
4 glyphs at source (192, 32), (240, 64), (16, 64), (64, 64) - each 14×15. CLUT row 13 signature byte-equal at PROT.DAT[0x113CC]. |
| Pointing-finger cursor | Same system-UI TIM, CLUT row 7, source (152, 64, 16, 16) |
Retail dispatches as a single textured-sprite at dst (114, 100); shifts down by 17 px for SLOT 2. |
| SLOT pills | PROT 0899 + 0x16908 save-menu TIM, CLUT 7, sources (33, 97, 45, 15) and (33, 113, 45, 15) |
Saturated blue with baked “SLOT 1” / “SLOT 2” labels. Byte-equal to retail. |
Method: pin CLUT TIMs via PCSX-Redux save state → extract_vram_from_sstate.py → locate CLUT row in VRAM → grep the byte signature in PROT.DAT. Pin tile geometry by scanning the captured main RAM for GPU primitives (scan_panel_prims.py for textured sprites, scan_textured_quads.py for gouraud-shaded quads) and reading the source u/v + CLUT inline in each primitive. See the full tooling chain at tooling/pcsx-redux-automation.
Engine port: legaia_engine_core::save_menu_atlas composes a single 256×256 RGBA atlas containing the 9-slice panel tiles + cursor + slot pills, decoded with the correct CLUT row each, and legaia_engine_render::save_select_chrome_draws_for emits the retail-pinned tile composition. All retail framebuffer coordinates (panel (6, 4), cursor (114, 100), etc.) are expressed in the canonical BOOT_UI_STAGE_W × BOOT_UI_STAGE_H = 320 × 240 stage so they remain in lockstep at any window resolution.
Slide-in UI primitive (FUN_801E1C1C)
The save-UI overlay's slide-in animations all flow through a single primitive, FUN_801E1C1C(mode, anim_t, start_x, start_y, target_x, target_y). The function inlines its own 12-bit fixed-point linear interpolation, then dispatches per mode to emit the slid-in content at the interpolated (x, y):
iVar10 = (param_5 - param_3) * param_2; // (target_x - start_x) * t
if (iVar10 < 0) iVar10 += 0xfff; // round-toward-zero
param_3 = param_3 + (iVar10 >> 0xc); // start_x + delta * (t/4096)
anim_t is 12-bit fixed-point in [0, 0x1000]: t=0 at start, t=0x1000 at target. Each animated element owns a dedicated timer global that the dispatcher (FUN_801DD35C) ramps +0x100 per frame, clamped at 0x1000 - a 16-frame slide at NTSC.
| Mode | Timer | Element | (start) → (target) |
|---|---|---|---|
0 | DAT_801ef160 | "Now checking" dialog | (416, 112) → (160, 112) |
1 | const 0 | Header tabs | held at (48, 6) |
2 | DAT_801ef194 | "Load" tab + active-slot pill | (160, 96) → (48, 40) |
3 | DAT_801ef1a4 | Yes/No confirm dialog | (160, 344) → (160, 88) |
4 | _DAT_801f01cc | Card-init / format dialog | (576, 112) → (160, 112) |
Engine port: SaveSelectSession::slide_anim_t() collapses retail timers 0/2 into one driver, and the free function interpolate_anim((start, target, t)) implements the formula. The shell driver wires it for the slot composite pill (136, 96) → (24, 40) and the NowChecking dialog (panel + text both at x ∈ {416 → 160}) via the renderer's slide_offset parameter.
Bottom info panel (FUN_801E08D8)
Once the "Now checking" dialog dismisses and the slot-preview screen appears, the save-UI overlay emits a bottom info panel showing the selected slot's kingdom, game time, party leader portrait, and per-character stats. FUN_801E08D8(slot_index, view_mode) renders the whole panel and is called once per frame by the grid-renderer wrapper FUN_801E06C0.
Vertical slide-in (sixth save-UI animator)
The info panel has its own bespoke vertical slide-in, distinct from the FUN_801E1C1C primitive - the primitive can only animate ONE element, while the info panel propagates a single panel_y across 15+ separate sprite/text emit calls. Entry math:
iVar4 = DAT_801ef1a0 * -0x100;
if (iVar4 < 0) iVar4 += 0xfff;
iVar4 >>= 0xc;
local_34 = iVar4 + 0x18a; // panel chrome top-y
local_34 ramps from 394 (off-screen below) at anim_t = 0 down to 138 (parked under load chrome) at anim_t = 0x1000. The timer DAT_801ef1a0 is held to 0 while DAT_801ef160 (NowChecking) is up, then increments once the NowChecking dialog has retracted - matching the engine's SaveSelectSession::info_panel_slide_anim_t() semantics.
Title row layout (view mode 1)
All emit at y = local_34 + 4 (= 142 fully-landed). The "No.X" slot-number badge is rendered as a sprite via FUN_801E3FF0 (CLUT row = slot_index << 4) at x = 8; kingdom name at x = 48; "Time " label at x = 208; HH:MM:SS digits at x = 236 / 260 / 284.
Per-character row layout
Iterates i = 0..slot_buf[+0x28] (party member count). Horizontal stride +0x60 = 96 px starting at base_x = 16, so columns 0/1/2 emit at x = 16 / 112 / 208. Per-character vertical base s3 = local_34 + 20 (= 158):
| Element | x (relative to col base) | y |
|---|---|---|
| 16×16 portrait icon | base_x | s3 − 4 (= 154) |
| Character name | base_x + 24 | s3 (= 158) |
LV separator + value | base_x / +32 | s3 + 13 (= 171) |
HP separator + cur/max | base_x / +16 / +61 | s3 + 26 (= 184) |
MP separator + cur/max | base_x / +24 / +69 | s3 + 39 (= 197) |
HP / MP value colour ramp: 7 (green, default), 6 (yellow, cur ≤ max/2), 9 (red, cur ≤ max/4).
Per-slot data buffer
The renderer reads slot N from 0x801EF1B8 + N * 0x100:
| Offset | Type | Field |
|---|---|---|
+0x00 | char[24] | Kingdom name (null-padded) |
+0x10 | char[14] | Save-card filename prefix (BISCPS-10059PRO) for validity |
+0x24 | u32 | Game time in seconds |
+0x28 | u8 | Party member count |
+0x2C+i | u8 | Per-character party ID (0=Vahn, 1=Noa, 2=Gala) |
+0x30+i | u8 | Per-character level |
+0x34 / +0x44 | s16 | Char 0 MP current / max |
+0x3C / +0x4C | s16 | Char 0 HP current / max |
+0x54 + i*0x0C | char[8] | Per-character name |
Engine port: SaveSelectSession::info_panel_slide_anim_t() drives the slide-in; legaia_engine_render::slot_info_panel_draws_for + slot_info_panel_text_draws_for take a panel_y_offset: i32 parameter wired from info_panel_slide_offset(session) in the shell driver. All per-element offset constants are exported panel-y-relative so future layout shifts only need to touch the parked-y constant.
Full reference
Complete state-machine and sub-screen tables with Ghidra provenance live at docs/subsystems/save-screen.md in the repo. Function dumps: ghidra/scripts/funcs/overlay_menu_801dc6b4.txt, overlay_menu_801daef4.txt, overlay_menu_801dafd4.txt, overlay_save_ui_select_801e1c1c.txt, overlay_save_ui_select_801e08d8.txt.