Legend of Legaia — reverse engineering & clean-room engine port
Two coordinated tracks under one repo. Track 1: extract every asset on the disc, document every format with provenance back to a Ghidra function, build round-trip parsers. Track 2: a clean-room Rust engine port that consumes the extracted data, in the same legal pattern as ScummVM, OpenRCT2, OpenMW, and OpenLara. Bring your own disc image; the toolkit handles the rest.
Start here
Pick the track that matches what you want to do.
Where we are
The disc is fully readable. Every major subsystem is traced and documented. All five runtime VMs are ported (actor, move-table, motion, field/event, effect) plus the battle action state machine. The combat-stat machinery is decoded. The asset preservation track ships round-trip parsers for every documented format.
save-tool.FUN_8003774C), Effect VM (battle effect pool), and the battle action state machine all ported. battle_formulas ships damage / MP-cost / accuracy / RNG kernels. Composite World in engine-core wires them together with a scene host, dialog panel, and BGM director. Clean-room SPU mixer + sequencer drive playback.BattleEndCause via the formula damage path). The encounter system at crates/engine-core/src/encounter.rs brackets random battles — EncounterTracker holds the per-scene EncounterTable (weighted formation rows, trigger rate in 1/256, optional safe-zone rectangles, suppression / rate-bias hooks for accessory effects) and EncounterSession drives the Idle → Transition → Triggered → Battling → Grace phase chain so engines render the camera-shake / fade / battle-load sequence with retail timings. Title screen (title.rs: TitleSession: FadeIn → PressStart → MainMenu → Done) plus a save-select state machine (save_select.rs: SaveSelectSession with Browsing / ConfirmLoad / ConfirmOverwrite / ConfirmDelete phases) close the boot-time UI loop. Target picker (target_picker.rs: TargetPickerSession) drives the post-action "who does this hit?" cursor, parameterised on a TargetKind enum (SingleEnemy / SingleAlly / SingleAllyOrSelf / DeadAlly / AnyAlly / AllEnemies / AllAllies / Self_) with skip-self / dead-only logic baked in. Equipment catalog (equipment.rs: EquipmentCatalog) ships ~30 vanilla entries covering weapons (Vahn-only swords, Noa-only knuckles, Gala-only quarterstaves), helmets, body armor, hand guards, boots, rings, and accessories with character-restriction flags + ability-bit-packed modifiers; to_modifier_table hands the resolved ItemModifier table to compute_battle_stats. Seru capture / spell learning (seru_learning.rs: SeruRegistry + SeruCaptureLog + record_capture + SeruCaptureSession) tracks per-character per-Seru capture points, accumulates them across the eligible party, fires LearnEvents when a character crosses the threshold, and surfaces a banner-driven UI session that engines render between captures and the post-battle resolution phase. Tactical Arts chain editor (tactical_arts_editor.rs: ChainLibrary + ChainEditor) drives the menu screen where the player composes 3..=7-byte command chains, names them, and saves them to per-character libraries (max 8 per character, retail-faithful). Save format extended to LGSF v2 at crates/save/src/ext.rs: backward-compatible v1 prelude plus an extension block carrying play_time_seconds, 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 — v1 readers stop at the v1 boundary; v2 readers consume both. Renderer-side dialog presenter (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, greedy width-based wrap, and newline support — dialog_panel_draws_for is the convenience wrapper for engines that already have DialogPanel::page_glyphs in hand. The BattleSession orchestrator at crates/engine-core/src/battle_session.rs composes runner + round + HUD + status tracker into a single phase-driven SM (RoundIntro → CommandInput → Resolve → RoundOutro → Victory/Defeat/Escaped), forwards admissible inputs to the runner, drains world events into the HUD, and runs end-of-round bookkeeping including wipe detection. The BattleRunner at crates/engine-core/src/battle_runner.rs sits between input and the SM — begin_round / commit_turn / end_round bracket each turn; push_command gates input against per-character ApGauge; commit_turn resolves the queue through Miracle / Super expansion before handing it to the SM. The BattleHud at crates/engine-core/src/battle_hud.rs is a renderer-agnostic UI model fed by BattleEvent::ApplyArtStrike (popups), StatusEvent (icons), and the round lifecycle (slot panels); engine-render::battle_hud_draws_for turns it into TextDraws. The InventoryUseSession at crates/engine-core/src/inventory_use.rs drives the "open inventory → pick item → pick target → use it" flow shared between field and battle menus, validates target compatibility, and folds the resolved ItemOutcome through World::use_item. Menu state machine + disk-backed save / load wired through engine-core::menu_runtime::MenuRuntime with per-slot files. Shop and inn session state (ShopSession, InnSession) wired into the menu runtime; the play-window HUD renders the cost prompt and Yes/No cursor for both. Level-up XP distribution (LevelUpTracker) fires BattleEvent::LevelUp per character; LevelUpBanner (180-frame countdown) drives a two-line yellow/green overlay in play-window via level_up_draws_for(). Tactical Arts learning tracker (TacticalArtsTracker, ArtLearnedBanner) follow the same pattern. Status-effect ticker (StatusEffectTracker) handles the eight retail conditions with per-instance turn counters and damage-over-time formulas; World::tick_status_effects folds tick damage into the actor's HP. AP / Spirit gauge (ApGauge) models the per-character action-point budget plus the +5 Spirit-press bonus. Battle stat aggregator (compute_battle_stats) is a clean-room port of FUN_80042558 — sums equipment modifiers, ORs ability bits, folds in status-effect deltas. Item catalog (ItemCatalog + apply_effect) ships a typed catalogue with 19 vanilla entries covering Heal / Cure / Revive / StatBoost / Spirit / Capture / Escape / Damage. Spell catalog (SpellCatalog + cast_spell) at crates/engine-core/src/spells.rs mirrors the item shape for the magic-side command flow: a typed SpellEffect enum (Damage / Heal / HealAll / Cure / CureAll / Revive / Buff / Capture / Escape) with a pure resolver that consumes a per-target SpellSnapshot; the vanilla() constructor pre-populates 17 entries covering 5 healing spells, 7 elemental damage spells, 4 buff/debuff spells, and 2 utility spells (Reseal / Warp). Equipment session (EquipSession) at crates/engine-core/src/equip_session.rs drives the pick-slot → pick-item → confirm flow with a preview_stats projection; commit re-runs compute_battle_stats through the equipment + active-status pipeline. The BattleRound orchestrator at crates/engine-core/src/battle_round.rs ties it all together — begin() resets per-party AP, recomputes equipment-aware stats, writes attack / UDF / LDF into the world; end() ticks status, drains tick damage into HP, returns the death count. SFX bank + scheduler (engine-audio::sfx::SfxBank + SfxScheduler) maps cue IDs to per-cue note-on parameters; the scheduler queues retail-style timing_frames offsets and dispatches through VabBank::play_note on the right anim frame. Per-actor animation runtime (engine-vm::anim_vm::AnimRuntime) wraps AnimPlayer for the keyframe pose decoder and surfaces a Host::on_opaque_record hook for record-level side-effects. The per-actor physics tick (engine-vm::actor_tick::tick_actor) ports FUN_80021DF4 (4732 bytes, 1183 instructions) as a layered pipeline: dispatch byte at actor[+0x5A] selects which subset of side-effects fires (keyframe accel for 0x02/0x06, positional SFX emitter for 0x05, path interpolation for 0x03, default movement for every byte except 0x05, render submissions for 0x04/0x07); cross-cutting effects surface as typed TickEvent entries. Level-up XP thresholds are sourced from the retail table at SCUS_942.54 0x8007123C (per-level increments 50–656; cumulative to level 99 ≈34,663 XP). Per-character HP/MP stat gains remain placeholder until the level-up overlay binary is captured. Per-scene item costs remain placeholder until the shop overlay is captured. The 16-arm action validator (FUN_8003FB10) is ported as engine-vm::action_validator. The motion VM (FUN_8003774C) is ported as engine-vm::motion_vm and feeds a runtime Camera in engine-core. ANM playback driver (legaia_anm::AnimPlayer) drives per-bone keyframe interpolation each frame.legaia-engine binary (crates/engine-shell) is the canonical "boot a scene from PROT bytes alone" entry point. Subcommands: info (one-line scene-asset summary), list-scenes (CDNAME map walker), play (tick the engine for N frames against a scene, drive the camera + BGM director + per-actor move VMs, log scene transitions), play-window (windowed scene-play with rendering + input; with --boot-ui the player boots through the title screen, picks New Game / Continue / Options, and on Continue routes through a save-select panel that hydrates the World from a slot file before the scene becomes interactive), play-str (windowed STR/MDEC FMV player), save / load (round-trip a slot file), battle (drive a synthetic BattleSession end-to-end with a scriptable input stream — reports per-frame SessionEvents for inspection), inventory (drive an InventoryUseSession against a synthetic World), equip (drive an EquipSession through pick-slot → pick-item → confirm and report the post-commit BattleStats), title (drive a synthetic TitleSession with scripted input and report the resolved menu pick), save-select (drive a SaveSelectSession against synthetic slot snapshots), encounter (roll a synthetic EncounterSession against a small table for N steps and report the first triggered formation), target-pick (drive a TargetPickerSession through cursor moves to a confirmed slot), chain-editor (drive a ChainEditor through compose → name → save), seru-capture (run the vanilla Seru registry through N captures and report the resulting per-character spell-learn events), gte-replay (load a JSON cop2 trace and replay it against a fresh Gte emulator, surfacing per-step register divergence). BootSession in the same crate is the embedding API: SceneHost + Camera + AudioBgmDirector wired together; the play subcommand is a thin wrapper around it.mc0–mc5 with high-value capture pairs targeting blocked decompilation work. Slot map (new): mc0 = Rim Elm town residency (CDNAME town0c); mc1 / mc2 = pre-encounter walking map01 vs. battle just initiated from the same scene; mc3 = Drake Castle battle (move-table boot path); mc4 / mc5 = battle command menu before vs. after using Fire Book I on Vahn. Slots 6–9 are unchanged. Encounter trigger pinned (mc1 → mc2): the mednafen-state diff over 0x801C0000..0x80200000 surfaces a single 133 KB region at 0x801CE808..0x801F3818 where the battle overlay loads on encounter trigger. The 8-slot battle actor pointer table at 0x801C9370+ populates with stride 0x60; the active scene-name table at 0x80084540 does not change — battle is layered on top of the field scene rather than swapped for it. Codified in new crates/engine-core/src/capture_observations.rs module under encounter_trigger; disc-gated test encounter_trigger_diff_loads_battle_overlay in crates/mednafen/tests/real_saves.rs exercises real save bytes. Fire Book I write footprint pinned (mc4 → mc5): inside Vahn's character record (0x80084708..+0x414) exactly one 3-byte cluster differs at +0x185..+0x188: 01 0C 00 → 02 03 0C. Pattern is a length-prefixed list growing by one entry, with the new entry inserted at position 0 and the existing entry shifted right. The byte values do not match retail learned-art constants (those occupy 0x1B..=0x32); two consistent interpretations remain (transient command-history buffer; or per-character recent-action buffer). The field is treated as pinned but uninterpreted in capture_observations::vahn_fire_book_use; precise interpretation pending a reader-search of the captured battle-action overlay. Disc-gated test fire_book_use_diff_pins_vahn_record_write asserts exactly one record-internal region at the documented offset. Town residency reference: town01_save_documents_active_scene_label records mc0's scene index 0x15 / CDNAME town0c for cross-scene RAM-layout diffs. scripts/mednafen/scenarios.toml rewrites slots 0–5 with the new labels + watchpoint regions; docs/tooling/mednafen-automation.md + docs/subsystems/battle.md + docs/subsystems/level-up.md + docs/formats/field-pack.md all extended. Total disc-gated tests: 6 → 10 (+4); new lib tests in capture_observations: +5.FieldMenuOutcome::Confirmed(row) in play-window dropped to Inactive instead of pushing the matching sub-session. This batch lands the dispatch. Sub-session dispatcher (crates/engine-core/src/field_menu_dispatch.rs): new FieldMenuSubsession enum holds the seven possible sub-sessions; FieldMenuSubsession::build(row, world, options, slots, library, catalog, table) constructs the matching sub from world state; tick_pad_edge(pressed) routes edge-triggered pad input into the right per-button bundle; apply helpers (apply_equip_outcome / apply_inventory_outcome / apply_spell_outcome / apply_arts_outcome) drain finished sub-sessions back into World. play-window field-menu wiring: BootUiState::FieldMenu now carries { session, sub: Option<FieldMenuSubsession> }. When the menu transitions to Suspended { row }, the shell builds the sub via the dispatcher; per-frame input flows into the sub instead of the menu; on is_done(), the shell calls the matching apply helper and session.resume(false) to drop back into Browsing. Save sessions write through MenuRuntime::save_to_slot; Config sessions update the persistent OptionsState. Cue / bin disc support (crates/iso/src/raw.rs): RawDisc::open now accepts a .cue sheet alongside the previous .bin-only path. New free function resolve_disc_path(path) reads the cue's first FILE "<name>" BINARY line, resolves it relative to the cue's parent, and returns the resolved bin. play-window --disc /path/to/Legaia.cue now works; previously failed with "failed to fill whole buffer" because the engine tried to parse the cue text as ISO9660. Inverse XP helper: legaia_save::xp_for_level joins level_for_cumulative_xp as the inverse direction lookup. End-to-end integration test at crates/engine-core/tests/field_menu_subsession_e2e.rs drives every row through the dispatch flow. Total tests: 1984 → 2015 (+31).crates/engine-core/src/field_menu.rs): seven-row pause panel (Items / Equip / Spells / Arts / Status / Save / Config) with per-row enable/disable mask; Cross enters a sub-session, the shell pushes the matching tracker, and resume(close) hands control back to the field tick or closes the menu entirely. Status screen (status_screen.rs): per-character stat detail snapshot (level / XP / HP / MP / AP / six base stats / equipment list / element ranks); L1/R1 cycle through party members; pure-data StatusSnapshot view so engines populate from the live record. Spell menu (spell_menu.rs): out-of-battle casting flow (CharSelect → SpellSelect → TargetSelect) that filters out battle-only effects (Damage / Capture / Escape / Buff) via is_field_usable, validates dead-target / Revive constraints, and resolves through the existing spells::cast_spell resolver. Options session (options.rs): typed OptionsState (BGM / SFX volume 0..=10, message speed 1..=8, vibration on/off, audio stereo/mono) with in-place editing (left/right adjusts the current row); revert_if_cancelled snaps state back to the entry snapshot. Key rebind session (key_rebind.rs): per-button rebind table reachable from the options screen; 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. Game-over session (game_over.rs): three-row Continue/Retry/Quit panel with fade-in counter; Continue dimmed when no save data is present. Each session ships with a renderer overlay in engine-render (field_menu_draws_for, status_screen_draws_for, spell_menu_draws_for, options_draws_for, key_rebind_draws_for, game_over_draws_for) and is wired into play-window's BootUiState so Title → Options now opens the actual config screen instead of falling through, and Start in field opens the pause menu instead of the legacy menu_runtime path. Built-in fallback font: legaia_font::Font::placeholder previously emitted solid white blocks; the new builtin module ships a hand-rolled 5×7 ASCII bitmap font (printable range plus a hollow-box unknown glyph) so HUD text renders legibly even before font-extract is run. Total tests: 1913 → 1984 (+71).crates/engine-core/src/encounter_registry.rs ships EncounterRegistry with most-specific-first lookup (exact-label → substring fallbacks → global default) plus a vanilla_encounter_registry constructor that distinguishes towns / cutscenes (rate 0) from field scenes (default early-game table). World::install_encounter_for_scene(®istry, scene_label) is the engine-side entry point; play-window now calls it on boot, replacing the ad-hoc default_early_encounter_table + set_encounter_session chain. Per-Seru stat-growth API: new crates/engine-core/src/seru_stats.rs ships SeruStatGrant (HP / MP / SP + six u16 stat deltas mapped to char-record +0x11C..+0x126) and SeruStatTable (id-keyed roster). LevelUpTracker::with_seru_roster(slot, &table, roster) installs a curve summed across the equipped Seru. Writer-search across the captured level-up overlay returned negative for code-side sb / sh writes targeting the documented record offsets — the per-Seru lookup table itself comes from a runtime read through Seru struct +0x74; the API scaffold lands now so the table can drop in once the runtime trace lands. Cumulative-XP accessor: CharacterRecord::cumulative_xp() reads the u16 at +0x04..+0x06 (pinned by the mc7→mc9 capture); top-level helper legaia_save::level_for_cumulative_xp infers the level via the retail XP table, replacing the hp_max / 20 stand-in in scan_save_dir. Cutscene map override: new CutsceneMap in crates/engine-core/src/scene.rs lets engines override the heuristic cutscene_str_for mapping when the FMV-cutscene overlay capture lands. scene_chain_e2e VAB probe: the assertion now also probes seq_in_stream_entries for VABp at chunk0 +4 (catches strict-detector border cases). Active-scene-label field: World::set_active_scene_label records the current CDNAME label so downstream consumers (HUD diagnostics, save snapshots) don't need to re-walk the SceneHost. Total tests: 1880 → 1913 (+33).BattleSession::push_command_with_target(world, cmd, kind, actor_slot) charges AP up-front, opens the target picker, and on TargetConfirmed writes the resolved slot into BattleActor::active_target (the field the action SM reads at strike time) and admits the buffered command into the runner queue without re-charging AP. Sweep targets write a 0xFF sentinel; cancellation drops the buffered command. New BattleRunner::push_no_ap(slot, cmd) is the post-confirm admit path. New open_target_picker_mut variant takes a mutable World so immediate-resolve kinds (sweep / self) write the active-target on the same call. Stat-growth captures: mednafen-state diff over the user's mc7..mc9 save triplet pins the per-byte footprint of a magic-rank-up + character-level-up event for Vahn (party slot 0) — spell-levels at +0x161, magic-rank counter at +0x9C, XP delta at +0x04 u16 LE, six per-stat increments at +0x11C..+0x12C, Spirit-max at +0x10E. Captured deltas surface as engine_core::levelup::observations::vahn_mc8_to_mc9(); LevelUpObservation::to_curve() emits a StatGrowthCurve::PerLevel vector that yields the per-level average inside the observed range and falls back to the default outside it; LevelUpTracker::with_observed_curve(slot, &obs) installs one observation per slot. Two disc-gated tests (level_up_diff_pins_captured_offsets_for_vahn_record, magic_rank_up_diff_pins_spell_level_offset) at crates/mednafen/tests/real_saves.rs assert the diff against the actual save states. CDNAME → MV cutscene routing: engine_core::scene::cutscene_str_for(scene) resolves op* / edteien labels to MOV/MV1.STR..MOV/MV6.STR; the legaia-engine play and play-window subcommands auto-resolve the STR file when the user passes --scene <cutscene> and the matching MV file exists in the extracted root. Encounter rate dropped from 8/256 to 5/256 (~1 in 51 steps) for retail-faithful "outskirts of Rim Elm" pacing. Total tests: 1862 → 1880 (+18).crates/engine-core/src/monster_catalog.rs): MonsterDef / MonsterCatalog / FormationDef / FormationTable with vanilla constructors shipping ~20 early-game monsters (Goblin, Wolf, Bandit, Slime, Skeleton, Killer Bee, Lizard Man, Mole, Spike Mole, Stone Golem, Goblin King, Drake Wyrm, etc.) and 14 formations (single, pair, triple, boss). World encounter integration: World::on_field_step() (RNG-driven step roll), World::tick_encounter() (transition / grace timers), World::drain_encounter_formation(), World::end_encounter_battle(), World::set_formation_table(table, catalog). BattleSession sub-phase: SubPhase::CommandSelect | TargetPick with open_target_picker, cancel_target_picker, and typed SessionEvent::TargetConfirmed / TargetSweepConfirmed / TargetCancelled outcomes that surface to the engine. SaveExtV2 ↔ live World: World::save_full derives learned_arts_mask from TacticalArtsTracker, spells from the seru log, seru_captures + active_chains + saved_chains + play_time_seconds from world-side fields; World::load_full repopulates trackers (re-marks learned arts, re-seeds spell list) on reload. Renderer overlays (engine-render::title_draws_for, save_select_draws_for, encounter_banner_draws_for) cover the new pre-scene panels. New play-window --boot-ui flag drops the player at the title screen; pad input is edge-triggered so menu navigation doesn't auto-repeat. End-to-end integration test at crates/engine-core/tests/playable_shell_e2e.rs drives the synthetic World through every wired surface in one pass: title → save-select → encounter roll → formation resolution → battle-session target picker → save → reload round-trip. Total tests: 1839 → 1862 (+23).legaia-engine-vm + legaia-engine-core + legaia-engine-audio all compile to wasm32-unknown-unknown. The in-browser viewer exposes a minimal LegaiaRuntime bridge for ticking the World + driving the menu state machine from JS. load_disc in crates/web-viewer now accepts either a bare PROT.DAT or a full Mode2/2352 .bin disc image — it walks ISO9660 to extract PROT.DAT and CDNAME.TXT automatically, so end users can drop in their disc image directly.AnimPlayer; bytecode-style records still need an overlay capture). Scene-init ANM binding (which actor gets which ANM record) is blocked on tracing the 0x8007C018 pointer-table registration order. Shop overlay still uncaptured (fills in per-scene item costs). Level-up XP thresholds are now from the retail SCUS table; per-character HP/MP growth still needs the level-up overlay for exact values. The STR/MDEC FMV cutscene overlay (game modes 26/27) has not been captured; dump_str_fmv_overlay.py is prepared for when a savestate is available. World map and save-screen overlays are captured and documented; exact story-flags + inventory byte offsets within the retail save block remain unpinned: money (_DAT_8008459C) and inventory (DAT_80085958) RAM addresses confirmed, but serialisation path into the staging buffer has not been traced to a function with known offsets. Field-VM WARP map_id → scene-name: 7 destinations traced (PROT 0x4d + map_id code overlays); pre-WARP handler that writes the scene name to DAT_80084548 not yet captured; DefaultMapIdResolver uses CDNAME sequential order as an approximation. Final reverse of per-scene DATA_FIELD slot semantics inside field-pack containers (97-slot schema understood structurally; per-slot meaning pending a per-scene consumer trace). PROT-scene routing for legaia-engine play --scene cutsceneN (direct STR file playback via play-str works; scene-entry routing pending STR-entry trace).