Engine reimplementation
A clean-room Rust port of Legend of Legaia - the same model as ScummVM or OpenRCT2, not a recompile. We trace the original PS1 code with a disassembler, document what each function does, then write fresh Rust that does the same thing. You supply your own disc image; the engine extracts the assets at first run and plays the game. The engine can reproduce retail exactly - and prove it against parity oracles - but it isn't bound to it: enhancements (dynamic lighting, free-angle movement, VR, and more to come) ride on top as toggles, with a retail-faithful mode always one flip away.
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 the original MIPS assembly. The tool is Ghidra, a disassembler that recovers readable pseudo-C from the game's machine code; the FUN_80xxxxxx names you'll see across these pages are the RAM addresses of functions in that trace, because the game shipped without symbols. 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 an optional WASM/web target. JP/EU regions land after NA is solid.
"Playable port" is grounded in retail, not bound by it. The decompilation, the format docs and the parity oracles pin down exactly what the original does - damage arithmetic, RNG, script pacing, save-record layout - and the engine reproduces it, provably, in its retail-faithful mode. That ground truth is a measuring stick, not a ceiling: the port is free to add features, mechanics, rendering and audio the original never had, exposed as toggles so a retail-faithful mode stays one flip away wherever a faithful mode makes sense. Details under fidelity & enhancements.
Non-goals:
- Static recompilation of
SCUS_942.54. The engine is clean-room from documented specs and decompile-then-rewrite logic - not auto-translated MIPS. This is the boundary the whole legal posture rests on. - Losing retail. The port departs from retail freely - new features, mechanics, rendering, audio - but never silently: departures live behind toggles, and the oracles keep "faithful" a testable claim about the retail mode. A quirk is behaviour to preserve in the faithful mode, and fair game to improve outside it.
- Re-authoring the game's assets. Every texture, mesh, sample and sequence comes off your own disc at runtime. Nothing is upscaled, redrawn, or bundled.
Modding and translation are not on that list: the randomizer (playable in-browser via the ROM patcher) and the legaia-patcher translate language packs are shipped, deliberately-designed parts of this project. Both are disc patchers that operate on a user-supplied .bin rather than engine features - the randomizer does not touch the clean-room engine at all. The separation is not a wall, though: what the patcher proves out against retail - randomizer logic, softlock fixes, tuning sliders - is expected to graduate into engine features and toggles. A mod that works on the disc has no reason not to become a mode of the port.
Fidelity & enhancements
The port draws a hard line between the retail-faithful mode and everything layered on top, so that "faithful" stays a testable claim rather than a mood.
The retail simulation is the measured ground truth. In the faithful mode no toggle changes damage, drop rolls, AP costs, encounter rates or story-flag behaviour, and the parity oracles hold it there. The engine scenarios hash the resulting save bytes against a blessed baseline, the VRAM diff harness diffs the engine's video-memory uploads against runtime blobs captured from save states (VRAM is the PS1's single 1024×512 video memory, where every texture and palette lives), and record / replay requires the same input file to produce bit-identical state traces twice. Every parity measurement runs against that mode.
The port is not bound to that mode. Enhancements - presentation today, mechanics and audio as they mature - land as explicit toggles that leave the faithful mode bit-identical when off, which is why flipping one never touches replays or the oracles above. Defaults follow the better experience, not the museum: where an enhancement is clearly better it ships enabled by default; a knob that currently defaults to retail marks an enhanced side still maturing, not a policy of restraint. Current knobs and defaults:
| Knob | Default | Effect |
|---|---|---|
set_dynamic_lighting (--dynamic-lighting, I) | off | Soft warm directional light + screen-centred light pool over the baked shading. |
World::precise_movement (R) | off | Free-angle locomotion instead of retail's 4/8-way quantisation. |
set_psx_mode (LEGAIA_PSX_RENDER=1) | off | Strict-PS1 rasterisation artefacts - see below. |
set_semi_blend | on | Retail ABE semi-transparency blending. On because it is retail. |
Camera distance (T) / debug orbit (C) | Far / off | Framing only; never feeds the simulation. |
| WebXR VR mode | off | Stereo presentation on the site's WebGL pages, not the wgpu path. |
Start leaves a minigame (World::poll_minigame_escape) | on, not a knob | Retail quits each of the five minigames through its own overlay - a different control per game - and none of those is wired to a control a player can find here, so without this an entered minigame is a softlock. Each game's own exit runs, so the cash-out and score bookkeeping match a deliberate exit. |
Two details are worth having straight, because the direction is not uniform:
- Shading defaults to retail. The field path has no runtime light source at all - both retail TMD renderers (TMD is the game's 3D-mesh format) issue exactly one colour op on the GTE (the PS1's geometry coprocessor) -
DPCS, the depth cue - and never anNC*lighting op, so shading is baked into the TMD colour words and applied astexel * colour / 128. The engine's field pipelines draw exactly that. The dynamic light is layered over it and is an exact identity when disabled, which is what keeps the render oracles honest. - Rasterisation defaults to clean.
psx_modeis off, so the default image is sharper than a PlayStation's: no sub-pixel vertex snap and no 15-bit ordered dither. Here faithfulness is the mode you opt into, not the default.
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.
- Quirks are preserved in the faithful mode, fixable outside it. Quirky damage rounding and oddly-timed cutscenes are replicated exactly where the oracles measure - that keeps ground truth honest. Changing them is legitimate engine work, but it lands as an enhancement toggle over the faithful path, never a silent edit to it.
- Behaviour tests against runtime traces. Inputs, RNG and frame outputs captured from the original game replay through the engine and diff against it - the VRAM diff harness and the record / replay oracles are where that lands.
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-vm → asset, prot, art, anm (VM layer; no GPU / audio deps)
engine-core → engine-vm + the parser crates
engine-ui → asset, tim, font (draw-list builders; no wgpu)
engine-render → engine-ui, asset, tim, font (wgpu; no engine-core dep)
engine-audio → xa, vab, seq, prot (cpal + SPU model; no engine-core dep)
engine-shell → engine-core, engine-vm, engine-render, engine-audio
asset-viewer → engine-*, all parser crates
engine-core sits above engine-vm - it implements the per-VM Host traits on World - while engine-render and engine-audio are leaf presentation crates that never depend on engine-core; the engine-shell binary composes all four. Sequenced music is covered by crates/seq (the SEQ parser) plus the engine-audio Sequencer; battle / menu modules live inside engine-vm / engine-core next to the actor + field VMs rather than as separate crates.
engine-ui is the wgpu-free leaf under engine-render: it builds the renderer-agnostic UI draw lists (TextDraw / SpriteDraw), which is what lets the browser target consume them without linking wgpu. engine-render re-exports its items at their historical crate-root paths, so native callers see no difference.
Foundation: core, render, audio + the asset viewer
The asset viewer is a standalone binary that loads the disc, lets you navigate PROT entries, and renders / plays them. It de-risks the engine's integration surface: everything it draws goes through the same crates the engine does. Render API: winit + wgpu (Vulkan / Metal / DX12 / WebGPU backends). Audio: cpal-backed mixer over a clean-room model of the SPU, the PS1's sound chip.
| 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 decode in the fragment shader - a CLUT is a 16- or 256-colour palette stored as a row of framebuffer pixels). |
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.
Smooth shading: legaia_tmd::mesh::tmd_to_vram_mesh emits a per-vertex normal stream by accumulating area-weighted face normals into per-position bins, so connected geometry shades smoothly; the VRAM-mesh shader falls back to screen-space derivatives only for unbinned positions. Those normals are what the opt-in dynamic light reads - retail's own render uses none of them. The SPU side is a full 24-voice mixer with ADSR (see audio).
Cutscene audio: legaia-engine play-str decodes a PSX STR movie's interleaved XA audio track off the disc and plays it through AudioOut in sync with the video frames, which retail fed through the MDEC (the PS1's video-decompressor chip). The track decodes up front rather than through an incremental streaming voice in engine-audio. See cutscene.
The ported VMs
Every VM is a handler-by-handler translation: the opcode handler is dumped from Ghidra, hand-ported to Rust, and unit-tested against captured runtime traces. The target is behavioural fidelity per opcode, not byte-exactness of the VM's internals. Each abstracts its SCUS callbacks behind a Host trait, so the VM crate stays free of GPU and audio dependencies.
- 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. - Title-overlay sub-mode dispatcher -
crates/engine-vm/src/title_overlay.rs. 25-entry JT at0x801CF244(the per-frameFUN_801DD35Ctick), state-struct field offsets, observedstate[+0x204] = Ntransitions. Four modes are semantically labelled (Init,Idle,AttractIdle,AttractDelay); the other 21 carryPhase0xNNplaceholders. Standout pin:Phase06writes_DAT_8007B83C = 0x02at0x801DFC00- the title-screen → main-game master-mode transition (exported asMASTER_GAME_MODE_FIELD_LAUNCH+PHASE06_LAUNCH_GAME_PC). - SCUS sprite-emit primitives -
crates/engine-vm/src/title_prim.rs. Clean-room ports of the three SCUS helpers the title tick calls into:FUN_80058298(ClearImagefill-rect),FUN_80058490(MoveImageVRAM-copy),FUN_800198E0(sprite-descriptor dispatcher with tag-0x11+ alpha-OR pre-pass + width-divisor variants).PrimHosttrait abstracts the four engine callbacks. Overlay-side helpers (FUN_801E1C1Cetc., shared across menu / battle / shop / save UI overlays) are deferred to their own port. - 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.
Gameplay systems
All major gameplay systems are wired into engine-core::World and driven from engine-shell::BootSession. The shell loop closes: title → save-select → field / encounter → battle → save.
Battle
- Battle round lifecycle -
crates/engine-core/src/battle_round.rs.BattleRound::beginresets every party AP gauge, recomputes per-slotBattleStats, and writes the resolved attack / UDF / LDF into world state;BattleRound::endticks every actor's status, drains tick damage intoBattleActor::hp, and returns the death count. The returned round carriesaction_blocked/magic_blockedarrays the action validator filters command input against. - Battle command runner + six-command menu -
crates/engine-core/src/battle_runner.rs+battle_input.rs.BattleRunnersits between player input and the action SM (begin_round/push_command/push_chained_art/commit_turnwith Miracle / Super expansion /end_round). The player-driven loop offers all six retail commands - Attack / Arts / Magic / Item / Spirit (AP charge + guard stance) / Run (escape band) - see battle → live gameplay loop. - Battle stat aggregator -
crates/engine-core/src/battle_stats.rs. Clean-room port ofFUN_80042558(the per-frame party stat walker): 8 equipment slots, per-item modifiers, 256-bit ability mask, status-effect modifiers. - Status effects -
crates/engine-vm/src/status_effects.rstracks the retail conditions (Toxic / Numb / Venom / Rot / Curse / Stone / Faint, plus host-driven Sleep / Confuse) with per-instance turn counters and damage-over-time formulas; the on-disc byte map follows the pinned appliers (3 = Venom, 4 = Toxic, 5 = Rot with a rolled disabled limb refused at command input, 6 = Curse).World::tick_status_effectsfolds tick damage intoBattleActor::hp. - AP / Spirit gauge -
crates/engine-core/src/ap_gauge.rs: 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. - Battle HUD model -
crates/engine-core/src/battle_hud.rs: per-slot HP / MP / AP / status icons, damage popups with fade timers, ringed log column;engine-render::battle_hud_draws_forrenders it. - Battle move-FX spawn -
World::request_move_fx_spawnresolves a non-summon cast / special through the move-power table (impact effect, trail texpage, sound cue) and spawns the matching effect. - RAM-pinned cameras - the battle orbit camera (mode
0x15:R = Rx(32)·Ry(yaw),TR = (0, 1280, 7680),H = 256, 4× actor world scale fromDAT_8007BF10) and the world-map walk camera are reproduced from live RAM captures; see battle → background + camera.
Field, world map + minigames
- Field scene rendering - Rim Elm (
town01) renders with the player walking / idling through the real locomotion ANM banks and NPCs drawn from their MAN placements (placement model = scene-TMD index, clip =anim_id - 1); the heightfield elevation inversion is handled byFIELD_WORLD_FLIP. - Inline field-VM dialogue (default-on) -
World::step_inline_dialogueports the retail dialog state machineFUN_80039B7Cthrough the real field VM, so NPC conversations run from the scene's own MAN bytecode;play-window --simple-dialogueopts back out to the segment-pool fallback. - World-map controller -
engine-core::WorldMapControllerdrivesSceneMode::WorldMap(overworld traversal + the 5-state entity SM port); see world map. - Minigame scene modes - the Noa dance rhythm game (
engine-core::dance, from the parsed step chart), fishing, and the casino slot machine are ported as suspendingSceneModes the play-window host opens on the K / L / O keys and returns from without losing field state. - Per-actor animation runtime -
crates/engine-vm/src/anim_vm.rs: a fixed actor pool wrappingAnimPlayer, emitting anAnimEventstream (PoseUpdated/OpaqueTick/Finished/Replaced) so engines drive renderer / SFX side effects without polling. - Per-actor physics tick -
crates/engine-vm/src/actor_tick.rs: layered port ofFUN_80021DF4(the shared actor tick). Dispatch bytes0x01..=0x07select layered side-effect subsets; cross-cutting effects surface as typedTickEvents (SfxUpdate/SplineDraw/MoveVmKick/ …).
Menus, shop + level-up
- Shop / Inn / Level-up -
ShopSession,InnSession,LevelUpTrackerinengine-core;MenuRuntimeroutes buy/sell/quantity/confirm/exit; HP/MP restore on inn commit; XP distribution firesBattleEvent::LevelUp. Shop stock is decoded from each scene's MAN op-0x49shop records and prices from the static SCUS item table (crates/engine-core/src/shop_catalog.rs); the XP curve (DAT_80076AF4+ formula) and per-character stat growth (DAT_80076918/DAT_800769CC) are extracted from the user's SCUS at boot and installed viaLevelUpTracker::with_growth_tables- see level-up. - Tactical Arts learning UI -
TacticalArtsTrackertracks per-char / per-art use counts;ArtLearnedBannercounts down inWorld::tick;BattleEvent::TacticalArtLearnedfires at threshold. - Item catalog -
crates/engine-core/src/items.rs: typedItemEffectenum with a pure-functional resolver;World::use_itemfolds outcomes back into world state (caps, status cures, AP refunds). - Inventory item-use session -
crates/engine-core/src/inventory_use.rs: the "open inventory → pick item → pick target → use it" flow shared between the field menu and the battle command menu. - Menu sub-screens -
MenuRuntimehandlesStatusCharacter/StatusEquipment/StatusInventorywith cursor input and commit side-effects.
Save, audio + shell
- Save / load - the
LGSF v2self-describing container (party records, story flags, money, inventory, play-time, per-character ext) with backward-compatiblev3(full story-flag bitmap) andv4(LGX4shiny-Seru block) extensions;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. - SFX bank + scheduler -
crates/engine-audio/src/sfx.rs: cue-ID →SfxEntrycatalog decoded from the user's executable at boot, frame-accurateSfxSchedulerfiring through the per-scene VAB. - 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_fielddrive the in-browser engine from disc bytes the user picks locally (see play the port). - Region support -
legaia_prot::Regionenum (NA / EU / JP);ProtIndex::with_region().
The browser host
The WASM target runs the engine itself, not a second implementation of it: legaia_web_viewer::runtime::LegaiaRuntime owns a real SceneHost, so the browser executes the same field / event VM, free-movement controller, floor sampler, NPC motion VMs, interaction probe and inline-dialogue runner the native window drives. The per-frame contract is small: hand the engine a PSX pad word, tell it the camera azimuth (so the d-pad remaps camera-relative), tick it, draw what it reports. Rendering goes through the site's shared WebGL TMD renderer rather than engine-render's wgpu path.
What the browser host reaches: field and town scenes - map, player, NPCs, doors, dialogue. What it does not: battles, the title / prologue chain, the pause-menu screens and audio, each of which has its state ported but its draw path only in the native window.
Two responsibilities fall to any host that enters a scene without a door to arrive through - the browser's scene picker is the case that exists:
- Seating -
enter_field_sceneplaces the player at the retail cold-boot spawn, which is authored fortown01, the one scene retail cold-boots into. Every other scene expects a door warp to override X/Z with an entry tile. A host that drops the player in cold must seat them itself, and must avoid seating them on a gate-1 walk-on trigger tile (SceneHost::tile_has_walk_on_trigger) - the first tick would fire it and warp the scene away. - Framing - retail authors a camera per scene; a generic follow camera puts a cave roof between the lens and the player. The browser host culls meshes straddling the camera-to-player line.
Open work
Open ports are tracked structurally rather than as a hand-maintained list: the port catalog cross-references every dumped Ghidra function against its docs page and its // PORT: tag in crates/, and port-catalog.py --dashboard regenerates the open-work view on demand. The question-level companion is docs/reference/open-rev-eng-threads.md.
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.
Engine integration scenarios
scripts/engine/scenarios.toml declares scenarios that drive the headless BootSession for a fixed frame count and assert the SHA-256 of the resulting SaveFile byte stream matches a recorded baseline. Mirrors the byte-level mednafen scenarios manifest - both files live side by side so a feature touching either layer is forced to consider regression coverage on the other.
Schema lives in crates/engine-shell/src/scenarios.rs; the disc-gated runner in crates/engine-shell/tests/scenarios.rs exercises every entry. The CLI runner is legaia-engine scenarios [--bless] (the --bless flag rewrites the manifest in place with observed hashes for blessing).
A scenario row whose expected_save_sha256 is empty is "unblessed" - the test reports the observed hash and skips assertion; the CLI runner exits non-zero unless --bless is on. That forces every new scenario to be reviewed once before it can drift silently.
VRAM diff harness
legaia-engine info --runtime-vram <bin> --vram-diff-png <path> and legaia-engine vram-oracle --runtime-vram <bin> already compare engine VRAM (built via SceneResources::build_targeted) against a runtime VRAM blob captured from a save state. The vram-oracle subcommand also exposes:
--rows-csv <path>- per-Y row CSV of pixel-level diff stats (y, runtime_nz, engine_nz, overlap, runtime_only, engine_only). Drift in any single row above a threshold (e.g. row 479 NPC CLUT) shows up as a highruntime_onlycount for that row only, which is the regression signature of a missed targeted-upload pass.--clut-regions- one-line health report per documented CLUT band (NPC palette row 479, character / texture-page CLUT rows). A<-- gapflag flags the engine-missing case.
Pair with mednafen-state vram-dump --out-bin to get the runtime ground-truth blob, and with mednafen-state prim-dispatch-survey to confirm the per-prim renderer dispatch tables haven't drifted between the saves you're comparing.
Static-mask parity (vram_oracle_e1)
A save state's VRAM is a live snapshot: much of the texpage region is dynamic / residual state (animation frames, battle leftovers, scroll position). Comparing two captures of the same scene (town01 pre- vs post-battle) shows ~40% of the primary texture band differs between them, so a stateless engine pre-pass can never be byte-exact against a single snapshot. The disc-gated vram_oracle_e1 test therefore asserts against the static mask - the words identical across every same-scene capture (the scene's genuine static VRAM). For each scene with ≥ 2 captures it builds the engine VRAM with the field-mode DMA-every-TIM pre-pass (upload_all_tims) and asserts the engine never uploads a wrong texel on a static pixel in the texpage region, excluding the runtime-managed NPC / character CLUT band (vram_oracle::NPC_CLUT_BAND_ROWS, row 479 ±). Incompleteness is not flagged - the engine doesn't yet assemble every boot-resident texture (font / menu atlases) - but the correctness of what it does upload is.
The per-scene mask premise (“stable across same-scene captures = genuinely static”) has two capture-pinned failure modes, each with its own refinement: (1) global shared bands are history-dependent - the befect_data effect-texture band (one disc source, resident across every field scene) carries a handful of pixels whose boot-resident value differs from the disc copy until a battle re-uploads the disc bytes (pinned at (853, 271): pre-battle / menu captures hold 0xFFFF words where the disc TIM - and every post-battle capture - holds 0x3333); when a scene's captures share battle history the per-scene mask misclassifies those pixels as static, so refine_mask_with_shared_band demands staticity across all scenes' captures for cells inside scene::effect_texture_image_rects. (2) World-map CLUT palette cycling - row 506's head is the 13-frame ocean CLUT animation (a capture holds an arbitrary phase, never the disc base CLUT), rows 508/509 each animate a few entries, row 508's entries 32..47 mirror its own 0..15 head, and row 506's tail holds a runtime-generated palette found in no disc bundle; WORLD_MAP_CLUT_CYCLE_ROWS / clear_world_map_clut_cycle_rows exclude the three rows for world-map scenes only (row 507, a non-animated terrain CLUT, stays asserted).