Engine reimplementation
A from-scratch Rust port of Legend of Legaia in the ScummVM / OpenRCT2 mould: the original PlayStation code is traced with a disassembler, documented, and rewritten fresh - never recompiled. You supply your own disc image; the engine reads every asset from it and plays the game. Retail behaviour is the measured ground truth, provable against parity oracles, and enhancements ride on top as toggles with a retail-faithful mode always one flip away.
At a glance
- What it is
- A Rust runtime - boot, field, battle, menus, cutscenes, audio - reading the original disc for every asset
- What it is not
- A recompile of
SCUS_942.54, a re-authored asset set, or a build that loses the retail-faithful mode - Hosts
- Native
legaia-engine play-window(winit + wgpu), the browser play page (WASM, same engine), the site’s minigame page - Crates
engine-vm(the VMs) ←engine-core(the world) ←engine-shell(the driver);engine-ui/engine-render/engine-audioare presentation leaves- Faithful vs enhanced
- Shading defaults to retail, rasterisation defaults to clean; every enhancement is bit-identical to retail when off
- Proof
- Save-hash scenarios, VRAM diff against save-state captures, mode / audio traces, record-replay determinism
- Legal posture
- Zero Sony bytes in the repo or any binary; disc-dependent tests skip when
LEGAIA_DISC_BINis unset
Runtime architecture
One binary, one session, one world. The shell boots a session, the session drives the mode table, the mode hands the scene host a world to tick, and the world drives every VM by implementing its Host trait. Rendering and audio hang off the side: they consume what the world reports and never reach back into it.
Host trait boundary that keeps engine-vm free of GPU and audio dependencies; dashed lines carry scene uploads to the renderer and sequences to the audio stack.| Crate | Depends on | Role |
|---|---|---|
engine-vm | asset, prot, art, anm | the VMs and the battle-action state machine; no GPU / audio |
engine-core | engine-vm + parser crates | World, scene host, Vfs (dir / disc / memory), menus, sessions, minigame rules |
engine-ui | asset, tim, font | renderer-agnostic draw lists (TextDraw / SpriteDraw); the wgpu-free leaf the browser links |
engine-render | engine-ui, asset, tim, font | wgpu renderer with a software PSX VRAM (1024×512, CLUT decode in the fragment shader) |
engine-audio | xa, vab, seq, prot | cpal output, from-scratch 24-voice SPU, SsAPI-shaped sequencer; WebAudioOut on WASM |
engine-shell | all of the above | the legaia-engine binary: BootSession, subcommands, parity oracles |
The asset crates (tim, tmd, vab, …) stay engine-agnostic: they produce typed in-memory data and never touch wgpu, winit or cpal. engine-render and engine-audio never depend on engine-core; the shell composes them.
Goal and non-goals
Goal: a playable port of the NA release on modern systems via Rust + wgpu, with a WASM target; JP / EU follow once NA is solid. “Playable port” is grounded in retail, not bound by it: the Ghidra-traced dumps and oracles pin what the original does, the faithful mode reproduces it provably, and that is a measuring stick rather than a ceiling.
- Not a decompilation. No byte-matching recompile is attempted; every line is fresh Rust written from format docs and the Ghidra-traced dumps. The decompiled C under
ghidra/scripts/funcs/is reference material, never pasted; per-opcode tests use synthetic bytecode. - Never lose retail. Departures live behind toggles; the oracles keep “faithful” a testable claim. A quirk is preserved in the faithful mode and fair game to improve outside it.
- No re-authored assets. Every texture, mesh, sample and sequence comes off your own disc at runtime.
- Deterministic simulation. Seeded RNG, fixed timestep - the precondition for record / replay and TAS-style verification.
Modding and translation are shipped tracks, not exceptions: the randomizer (in-browser via the ROM patcher) and legaia-patcher translate patch a user-supplied disc without touching the engine, and what they prove out against retail - softlock fixes, tuning sliders - is expected to graduate into engine toggles.
Fidelity and enhancements
In the faithful mode no toggle changes damage, drops, AP costs, encounter rates or story flags, and every parity measurement runs against that mode. Enhancements are explicit toggles, bit-identical to retail when off, so flipping one never touches replays or oracles. Defaults follow the better experience: a knob still defaulting to retail marks an enhanced side that is maturing, not a policy of restraint.
| Knob | Default | Effect |
|---|---|---|
Dynamic lighting (--dynamic-lighting, I) | off | soft directional light + screen-centred pool over the baked shading |
Dynamic shadows (--no-dyn-shadows, Y) | on | per-scene candle / wall point lights with PCF shadow maps; inert while lighting is off (renderer) |
Occlusion fade (--no-occlusion-fade, F4) | on | geometry that completely hides the player dissolves to a dither circle; presentation-only, replays force it off |
Precise movement (R) | off | free-angle locomotion instead of retail’s 4 / 8-way quantisation (locomotion) |
Field run button (World::locomotion.run_button_mask) | retail + one alternate | retail’s Cross | R1 (the config word 0x800846DC = 0x48, seeded once and never exposed as an option row) plus Square, the port’s historical binding. Which key makes each button is the binding table (legaia-engine config set --binding W=R1), not this mask (locomotion) |
| Reduce flashing (options) | on | slew-limits the ambient palette cyclers; retail’s koin3 dance floor strobes at 15 Hz. Simulation identical either way |
Entry pulse (--no-entry-pulse) | on | a scene-entry vertex-morph envelope over packs retail never arms at entry |
PSX rasterisation (LEGAIA_PSX_RENDER=1) | off | sub-pixel vertex snap + 15-bit ordered dither |
| Semi-transparency blend | on | retail ABE blending - on because it is retail |
Camera distance (T) / debug orbit camera (F3) | Far / off | framing only; Far is 1.35× retail’s eye-back distance, Retail is the pinned framing. Both the native window and the play page default to the engine camera and swap in their own wide vantage on F3 |
| WebXR VR | off | stereo presentation on the site’s WebGL pages |
| Start leaves a minigame | on, not a knob | exits any of the five minigames through that game’s own exit so cash-out and score match a deliberate quit |
- Shading defaults to retail. Both retail mesh renderers issue one colour op on the GTE (the PS1’s geometry coprocessor) - the depth cue - and no lighting op, so shading is baked into the mesh colour words as
texel × colour / 128. The engine draws exactly that; the dynamic light is layered over it and is an identity when off. - Rasterisation defaults to clean. The default image is sharper than a PlayStation’s; here faithfulness is opt-in.
- Affine texturing is not gated. The PS1 has no perspective-correct texturing and neither does the mesh path; the swimming textures are the faithful behaviour on both hosts.
Why every minigame must be leavable
A mode the player can enter has to be one the player can leave on every host, or reaching it is a softlock. Retail has no single exit to port: each minigame quits through its own overlay’s state machine (the slot cabinet’s exit row, the duel’s decided-match confirm, the arena’s give-up arm). World::poll_minigame_escape is therefore an engine affordance: Start leaves whichever minigame is live through its own exit_* and closes the mode-24 round trip when the entry came through a door warp. It lives in World::tick, so both hosts inherit it; engine-shell/tests/casino_floor_softlock.rs enters each of the five the way the door warp does and asserts a pad press gets back out.
The ported VMs
Every interpreter is ported handler by handler: the opcode is read from the disassembly, rewritten in Rust and unit-tested against captured traces. The target is behavioural fidelity per opcode, not byte-exact internals. Each VM talks to the game through a Host trait that World implements.
| VM | Module | Scope |
|---|---|---|
| Actor / window-widget VM | engine-vm/src/lib.rs | 13 opcodes; the menu overlay’s window choreography |
| Field / event VM | engine-vm/src/field.rs | 43 opcodes, cross-context dispatch, the op-0x49 tristate, the 0x4C sub-dispatchers |
| Move VM | engine-vm/src/move_vm.rs | 71 opcodes + the 61-sub-opcode overlay extension; per-frame entry actor_tick |
| Motion VM | engine-vm/src/motion_vm.rs | pursue / patrol / face-target with 12-bit fixed-point angle math |
| Effect VM | engine-vm/src/effect_vm.rs | 32 master + 128 child slots; init / spawn / tick; UI-element routing |
| Battle action SM | engine-vm/src/battle_action.rs | 47 states in 7 bands; damage at the swing apex, art strikes from the actor’s chosen art |
| Title sub-mode dispatcher | engine-vm/src/title_overlay.rs | 25-entry jump table; the title → field launch write |
| Actor physics tick + animation runtime | actor_tick.rs, anim_vm.rs | layered side-effect subsets surfaced as typed TickEvents / AnimEvents |
Gameplay systems
The shell loop closes: title → save-select → field / encounter → battle → save. Everything below is wired into engine-core::World and driven from BootSession.
| Area | Module | What it covers |
|---|---|---|
| Battle round | battle_round.rs, battle_runner.rs | AP reset, stat recompute, all six commands (Attack / Arts / Magic / Item / Spirit / Run), Miracle / Super expansion (live loop) |
| Stats + status | battle_stats.rs, status_effects.rs | the per-frame party stat walker (8 slots, ability mask); Toxic / Numb / Venom / Rot / Curse / Stone / Faint with tick damage |
| AP gauge + HUD | ap_gauge.rs, battle_hud.rs | base 4 AP, +1 per 10 levels capped at 10, +5 on Spirit; HP / MP / AP bars, popups, log |
| Field | world.rs, field_env | locomotion ANM banks, NPC placements from the MAN, inline field-VM dialogue from the scene’s own bytecode |
| World map + minigames | WorldMapController, dance, baka_fighter, muscle_dome | overworld traversal + the 5-state entity SM; minigames as suspending SceneModes |
| Shop / inn / level-up | ShopSession, InnSession, LevelUpTracker | stock from the MAN, prices from the item table, XP curve and growth tables read from the user’s executable at boot |
| Items + inventory | items.rs, inventory_use.rs | typed ItemEffect resolver; the pick-item / pick-target flow shared by field menu and battle |
| Title + save | title::TitleSession, save_select, save/src/ext.rs | real title sheet (PROT 0890); LGSF container, versioned v1..v4 with sentinel-guarded extensions; memory-card writeback |
| Audio | AudioBgmDirector, sfx.rs | 30-frame cross-fades; cue catalog decoded from the executable, frame-accurate scheduler through the scene VAB |
| Cutscenes | play-str, mdec | STR video through the from-scratch MDEC decoder with XA audio in sync (cutscene) |
The browser host
The WASM target runs the engine itself: LegaiaRuntime owns a real SceneHost, so the browser executes the same field VM, movement controller, NPC motion and dialogue runner as the native window. Per frame it hands the engine a pad word and the camera azimuth, ticks, and draws what it reports through the site’s shared WebGL renderer. It reaches field and town scenes; battles, the title chain, the pause-menu screens and audio have their state ported but their draw path only in the native window.
- Seating. The cold-boot spawn is authored for
town01only; every other scene expects a door warp. A host that drops the player in must seat them itself, off any walk-on trigger tile. - Framing. Retail authors a camera per scene; a generic follow camera puts a cave roof in the way, so the browser culls meshes straddling the camera-to-player line.
Open ports are tracked structurally by the port catalog, which cross-references every dumped function against its docs page and its // PORT: tag.
How we know it matches
| Oracle | Command | What it proves |
|---|---|---|
| Engine scenarios | legaia-engine scenarios [--bless] | a headless run for N frames hashes the save bytes against a blessed baseline; an unblessed row fails until reviewed |
| VRAM diff | legaia-engine vram-oracle --runtime-vram <bin> | engine video memory vs a save-state capture; --rows-csv and --clut-regions localise a missed upload to one row |
| Mode / audio / sim traces | mode-trace, audio-trace, pcm-trace, sim-trace | the mode word, SPU state and per-frame simulation channels against retail snapshots (differential) |
| Record / replay | legaia-engine record / replay | the same input file yields bit-identical state traces twice (replay) |
| Mednafen scenarios | scripts/scenarios.toml | the byte-level twin of the engine manifest; both live side by side so a change to one is forced to consider the other |
Details: the VRAM static mask
A save state’s VRAM is a live snapshot: two captures of the same scene differ across ~40% of the texture band (animation frames, battle leftovers), so no stateless pre-pass can be byte-exact against one snapshot. The disc-gated vram_oracle_e1 test asserts against the static mask - the words identical across every same-scene capture - excluding the runtime-managed NPC CLUT band around row 479. Incompleteness is not flagged; a wrong texel on a static pixel is.
Two refinements the captures forced: the shared effect-texture band is history-dependent (a handful of pixels hold a boot-resident value until a battle re-uploads the disc bytes), so cells inside it must be static across all scenes; and the world map’s ocean CLUT rows 506 / 508 / 509 animate, so they are excluded for world-map scenes only.