Subsystems
How Legaia's engine actually works under the hood. Each card is a self-contained page on one subsystem — what it does, where its code lives, and how it connects to the rest. Start with Boot path if you're new to the project.
The shape of the engine
Legaia is a fairly typical late-90s PSX RPG engine, but with a twist: most of the gameplay logic doesn't live in the main executable. SCUS_942.54 contains the boot path, low-level I/O, the asset dispatcher, the move-table VM, the motion VM, and the audio / renderer libraries — about half the runtime. The other half lives in RAM overlays that the disc loads on demand: title screen, town, battle, menu. Two of the runtime VMs (the field/event VM and the effect VM) live in those overlays.
The list below groups subsystems by what layer of the engine they sit at.
Bootstrap and asset plumbing
The five runtime VMs
Legaia's runtime is driven by five independent VMs that all talk to one shared actor model. Four are clean bytecode dispatchers (actor / move-table / motion / field-event); the fifth (effect) is a per-slot state machine — different shape, same architectural role. They serve different layers of the engine and were almost certainly written by different members of the dev team.
FUN_8003774C. Drives NPC pathing + camera follow + "face the speaker" dialog poses. Fully ported.Per-domain runtime
FUN_801E76D4 handles the debug top-view toggle, top-view camera scroll/azimuth/zoom, and normal-walk per-frame update. Entity tick SM (FUN_801DA51C, 5 states) drives encounter and location-entry sequences.FUN_801DC6B4: 9-state machine, entry-context pointer table at 0x801E4F40, slot-select via actor VM, save-block existence scan at DAT_80084140.ShopSession tracks pending item, quantity, and buy/sell direction. Sell price = half buy price (min 1). Per-scene item tables pending overlay trace.InnSession { cost } gates the stay on affordability, then restores all active party members to full HP/MP. Per-scene costs pending overlay trace.SCUS_942.54 0x8007123C (98 u16 increments, L1→2 = 50). LevelUpBanner renders for 180 frames via level_up_draws_for(). Per-character HP/MP growth curves pending level-up overlay capture.StrInit / StrMode). MDEC decoder: VLC → IDCT → BT.601 YCbCr→RGBA. Clean-room port in crates/mdec.FUN_801E295C — the layer between “player picked Attack” and “HP has been deducted.” Two-level dispatch: action category (party byte) plus execution phase (ctx byte).FUN_800402F4; spirit damage is hard-coded; MP cost is ability-bit modified; engine-vm port lives in battle_formulas.rs.The clean-room port
engine-core::title::TitleSession drives the FadeIn → PressStart → MainMenu → Done loop with a no-save fallback path; engine-core::save_select::SaveSelectSession covers the slot-list UI with Browsing / ConfirmLoad / ConfirmOverwrite / ConfirmDelete phases. Both are renderer-agnostic and emit typed event streams; legaia-engine title + save-select drive them headlessly with scripted input.engine-core::encounter: EncounterTable holds the per-scene rows + trigger rate + safe zones; EncounterTracker rolls per-step against the table with rate-bias hooks for accessory effects; EncounterSession brackets the transition with Idle → Transition → Triggered → Battling → Grace phases so engines render the camera-shake / fade chain.engine-core::target_picker::TargetPickerSession drives the post-action target cursor. Parameterised on a TargetKind (SingleEnemy / SingleAlly / SingleAllyOrSelf / DeadAlly / AnyAlly / AllEnemies / AllAllies / Self_) with skip-self / dead-only logic baked in; sweep targets resolve immediately, single-target picks walk valid candidates with cursor-wrap.engine-core::equipment::EquipmentCatalog ships ~30 vanilla entries covering weapons (character-locked: Vahn-only swords, Noa-only knuckles, Gala-only quarterstaves), helmets, body armor, hand guards, boots, rings, and accessories. Per-entry ItemModifier with stat deltas + ability-bit packing; to_modifier_table hands the resolved table to compute_battle_stats.engine-core::seru_learning: SeruRegistry maps Seru ids to spell ids + capture-point grants; SeruCaptureLog tracks per-character per-Seru running totals with a learnable_mask filter; record_capture is the pure resolver that fires LearnEvents when a character crosses the threshold; SeruCaptureSession drives the post-capture banner sequence ("Captured: Spark!" → "Vahn learned Spark!").engine-core::tactical_arts_editor: ChainLibrary stores up to 8 saved chains per character (3..=7-byte sequences, retail-faithful caps). ChainEditor drives the menu-side editor — Browsing → Editing → Naming → Done — emitting typed events for each direction push, pop, and commit. Engines feed picks back to BattleRunner::push_chained_art at battle start.crates/save/src/ext.rs: backward-compatible v1 prelude plus an extension block (magic LGX2) carrying play-time, the active-party slot list, per-character CharSaveExt rows (learned-arts mask, learned-spell list, per-Seru capture totals, four active-chain quick slots), and the cross-character saved-chain library.engine-render::dialog_box_draws_for takes the dialog panel's typed glyph stream + a DialogBoxLayout (origin / size / padding / line height / column count) and emits TextDraws with per-glyph CLUT tinting via dialog_clut_color, greedy width-based wrap, and newline support; dialog_panel_draws_for is the engine-core compatibility shim.engine-core::field_menu::FieldMenuSession: seven-row pause panel (Items / Equip / Spells / Arts / Status / Save / Config) with a FieldMenuRowMask for per-row enable/disable; Cross enters a sub-session, the shell pushes the matching tracker, and resume(close) hands control back. Wired into play-window so Start in field opens the menu.engine-core::field_menu_dispatch::FieldMenuSubsession wires every FieldMenuRow selection to its real session: InventoryUseSession, EquipSession, SpellMenuSession, ChainEditor, StatusScreenSession, SaveSelectSession, OptionsSession. build(row, world, options, slots, library, catalog, table) constructs the matching session from world state; tick_pad_edge(pressed) routes edge-triggered pad input. Apply helpers (apply_equip_outcome, apply_inventory_outcome, apply_spell_outcome, apply_arts_outcome) drain finished sub-sessions back into World. play-window's BootUiState::FieldMenu uses this — Cross on a row pushes the right session, control returns to FieldMenuSession::resume when the sub finishes.engine-core::status_screen::StatusScreenSession: per-character snapshot (level / XP / HP / MP / AP / six base stats / equipment list / element ranks). L1/R1 cycle through party members; the StatusSnapshot view is plain data so engines populate from the live record without coupling to the renderer.engine-core::spell_menu::SpellMenuSession: out-of-battle casting flow (CharSelect → SpellSelect → TargetSelect). is_field_usable filters out battle-only effects (Damage / Capture / Escape / Buff); dead-target / Revive constraints are validated; resolution flows through the existing spells::cast_spell resolver and surfaces a typed SpellMenuOutcome.engine-core::options::OptionsSession: typed OptionsState (BGM / SFX volume 0..=10, message speed 1..=8, vibration on/off, audio stereo/mono) with in-place editing. engine-core::key_rebind::KeyRebindSession drives the per-button rebind table; pressing any key in AwaitingKey swaps the binding into input::Mapping while clearing any conflicting key so two keys never report the same pad button.engine-core::game_over::GameOverSession: three-row Continue/Retry/Quit panel with a fade-in counter; Continue dimmed when no save data is present. Engines invoke this on a BattleSession::Defeat outcome and route Continue back through SaveSelectSession.legaia_font::Font::placeholder ships a hand-rolled 5×7 ASCII bitmap font in legaia_font::builtin so HUD text renders legibly even before font-extract populates the real dialog atlas. Printable-ASCII range plus a hollow-box unknown glyph; non-ASCII bytes still distinguish from missing glyphs.