Engine reimplementation
A clean-room Rust port of Legend of Legaia. End-user model: ship the engine as a binary; users supply their own disc image; the engine extracts assets at first run and plays the game using fresh Rust ports of every runtime subsystem.
How it works
"Clean-room" here means exactly what it means in the ScummVM / OpenRCT2 / OpenMW / OpenLara projects: every line of code in the engine port is fresh Rust, written from format documentation + decompile-then-rewrite logic, not by auto-translating MIPS assembly. We read the Ghidra dumps to understand what each function does; we write the Rust to do that thing idiomatically. The decompiled C in ghidra/scripts/funcs/*.txt is reference material, not committable engine code.
The legal posture: zero Sony bytes ship in the repo or in any released binary. No game executable, no asset data, no decompressed Sony strings, no decompiled-C dumps with literal data — all gitignored. The engine binary is empty until you point it at your own disc image. CI runs without disc data, so disc-dependent tests skip when LEGAIA_DISC_BIN is unset. This is the same model used by the projects above and is well-established legal territory.
Goal & non-goals
Goal: a playable port of Legend of Legaia (NA SCUS-94254) on modern systems via Rust + wgpu, with optional WASM/web target. JP/EU regions land after NA is solid.
Non-goals:
- Improving the game (no HD remaster, no balance changes, no QoL beyond what the original supported).
- Modding kit (useful as a side-effect, not as a designed deliverable).
- Translation work.
- Static recompilation of
SCUS_942.54. The engine is clean-room from documented specs and decompile-then-rewrite logic — not auto-translated MIPS.
Architectural principles
- Asset crates stay engine-agnostic.
crates/tim,crates/tmd, etc. don't depend on wgpu / SDL3 / cpal. They produce typed in-memory representations; the engine layer turns those into GPU resources / audio buffers. - Mockable I/O for tests. The disc read path is abstracted via
crates/iso::RawDisc; the same pattern extends to file-system extraction so tests can run without a disc. - Deterministic gameplay. RNG seeded from a known value; physics tick on a fixed timestep. Required for any future TAS / verification work.
- No "fix the bug" temptation. If the original game has quirky damage rounding or oddly-timed cutscenes, replicate them. Behavioural fidelity is in scope; QoL is not.
- Behaviour tests against runtime traces. Long-term, capture inputs + RNG + frame outputs from the original game, replay through the engine, diff. The asset-viewer phase landed enough infrastructure to make this possible later.
Crate layering
iso ← (none)
prot → iso (conceptual)
lzs ← (none)
asset → lzs, prot
tmd ← (none)
tim ← (none)
xa ← (none)
vab → xa (shares SPU-ADPCM F0/F1 filter constants)
mdt ← (none)
mes ← (none)
anm ← (none)
extract → all of the above
engine-core ← (none)
engine-render → engine-core
engine-audio → engine-core
engine-vm → engine-core
asset-viewer → engine-*, all parser crates
A future sound crate (sequencer playback for .spk sequences and the .dpk / .MAP / .PCH family) would depend on vab. A future battle / menu module belongs inside engine-vm next to the actor + field VMs rather than as a separate crate.
Phase 1 — asset viewer (de-risks integration)
A standalone binary that loads the disc, lets the user navigate PROT entries, and renders / plays them. Render API: winit + wgpu (Vulkan / Metal / DX12 / WebGPU backends). Audio: cpal-backed mixer.
Implemented
| Crate | What's there |
|---|---|
engine-core | Vfs trait + three backends: DirVfs (extracted-dir), DiscVfs (reads PROT.DAT / CDNAME.TXT directly from a .bin ISO9660 tree, no extraction step needed), MemoryVfs (WASM in-memory). AssetCache, FrameTime. SceneHost::open_disc(path) bootstraps the engine from a disc image; BootSession::open_disc(path, cfg) wraps it for the runtime. Every legaia-engine subcommand (info, list-scenes, play, play-window, save) accepts --disc PATH as an alternative to --extracted-root. Engine-agnostic, no GPU deps. |
engine-render | Renderer (wgpu device + surface + textured-quad pipeline + flat / textured-mesh pipelines + lines pipeline). Aspect-preserving letterbox. Software PSX VRAM emulation (1024×512 R16Uint, per-prim CBA/TSB + 4/8/15bpp + CLUT decoded in fragment shader). |
engine-audio | AudioOut (cpal-backed) + clean-room PSX SPU model (24-voice mixer, streaming ADPCM, ADSR, 512 KB SPU RAM, libspu-shaped transfer engine). VabBank::upload drops VAB bodies into SPU RAM; play_note translates a MIDI key into voice config + key-on. Sequencer drives a SEQ + VAB pair from the cpal callback. |
asset-viewer | winit binary with subcommands: tim, tmd, stage, vab, prot. |
The PROT browser dispatch handles tim_passthrough, tim_pack, data_field_streaming, scene_tmd_stream, scene_vab_stream, and a VAB byte-search fallback for any class with embedded banks.
Open Phase 1 milestones
- XA stream playback (streaming voice in
engine-audio). - Multi-voice mixer (the PSX SPU runs 24 voices; current mixer plays one).
- ADSR shaping for VAB tones.
- Per-vertex normals from the TMD per-object normal table (currently the renderer derives normals via screen-space derivatives, which is flat-shading).
Phase 2 — runtime port
Port the script VM, field-loader chain, and effect VM. Handler-by-handler translation: dump each opcode handler from Ghidra, hand-port to Rust, unit-test against captured runtime traces. Aim for behavioural fidelity per opcode, not byte-exactness of the VM internals.
Implemented
- Actor VM —
crates/engine-vm/src/lib.rs. All 13 opcodes ported, full unit-test coverage. Drives the title screen sprite cluster. - Field VM —
crates/engine-vm/src/field.rs. All 43 explicit opcodes ofFUN_801DE840are ported with aFieldHosttrait abstracting every SCUS callback. Cross-context dispatch (extended-bit prefix), YIELD caller-propagation,Op49Statetristate (with the inline-MES walker for sub-0), the0x4Couter-nibble dispatcher, the0x38halt-acquire path, and the0x5x/0x6x/0x7xdefault-route fourth-flag-bank dispatchers are all wired. - Move VM —
crates/engine-vm/src/move_vm.rs. All 71 main opcodes (0x00..0x46) ofFUN_80023070ported, plus the0x2Fextension dispatcher (61 sub-opcodes viaFUN_801D362C). Per-frame entry isactor_tick, mirroring the gate atFUN_80021DF4 + 0x80022B94. - Motion VM —
crates/engine-vm/src/motion_vm.rs. All 6 opcodes ported including the 12-bit fixed-point angle-math opcodes0x38RotateToAngleand0x4CFaceTarget. - Effect VM —
crates/engine-vm/src/effect_vm.rs. Slot pool (32 master + 128 child slots),Pool::init/Pool::spawn/Pool::tickports ofFUN_801DE914/FUN_801DFDF8/FUN_801E0088,Pool::spawn_by_ui_id+EffectCatalogfor UI-element routing. - Battle action state machine —
crates/engine-vm/src/battle_action.rs. Port ofFUN_801E295C(16 KB, the largest function in the battle overlay) as a per-frame edge-triggered state machine across 47 explicit states in 7 bands. Attack chain firesapply_damageat the swing-apex byte. The Tactical-Arts strike band additionally callsapply_art_strike(ArtStrikeInfo)with the per-strike power byte, dmg_timing, status effect, and hit cue resolved from the active actor's chosen art viaBattleActionHost::art_record. - Composite world / actor system —
crates/engine-core/src/world.rs.Worldowns the actor table, battle ctx, effect pool, field-VM ctx, per-actor move-VM buffers, shop/inn/level-up session state, tactical-arts tracker, and ANMAnimPlayerinstances.World::tickdrives all of them in order per frame. - Clean-room SPU mixer —
crates/engine-audio/src/spu/. 24-voice SPU model with streaming ADPCM, ADSR, 512 KB SPU RAM, libspu-shaped transfer engine. BGM cross-fade (30-frame volume ramp) and sequencer pause gating. WASM path usesWebAudioOut(ScriptProcessorNode). See audio.
Phase 3 — gameplay assembly
All major gameplay systems are wired into engine-core::World and driven from engine-shell::BootSession.
- Shop / Inn / Level-up —
ShopSession,InnSession,LevelUpTrackerinengine-core.MenuRuntimeroutes buy/sell/quantity/confirm/exit through session state; HP/MP restore wired on inn commit; XP distribution firesBattleEvent::LevelUpper character per level.LevelUpBanner(180-frame countdown, same shape asArtLearnedBanner) set byapply_battle_xp, ticked byWorld::tick.level_up_draws_for()inengine-renderproduces a two-line yellow/green overlay (title + HP/MP gains); wired intoplay-windowHUD at anchor(8, 60). Exact XP and per-level stat tables remain placeholder until full level-up overlay capture. - Tactical Arts learning UI —
TacticalArtsTrackertracks per-char / per-art use counts;ArtLearnedBannercounts down inWorld::tick;BattleEvent::TacticalArtLearnedfires when the threshold is crossed. - Status effects —
crates/engine-vm/src/status_effects.rstracks the eight retail conditions (Burned / Shocked / Poisoned / Asleep / Confused / Silenced / Stunned / Petrified) with per-instance turn counters and damage-over-time formulas (Burned =max_hp / 16, Poisoned =current_hp / 8).World::tick_status_effectsfolds tick damage intoBattleActor::hp;fold_battle_eventpushesEnemyEffectbytes from art strikes into the tracker. - AP / Spirit gauge —
crates/engine-core/src/ap_gauge.rsmodels the per-character AP budget (base 4, +1 per 10 levels capped at 10) plus the +5 Spirit-press bonus.art_ap_cost(action)mirrors the per-action-byte cost table; the world carries[ApGauge; 3]and resets all three at turn start. - Battle stat aggregator —
crates/engine-core/src/battle_stats.rs. Clean-room port ofFUN_80042558: walks 8 equipment slots, sums per-item modifiers (EquipmentTable), ORs ability bits into a 256-bit mask, folds in status-effect modifiers (Burned -ATK, Confused halves accuracy, immobilising statuses zero evasion, Silenced / Petrified block Magic). - Item catalog —
crates/engine-core/src/items.rs. TypedItemEffectenum (Heal / Cure / Revive / StatBoost / Spirit / Capture / Escape / Damage / KeyItem);apply_effect(effect, &TargetSnapshot) -> ItemOutcomeresolves the side-effect pure-functionally. Vanilla catalog ships 19 entries.World::use_item(item_id, target_slot)wraps the resolver and folds outcomes back into world state — HP / MP gains capped at the actor's max, status cure / cure_all clears the matching tracker entries, Spirit-restore items refund AP viaApGauge::refund. - Battle round lifecycle —
crates/engine-core/src/battle_round.rs.BattleRound::begin(&mut world, &[StatRecord; 8], &EquipmentTable, &StatusModifiers)orchestrates per-round bookkeeping: resets every party AP gauge, recomputes per-slotBattleStats, and writes the resolved attack / UDF / LDF back intoWorld::battle_attack/battle_defense_split.BattleRound::end(&mut world)ticks every actor's status, drains tick damage intoBattleActor::hp, and returns the death count. The returnedBattleRoundcarriesaction_blocked/magic_blockedarrays the action validator filters command input against. - Per-actor animation runtime —
crates/engine-vm/src/anim_vm.rs.AnimRuntime::with_slots(N)manages a fixed actor pool that wrapsAnimPlayerfor the keyframe path and surfaces aHost::on_opaque_recordhook for record-level side-effects (sprite swaps, voice cues). Per tick the runtime emits anAnimEventstream (PoseUpdated/OpaqueTick/Finished/Replaced) so engines drive renderer / SFX side effects without polling per-actor state. - Per-actor physics tick —
crates/engine-vm/src/actor_tick.rs. Layered port ofFUN_80021DF4(4732 bytes, 1183 instructions).ActorPhysicsmodels the retail actor record's tick-relevant fields with offset annotations;tick_actor(physics, scalars, listener)runs the dispatch ladder. Each dispatch byte (0x01..=0x07) selects a layered subset of side-effects: common pre-update, keyframe accel (0x02/0x06), positional SFX emitter (0x05), path interpolation (0x03), default movement (every byte except0x05), and the common late-update (env clamps, render submissions, keyframe pose write for0x06). Cross-cutting effects surface asTickEvententries (SfxUpdate/SfxRelease/SplineDraw/DampDraw/MoveVmKick/UnlinkRequest/KeyframePoseWritten) so engines drive their audio mixer / scene graph / move-VM driver from a single typed event stream. - Battle command runner —
crates/engine-core/src/battle_runner.rs.BattleRunnersits between player input and the action SM:begin_rounddelegates toBattleRound::beginfor AP refresh + stat recompute,push_command/push_chained_artgate input againstApGauge,commit_turnresolves the per-slot queue throughresolve_action_queue(Miracle / Super expansion), andend_rounddrivesBattleRound::endfor tick-damage drainage. Per-slot buffers + chained-art lists let the player switch between party members mid-turn without losing state. - Battle HUD model —
crates/engine-core/src/battle_hud.rs. Renderer-agnosticBattleHudholds per-slot HP / MP / AP / status icons, a queue ofDamagePopups with fade timers, and a ringed log column.engine-render::battle_hud_draws_forturns it into aVec<TextDraw>; engines feed the HUD fromBattleEvent::ApplyArtStrike(popups),StatusEvent(icons), andBattleRound::begin/end(slot panels). - Inventory item-use session —
crates/engine-core/src/inventory_use.rs.InventoryUseSessiondrives the "open inventory → pick item → pick target → use it" flow shared between the field menu and the battle command menu. Filters items byInventoryContext(battle vs field), validates target compatibility (Revive needs a dead target; everything else needs a live one), and folds the resolvedItemOutcomeinto world state viaWorld::use_item. - SFX bank + scheduler —
crates/engine-audio/src/sfx.rs.SfxBankmaps cue IDs (theHitCue::kindbyte from art records, plus engine-extended slots for menu blips / footsteps) to per-cueSfxEntrydescriptors that delegate toVabBank::play_note.SfxScheduler::tick_framedrains a queue ofPendingCues with retail-styletiming_framesoffsets so cues fire on the correct anim frame relative to the strike. - Menu sub-screens —
MenuRuntimehandlesStatusCharacter/StatusEquipment/StatusInventorywith cursor input, data-view methods, and commit side-effects (unequip slot, decrement inventory item). - Save / load —
LGSF v1self-describing binary (magic + story_flags + money + inventory pairs + party records);World::save_full/load_full; memory-card writeback vialegaia_save::card::write_block. - BGM + audio —
AudioBgmDirectorcross-fades between tracks over 30 frames; sequencer pause gating;input::Mappingpersists key bindings to TOML. - Windowed engine binary —
legaia-engine play-windowopens 960×720 via winit;play-strplays back PSX STR + XA in a window;config set --bindingedits key maps. - WASM disc-bytes Vfs —
MemoryVfs,Archive::from_bytes,SceneHost::from_prot_bytes, andLegaiaRuntime::load_disc/enter_scene/disc_loadeddrive the in-browser engine from uploaded disc bytes. The viewer “Run engine” tab wires it in JS. - Region support —
legaia_prot::Regionenum (NA / EU / JP);ProtIndex::with_region(); documented indocs/reference/builds.md.
Open Phase 3 items
- Shop / inn exact item-price data pending shop overlay capture; current prices are synthetic placeholders.
- Exact XP / stat-gain tables from the level-up overlay (current placeholder: 100×n² curve); banner render layer is wired, only the tables need the capture.
- Scene-init ANM binding per-actor (blocked on tracing the
0x8007C018pointer-table registration order). legaia-engine play --scene cutsceneNPROT-scene routing (directplay-str <file>works; scene-entry routing pending STR-entry trace).- Exact field-VM WARP map_id → scene-name table (7 destinations; pre-WARP handler that sets
DAT_80084548not yet traced;DefaultMapIdResolveruses CDNAME sequential order as approximation).
Provenance + memory hygiene
The decompiled C dumps under ghidra/scripts/funcs/ are reference material. Engine code in crates/engine-vm/ is fresh Rust written from the decompile — never paste, always rewrite from the documented spec.
Per-opcode tests live next to the port; they use synthetic bytecode (no Sony bytes) so the test suite stays clean-room.