Save Screen
Everything that happens between "Save" on the menu and bytes landing on the PSX memory card: the slot list, the "Now checking. Do not remove MEMORY CARD" beat, the overwrite confirm, and the actual card write. One structural surprise up front: the retail save UI is two-stage in two different id spaces - the SLOT 1 / SLOT 2 pills pick a memory-card port, and the 5×3 preview grid then picks one of that card's 15 save blocks. Conflating the two is the easy mistake.
How it works
The whole flow lives inside the menu overlay - a chunk of code the game streams into RAM on demand, the same binary that carries the shop, inn, and status screens (it is not an overlay of its own). We traced it with a disassembler (Ghidra); the FUN_80xxxxxx names below are RAM addresses in that trace. Three layers: an outer 9-state dispatcher (FUN_801DC6B4), a sub-screen function-pointer table, and a state machine over libcd (Sony's CD/memory-card library) that does the actual card I/O.
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 - load-from-slot driver (sub-screen 0x19)
Runs an actor VM with bytecode at &DAT_801E4E30 (its display actor), 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). It is reached from row 6 of the root command picker FUN_801D6B20 (sub-screen 0x01) - which, despite an earlier label here, is not a slot selector.
FUN_801D6B20 - root command picker (sub-screen 0x01)
The menu overlay's own top level. Phase 0 runs the display script &DAT_801E4BC0, raises DAT_801E46C0 = 0x1000 and masks DAT_801E46BC &= 0xFFF; phase 1 waits for the menu-active flag to clear, then runs a 7-row picker FUN_801D688C(&DAT_801E46BC, 7, 1). Rows 0..4 route unconditionally to sub-screens 5 (Items command window), 0x0E (Magic caster picker), 0x12 (Equip character picker), 0x15 and 0x17. The last two are gated: row 5 (save-card driver 0x18) is blocked when the installed entry context _DAT_8007B450 carries kind byte 0x0D - the test is on the kind, not on the pointer's presence, so a null context takes the allow branch - and row 6 (load 0x19) is blocked when the save-block presence byte is zero. Blocked rows buzz with cue 0x23 and stay. Cancel leaves for sub-screen 0, except under that same 0x0D context where it goes to 3, the Yes/No confirm: one context byte both hides Save and makes leaving ask first. Engine port: engine-core::pause_screens::{root_menu_confirm_route, root_menu_cancel_route}.
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).
FUN_801D688C - shared list-cursor navigator
The menu / shop / save-slot state-handlers funnel list-cursor navigation through one overlay helper, FUN_801D688C(cursor: *u32, count, mode). It reads the overlay confirm / cancel pad masks (_DAT_8007B874 & DAT_801EF0F0 / DAT_801EF0F4) and the held-pad word _DAT_8007BB84, mutates the caller's cursor cell in place, enqueues a UI SFX cue through FUN_80035B50, and returns a result enum: 1 = confirm (SFX 0x36, tested first even when count == 0), 2 = cancel (SFX 0x37), 3 = moved (SFX 0x21), 0 = none. The cursor cell is packed - the low 12 bits are the list index and the high nibble (0xF000) carries caller flags the navigator preserves across a move; held-pad 0x1000 decrements, 0x4000 increments. mode == 0 clamps at the ends, mode != 0 wraps (every ported call site passes 1). Call sites: the 3-item slot list (FUN_801DAFD4), the 2-item Yes/No confirm FUN_801D688C(&DAT_801E46D0, 2, 1) (sub-screen 0x03), and the party-count picker (sub-screen 0x12).
Engine port: legaia_engine_core::menu_input::menu_cursor_nav(cursor, count, wrap, NavButtons) reproduces it as a plain function over a caller-owned cursor cell and a NavButtons snapshot (host derives the booleans from input::InputState), returning a CursorNav enum whose sfx_cue() surfaces the retail cue id for the host to play through its SfxBank. SaveSelectSession::tick_confirm consumes it for the Yes/No confirm cursor.
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_801D6B20 - root command picker (7 rows) |
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 | - | FUN_801DAEF4 - load-from-slot driver |
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.
Save-block checksum (FUN_801E38D8)
A save block is exactly one card block (0x2000 bytes = 0x800 u32 words). FUN_801E38D8 is the block's additive checksum: it sums the first 0x7FF little-endian words with a wrapping accumulator, stopping one word short of the block's final word at byte 0x1FFC - the word where the write path stores the sum. The load direction of FUN_801DD35C reloads it (lw v1,0x1ffc(s1); beq v1,v0 at 0x801df888) and branches on stored-equals-computed to route the slot to the valid or the corrupt state. Ported as save_select::save_block_checksum with the compose helper save_block_checksum_valid mirroring the load-path compare.
Slot list: memory-card slots, not save blocks
Retail's save UI is two-stage, and the two stages live in different id spaces - conflating them is the easy mistake.
| Stage | What the player picks | Count | Retail anchor |
|---|---|---|---|
Pill row (SLOT 1 / SLOT 2) | a memory-card port | 2 | the libcd channel's port (chan = port * 16 + sub_op, FUN_801E3294) |
| 5x3 preview grid | a save block on the chosen card | 15 | the directory walk FUN_801E1208; per-slot buffer 0x801EF1B8 + N * 0x100 |
Between them sits the card read - the "Now checking. Do not remove MEMORY CARD" dialog - which is why that beat exists at all.
SaveSelectSession is renderer-agnostic and models the phases, not the id space, so a host picks which reading its slot list carries:
- Flat (default): the slot list is the save blocks; the pills show the first two and Save picks a block straight off the pill row. The native shell drives this against its on-disk LGSF slots.
- Card slots (
set_card_slots_mode(true)): the slot list is the two ports. Save then crosses the sameNowCheckingbeat Load does and raises its overwrite prompt from the preview rather than from the pill row, andpresenton a pill means "a card is inserted", not "this holds a save". The browser play page drives this against the player's own card images.
The grid cursor is the host's, not the session's: SlotPreview ignores directions, so which of the fifteen blocks is focused - and therefore which block a confirm commits - is host state.
Messagebox panel geometry (FUN_801E36C4)
Every save-UI panel rect flows through one drawer. Its x is a centre, not a left edge, and the box emitter it forwards to inflates the centre rect by a uniform 8px on every side (the same inflation as the dialog reading box):
footprint = (center_x - w/2 - 10, y - 2, w + 16, h + 16)
Pinned against the live GP0 draw list (the sprite rects the GPU actually receives): the header tab (48, 6, 65, 13) predicts (6, 4, 81, 29) - exactly the Load panel's byte-pinned 14-sprite composition - and the parked "Now checking" dialog (160, 97, 169, 26) predicts (66, 95, 185, 42). Gold-border pixel scans read one pixel inside this footprint (the outermost tile ring scans as background), which is what an earlier +14 / -9 / -1 model was built on.
The confirm prompt is two panels plus stacked options - not one box with Yes/No side by side: prompt bar (8, 86, 300, 29) and Yes/No box (129, 118, 58, 42) at the parked slide y = 88.
Engine port
legaia_save::SaveFile (with SaveExt) is the clean-room counterpart: the LGSF v2 container (party records, story flags, money, inventory, play-time, per-character ext) with backward-compatible v3 (full 512-byte story-flag bitmap) and v4 (LGX4 shiny-Seru block) extensions. It 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.
UI render side. legaia_engine_core::save_menu_atlas composes a single 256×256 RGBA atlas containing the 9-slice panel tiles, the interior-fill tile (pre-baked gouraud gradient), the pointing-finger cursor, and the slot pills, each decoded with its correct CLUT row; legaia_engine_render::save_select_chrome_draws_for emits the retail-pinned tile composition, and the “Load” title renders from the whitewashed dialog-font stencil at SAVE_SELECT_TITLE_POS with the SAVE_SELECT_TITLE_COLOR tint. All retail framebuffer coordinates are expressed in the canonical 320 × 240 boot-UI stage so they stay in lockstep at any window resolution.
Animation side. SaveSelectSession::slide_anim_t() collapses retail slide timers 0/2 into one driver, with interpolate_anim((start, target, t)) implementing the 12-bit fixed-point formula; info_panel_slide_anim_t() drives the bottom info panel's vertical slide, and slot_info_panel_draws_for / slot_info_panel_text_draws_for take a panel_y_offset so every per-element constant stays panel-relative.
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 a VRAM dump (VRAM is the PS1's video memory, where every texture and CLUT palette row lives) plus a scan of the GP0 primitives (the GPU's raw draw commands) at PCSX-Redux save state 9:
| Element | Source | Notes |
|---|---|---|
| Title art behind | PROT 0890 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 | The dialog font (legaia_font), tpage 14 (VRAM (896, 0)), CLUT at VRAM (208, 510) |
4 textured sprites (14×15 each) at stage (35, 13)…; source UVs map to L/o/a/d via the runtime 16×16 cell pitch. The bright text colour is CLUT entry [15] = (206, 206, 206). The earlier “menu-glyph atlas at PROT.DAT[0x11218], CLUT row 13” pin is falsified - that atlas has zero glyph indices at the documented rects. |
| 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. |
Pin 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. Full tooling chain: tooling/pcsx-redux-automation.
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.
Per-mode timer + element table
| 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) |
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.
Buffer byte layout
| 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 |
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. Capture sources: the overlay_save_ui_select.bin / overlay_save_ui_saving.bin mednafen captures (slot-select and writing-in-progress states), both confirmed as the menu overlay by function-address identity.