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.

Disc + asset preservation
Mode2/2352 reader, ISO9660 walker, PROT.DAT TOC math, format detectors covering all documented PROT format families — verified by a disc-gated categorize_coverage test. Round-trip parsers ship for TIM, TMD, VAB (including multi-bank archives), SEQ (both u16 and u32 BE version variants), MES (partial), ANM (container + per-bone keyframe walker), MDT, LZS, plus the scene-bundle family. PSX memory-card walker round-trips Legaia save blocks via save-tool.
Static analysis
Function dumps committed for the bulk of the binary. Citation graph is closed-loop (all helper references in docs are backed by dumps). All five runtime VMs decoded plus the battle action state machine. All major SCUS subsystems traced.
Engine port — Phase 1
Asset viewer with PSX VRAM emulation, textured TMD rendering, stage geometry, VAB playback, SEQ + VAB sequencer, dialog typewriter, field-VM scene runner with dialog rendering, battle-scene SM driver. In-browser version live.
Engine port — Phase 2
Runtime port. Actor VM (13 ops), Field VM (43 ops), Move VM (71 + 61 ext sub-ops), Motion VM (per-actor pursue / patrol / face-target at 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.
Engine port — Phase 3
Gameplay assembly. Field-VM scene runner ticks real per-scene event scripts and renders dialog inline. Battle-action SM drives a working scene with party + monster slots end-to-end (synthetic battle resolves to 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.
Top-level shell
The 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.
Encounter registry + per-Seru API + cutscene-map override (fourteenth post-#26 batch)
Pure-code consolidation of the open scaffolding. Encounter registry: new 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(&registry, 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).
Action SM target wiring + stat-growth captures (thirteenth post-#26 batch)
Closes the wiring gap noted in the twelfth-batch session-recommendations: 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).
Playable shell wiring (twelfth post-#26 batch)
The eleventh batch shipped seven renderer-agnostic state machines (boot UI, encounter, target picker, equipment, seru learning, chain editor, LGSF v2). The twelfth batch wires them into the engine-shell so the player can boot through them end-to-end. Monster catalog + formation tables (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).
WASM target
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.
Open work
Per-record ANM bytecode for the residual non-keyframe-table records (the keyframe-table path is ported via 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).