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_8007B450Sub-screen IDMeaning
(char*)1 sentinel0x2Save from menu entry
*ptr == '\x01'0x19Load from slot
*ptr == '\x07'0x20Auto-save path
*ptr == '\r'0x4Post-save return
*ptr == '\x00'0x1aCancel / 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-screenDriverDisplay actorCallDirection
0x18FUN_801DAE24&DAT_801E4E28FUN_801DD35C(1, 2)save (RAM→card)
0x19FUN_801DAEF4&DAT_801E4E30FUN_801DD35C(1, 1)load (card→RAM)

Sub-screen pointer table at 0x801E4F40

IDAddressFunction
0x10x801E4F44FUN_801DAEF4 - slot selector
0x20x801E4F48Save entry (from menu)
0x40x801E4F50Post-save return path
0x180x801E4F80FUN_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
0x1E0x801E4FB8FUN_801DBC5C - 4-state spinner; advances to 0x1F on user-confirm
0x1F0x801E4FBCFUN_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:

StoreAddressSizePersists in SC?
Wide bitmapRAM 0x80085600..0x80085800512 B (4096 bits)Yes - at SC offset 0x14C0, via the bulk RAM→card transfer
Scratchpad wordRAM 0x1F8003944 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:

ElementSourceNotes
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.

ModeTimerElement(start) → (target)
0DAT_801ef160"Now checking" dialog(416, 112)(160, 112)
1const 0Header tabsheld at (48, 6)
2DAT_801ef194"Load" tab + active-slot pill(160, 96)(48, 40)
3DAT_801ef1a4Yes/No confirm dialog(160, 344)(160, 88)
4_DAT_801f01ccCard-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):

Elementx (relative to col base)y
16×16 portrait iconbase_xs3 − 4 (= 154)
Character namebase_x + 24s3 (= 158)
LV separator + valuebase_x / +32s3 + 13 (= 171)
HP separator + cur/maxbase_x / +16 / +61s3 + 26 (= 184)
MP separator + cur/maxbase_x / +24 / +69s3 + 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:

OffsetTypeField
+0x00char[24]Kingdom name (null-padded)
+0x10char[14]Save-card filename prefix (BISCPS-10059PRO) for validity
+0x24u32Game time in seconds
+0x28u8Party member count
+0x2C+iu8Per-character party ID (0=Vahn, 1=Noa, 2=Gala)
+0x30+iu8Per-character level
+0x34 / +0x44s16Char 0 MP current / max
+0x3C / +0x4Cs16Char 0 HP current / max
+0x54 + i*0x0Cchar[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.

See also

Reference Save record Inn Shop UI