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:

KnobDefaultEffect
set_dynamic_lighting (--dynamic-lighting, I)offSoft warm directional light + screen-centred light pool over the baked shading.
World::precise_movement (R)offFree-angle locomotion instead of retail's 4/8-way quantisation.
set_psx_mode (LEGAIA_PSX_RENDER=1)offStrict-PS1 rasterisation artefacts - see below.
set_semi_blendonRetail ABE semi-transparency blending. On because it is retail.
Camera distance (T) / debug orbit (C)Far / offFraming only; never feeds the simulation.
WebXR VR modeoffStereo presentation on the site's WebGL pages, not the wgpu path.
Start leaves a minigame (World::poll_minigame_escape)on, not a knobRetail 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 an NC* lighting op, so shading is baked into the TMD colour words and applied as texel * 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_mode is 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.

CrateWhat's there
engine-coreVfs 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-renderRenderer (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-audioAudioOut (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-viewerwinit 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 of FUN_801DE840 are ported with a FieldHost trait abstracting every SCUS callback. Cross-context dispatch (extended-bit prefix), YIELD caller-propagation, Op49State tristate (with the inline-MES walker for sub-0), the 0x4C outer-nibble dispatcher, the 0x38 halt-acquire path, and the 0x5x/0x6x/0x7x default-route fourth-flag-bank dispatchers are all wired.
  • Move VM - crates/engine-vm/src/move_vm.rs. All 71 main opcodes (0x00..0x46) of FUN_80023070 ported, plus the 0x2F extension dispatcher (61 sub-opcodes via FUN_801D362C). Per-frame entry is actor_tick, mirroring the gate at FUN_80021DF4 + 0x80022B94.
  • Motion VM - crates/engine-vm/src/motion_vm.rs. All 6 opcodes ported including the 12-bit fixed-point angle-math opcodes 0x38 RotateToAngle and 0x4C FaceTarget.
  • Effect VM - crates/engine-vm/src/effect_vm.rs. Slot pool (32 master + 128 child slots), Pool::init / Pool::spawn / Pool::tick ports of FUN_801DE914 / FUN_801DFDF8 / FUN_801E0088, Pool::spawn_by_ui_id + EffectCatalog for UI-element routing.
  • Battle action state machine - crates/engine-vm/src/battle_action.rs. Port of FUN_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 fires apply_damage at the swing-apex byte. The Tactical-Arts strike band additionally calls apply_art_strike(ArtStrikeInfo) with the per-strike power byte, dmg_timing, status effect, and hit cue resolved from the active actor's chosen art via BattleActionHost::art_record.
  • Title-overlay sub-mode dispatcher - crates/engine-vm/src/title_overlay.rs. 25-entry JT at 0x801CF244 (the per-frame FUN_801DD35C tick), state-struct field offsets, observed state[+0x204] = N transitions. Four modes are semantically labelled (Init, Idle, AttractIdle, AttractDelay); the other 21 carry Phase0xNN placeholders. Standout pin: Phase06 writes _DAT_8007B83C = 0x02 at 0x801DFC00 - the title-screen → main-game master-mode transition (exported as MASTER_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 (ClearImage fill-rect), FUN_80058490 (MoveImage VRAM-copy), FUN_800198E0 (sprite-descriptor dispatcher with tag-0x11 + alpha-OR pre-pass + width-divisor variants). PrimHost trait abstracts the four engine callbacks. Overlay-side helpers (FUN_801E1C1C etc., shared across menu / battle / shop / save UI overlays) are deferred to their own port.
  • Composite world / actor system - crates/engine-core/src/world.rs. World owns 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 ANM AnimPlayer instances. World::tick drives 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 uses WebAudioOut (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::begin resets every party AP gauge, recomputes per-slot BattleStats, and writes the resolved attack / UDF / LDF into world state; BattleRound::end ticks every actor's status, drains tick damage into BattleActor::hp, and returns the death count. The returned round carries action_blocked / magic_blocked arrays the action validator filters command input against.
  • Battle command runner + six-command menu - crates/engine-core/src/battle_runner.rs + battle_input.rs. BattleRunner sits between player input and the action SM (begin_round / push_command / push_chained_art / commit_turn with 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 of FUN_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.rs tracks 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_effects folds tick damage into BattleActor::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_for renders it.
  • Battle move-FX spawn - World::request_move_fx_spawn resolves 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 from DAT_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 by FIELD_WORLD_FLIP.
  • Inline field-VM dialogue (default-on) - World::step_inline_dialogue ports the retail dialog state machine FUN_80039B7C through the real field VM, so NPC conversations run from the scene's own MAN bytecode; play-window --simple-dialogue opts back out to the segment-pool fallback.
  • World-map controller - engine-core::WorldMapController drives SceneMode::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 suspending SceneModes 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 wrapping AnimPlayer, emitting an AnimEvent stream (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 of FUN_80021DF4 (the shared actor tick). Dispatch bytes 0x01..=0x07 select layered side-effect subsets; cross-cutting effects surface as typed TickEvents (SfxUpdate / SplineDraw / MoveVmKick / …).

Menus, shop + level-up

  • Shop / Inn / Level-up - ShopSession, InnSession, LevelUpTracker in engine-core; MenuRuntime routes buy/sell/quantity/confirm/exit; HP/MP restore on inn commit; XP distribution fires BattleEvent::LevelUp. Shop stock is decoded from each scene's MAN op-0x49 shop 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 via LevelUpTracker::with_growth_tables - see level-up.
  • Tactical Arts learning UI - TacticalArtsTracker tracks per-char / per-art use counts; ArtLearnedBanner counts down in World::tick; BattleEvent::TacticalArtLearned fires at threshold.
  • Item catalog - crates/engine-core/src/items.rs: typed ItemEffect enum with a pure-functional resolver; World::use_item folds 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 - MenuRuntime handles StatusCharacter / StatusEquipment / StatusInventory with cursor input and commit side-effects.

Save, audio + shell

  • Save / load - the LGSF v2 self-describing container (party records, story flags, money, inventory, play-time, per-character ext) with backward-compatible v3 (full story-flag bitmap) and v4 (LGX4 shiny-Seru block) extensions; World::save_full / load_full; memory-card writeback via legaia_save::card::write_block.
  • BGM + audio - AudioBgmDirector cross-fades between tracks over 30 frames; sequencer pause gating; input::Mapping persists key bindings to TOML.
  • SFX bank + scheduler - crates/engine-audio/src/sfx.rs: cue-ID → SfxEntry catalog decoded from the user's executable at boot, frame-accurate SfxScheduler firing through the per-scene VAB.
  • Windowed engine binary - legaia-engine play-window opens 960×720 via winit; play-str plays back PSX STR + XA in a window; config set --binding edits key maps.
  • WASM disc-bytes Vfs - MemoryVfs, Archive::from_bytes, SceneHost::from_prot_bytes, and LegaiaRuntime::load_disc / enter_field drive the in-browser engine from disc bytes the user picks locally (see play the port).
  • Region support - legaia_prot::Region enum (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_scene places the player at the retail cold-boot spawn, which is authored for town01, 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 high runtime_only count 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 <-- gap flag 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).

See also