Host drift: keeping three hosts on one engine
The port ships one engine behind three framebuffers: the native play window, the browser play page and the browser minigames page. A feature wired into one host and not another is invisible in a diff, because no file holds both columns. This page covers the gates that catch that class of defect, what each cannot see, and the two runs that compare the hosts' actual output.
At a glance
| Host | Crate | Driven by |
|---|---|---|
| Native play window | engine-shell | wgpu through engine-render |
| Browser play page | web-play | site/js/play-app.js and its view files |
| Browser minigames page | web-minigames | per-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:
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, socheck-wasm-target.shtype-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
| Tier | Question | Blind to |
|---|---|---|
| 1 reachability | does each engine-ui draw builder reach both hosts? (derived: every builder is a screen) | arguments, runtime reach |
| 2 paired constants | do two named constants carry one value? | how the value is used |
| 3 simulation | do two injection sites name the same kernels? | arguments, order |
| 4 trait overrides | does every implementer override the same defaulted hooks? | what the bodies do |
| 5 input | does a page carry its own keyboard table? | - |
| 6 diagnostics | is an additive debug draw default-off on both hosts? | an undeclared debug draw |
| 7 render kernels | does every surface building a draw list name its kernel? | a kernel with no rule |
| 8 ownership | does each host hold the engine type, or only read it? | how it is driven |
| 9 variant coverage | does each host answer every enum variant? | whether the arm draws |
| 10 entry symmetry | is a phase armed only from a debug key? | data decoded beside the phase |
| 11 frame path | does an early exit skip a kernel the frame still draws? | whether paired kernels do the same work |
| 12 content | do two paired frame kernels reach the same engine calls? | arguments, guards |
| 13 boot installs | does the page's boot make every install the native boot makes? | tables written as field assignments |
| 14 save routing | does every save and load go through the retail screen's applier? | the page's save-bar import |
| 15 engine environment | does an engine crate read a switch from the process environment? | a crate no browser host links |
| 16 page cues | does the minigames page key the live SPU rather than play decoded PCM? | whether the id is right |
| 17 page state | does 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 green | What it looked like |
|---|---|
| Sticky GL state | leaving the dance hall left its face winding behind, and the field drew with its ground gone |
| A fill rule on a bottom-up window | every sprite drew a row low; the slot machine's dot matrix lost its top rows |
| A page clock over an engine clock | the dance hall's panels flipped once a page frame instead of on the dance's own clock |
| An actor watermark set early | the window's battle floor took no ambient occlusion where the page's did |
| A pass the other host never runs | the window ran ambient occlusion in the minigame venues; both now ask one scope test |
| A cut on half a buffer | a stage curtain seen from behind covered the whole frame for about a hundred ticks |
| A wash run twice | a 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.