At a glance

HostCrateDriven by
Native play windowengine-shellwgpu through engine-render
Browser play pageweb-playsite/js/play-app.js and its view files
Browser minigames pageweb-minigamesper-minigame modules under site/js/

Both play hosts tick one engine-session::BootSession; the minigames page drives the World-free rules engines directly. Every question below narrows the one before it, and each has its own instrument:

Reached?does each host reach ittiers 1, 8, 9, 10, 13 Same inputs?do both feed it aliketiers 2, 3, 4, 5, 12 Every frame?run and drawn each frametiers 6, 7, 11, 14, 17 Same frames?frame-body trace (state)host pair run (pixels) absent on BOTH hosts: no drift gate fails - page export reach finds it
Every tier is a source scan that answers one question and is silent about the rest, so a green suite is a floor, not host parity. Only the frame-body trace and the host pair run actually run both hosts.

Where the rows live and where the gates run

Tiers 1-3 and 5-17 are scripts/ci/check-ui-host-drift.py (--list prints the surface table, --selftest the detector controls); tier 4 is check-trait-override-symmetry.py. The script is an entry point over a package that keeps the rows a contributor edits (one module per table under rows/), the analysis (one module per tier) and the report apart. A row that names a file no longer on disk would check nothing, so a path check fails the gate on any such row.

  • CI is the authority. Every tier runs in the CI gates job and in the pre-commit hook as a fast mirror. A gate may be hook-only only when its input is gitignored (the Ghidra dump corpus, the extracted disc), and it must then skip cleanly where the input is absent.
  • Nothing above compiles the browser host. The wasm32-only blocks in the web crates are invisible to the host-triple cargo gates, so check-wasm-target.sh type-checks them on a staged change and on every pull-request push.
  • A wasm export is a property of its impl block. A method outside the #[wasm_bindgen] block compiles and passes every gate and is "not a function" in the browser; only a headless run of the built bundle sees that.

The seventeen tiers

TierQuestionBlind to
1 reachabilitydoes each engine-ui draw builder reach both hosts? (derived: every builder is a screen)arguments, runtime reach
2 paired constantsdo two named constants carry one value?how the value is used
3 simulationdo two injection sites name the same kernels?arguments, order
4 trait overridesdoes every implementer override the same defaulted hooks?what the bodies do
5 inputdoes a page carry its own keyboard table?-
6 diagnosticsis an additive debug draw default-off on both hosts?an undeclared debug draw
7 render kernelsdoes every surface building a draw list name its kernel?a kernel with no rule
8 ownershipdoes each host hold the engine type, or only read it?how it is driven
9 variant coveragedoes each host answer every enum variant?whether the arm draws
10 entry symmetryis a phase armed only from a debug key?data decoded beside the phase
11 frame pathdoes an early exit skip a kernel the frame still draws?whether paired kernels do the same work
12 contentdo two paired frame kernels reach the same engine calls?arguments, guards
13 boot installsdoes the page's boot make every install the native boot makes?tables written as field assignments
14 save routingdoes every save and load go through the retail screen's applier?the page's save-bar import
15 engine environmentdoes an engine crate read a switch from the process environment?a crate no browser host links
16 page cuesdoes the minigames page key the live SPU rather than play decoded PCM?whether the id is right
17 page statedoes the play page keep the GL state and frame order a measured defect left wrong?state no row names; the pixels

Each tier's rows, worked cases and exact rules are on the full page. The pattern is the same throughout: a tier that has caught a shipped defect is kept, with a self-test proving its detector fires on the shape it was built for.

The page's own gates, and waivers

  • Sticky renderer state. Every page renderer setter that stores a value until the next call must be staged once a frame ahead of every mode branch, unless classified with a reason. Five such values had been staged in the field branch only, so a battle entered from the overworld kept the overworld's curvature and the field's face cull.
  • Page export reach. Every method the runtime exports must be named by some page source or waived. It is the converse of the tiers, and the only check that finds a feature both hosts lack: the fishing start export was pinned by tests and documented, with no page control calling it.
  • Waivers name a blocking capability - something that does not exist yet, spelled concretely enough to recognise when it lands. "Not wired yet" is undone work, not a waiver. Both waiver files are checked for staleness on every run.

The frame-body trace: same calls, same world?

A source scan cannot see the same calls producing different world state: a step one host runs per redraw and the other per tick, a loop that ends early on one side. The disc-gated host_frame_parity test runs both hosts in one process without a renderer - the native play window's app assembled headless, and the real page runtime driven the way the page's frame loop drives it - samples each display frame (scene, mode, pad edges, world frame, rand() state, script cursor, actors, NPCs, camera, flags, bag, dialogue, battle, menus, audio) and reports the first frame each channel parts on.

Each scenario runs at three cadences - one tick per display frame, two (a 30 Hz display) and one every other frame (120 Hz) - because a clock split between the redraw and the tick is invisible at 60 Hz. Each also asserts what it exercised (a menu opened and closed, a battle was won through to the overworld, each minigame entered and left), so agreement on a world that never moved fails.

The host pair run: same frames?

scripts/ci/host-pair.mjs --site <site> --engine target/release-test/legaia-engine --out <scratch> \
    [--scene town_walk,menu,...] [--mode retail,enhanced] [--every n] [--ticks n] [--from n]

host-pair.mjs runs one scene on one pad script in the play window and in the built play page, pairs them by world frame, and compares both what they hold (the trace text) and what they drew (the share of pixels off at 320×240). It is the one check that sees the page's JavaScript and GL state against the window. --mode enhanced can drop one pass on both hosts (no_ssao, no_lighting, no_fog, no_fade) to split a pixel difference by the pass behind it. Its scenes cover a walk, each pause row, a talk, a shop, an inn, a fight to its results, a summon, every minigame, retail card saves carried from one host to the other, and seeded random presses over the whole pad. A save carries no RNG state, so a card scene seeds one rand() stream on both hosts after the landing (LEGAIA_RNG_SEED natively, debug_seed_rng on the page); unseeded, the two drew different fights.

A healthy pair agrees on every sampled trace and differs by a few tenths of a percent of pixels: hairline rows on flat 3D edges where a tie lands on the other row. It prints and asserts nothing; it needs a disc, a built bundle and a display.

What it found, with every tier greenWhat it looked like
Sticky GL stateleaving the dance hall left its face winding behind, and the field drew with its ground gone
A fill rule on a bottom-up windowevery sprite drew a row low; the slot machine's dot matrix lost its top rows
A page clock over an engine clockthe dance hall's panels flipped once a page frame instead of on the dance's own clock
An actor watermark set earlythe window's battle floor took no ambient occlusion where the page's did
A pass the other host never runsthe window ran ambient occlusion in the minigame venues; both now ask one scope test
A cut on half a buffera stage curtain seen from behind covered the whole frame for about a hundred ticks
A wash run twicea battle's exit fade took the page's results box to black at twice the scene's rate: the page washed its text canvas and then composited it under the same fade

Shapes no tier fails on, and open differences

Reading the hosts side by side with every tier green still finds defects, and the shapes recur: one decision fed two inputs (the occlusion-fade arming gate excluded different modes on each host), a rule spelled beside the shared predicate (the page let Start open the pause menu over a shop), an input contract one host does not honour (a held word where the menu wanted edges, so one press stepped several rows), a typed channel narrowed at one host, and opposite defaults on one knob. Four of these shapes became tiers; the rest are decided per instance.

Some differences are deliberate and listed as such: the two default key layouts (a desktop layout and WASD walking cannot share one table), the output level (the site's pages share a quieter level), the look-and-feel preset the launcher sets in one row and the page in two boxes (the window’s F12 panel carries both halves live, as Original game only and PlayStation pixel look), the in-frame settings panel only a window needs, and the platform-only features: the touch pad, quality step-down and VR on the page, netplay hosting and the pack folder watch in the window.

Where each shared decision lives

The full page keeps one row per decision that has one engine implementation both hosts call - the frame loop and its clocks, cameras, lighting, screen effects, menus, saves, audio routing - and most rows are pinned by a tier-3 row on the kernel's name. A host that re-spells one locally is drift.

  • Gaps absent from both hosts fail no tier, because a symmetric absence is parity. The ones found that way - the overworld curvature, the field drop shadow, the battle ground shadow, the scene-entry fade - now draw on both hosts through one kernel each.
  • The field readout over the battle transition: retail draws none, because the transition overlay loads into the slot the HUD lives in; one suppress kernel carries that term for both hosts (the party readout).
  • Adding coverage is mostly declaring: a screen appears on the surface by existing, a paired constant or feature joins by its row, a diagnostic by being declared, a boot install by being an engine call both boots make, and a situation by a scenario in the frame-body trace that fails when the run never reached it.

See also