Browser frame hash: did this bundle draw the same picture?
scripts/ci/frame-hash-web.mjs is the browser’s equivalence proof for a change that should move how fast a frame arrives and nothing else. It steps the play page one sim tick at a time on a scripted pad, reads every frame back, hashes it and compares two bundles line for line. Hashes are never committed: they differ between drivers.
At a glance
scripts/ci/frame-hash-web.mjs --site site --out new.txt # hash one site
scripts/ci/frame-hash-web.mjs --compare base.txt new.txt # line for line
scripts/ci/frame-hash-web.mjs --site new/site --base-site old/site --out dir # both, then compare
scripts/ci/frame-hash-web.mjs --gl egl --no-msaa --out new.txt # the fast formWhat a run does
| Scene | Drives |
|---|---|
town01 | A dense town, walked down, up and left by the pad script |
world_map | map01, the same script |
battle | map01, a forced fight, then the confirm button on a rhythm |
cast | map01, a forced fight with a big summon seeded in flight |
bio_castle | jouina, the same walk, every env morph live. By name only (--scene), for a change to them |
menu, dialog, shop | The pause menu’s rows; a talk through its pages; a first shop. By name only, and each fails if its screen never came up or its overlay hardly changes |
results, levelup | A fight to its result windows; eleven fights with the first level-ups on the way, hashed from the ninth fight’s end. By name only, with the same check |
Two modes: retail ticks the page’s own Retail-faithful and PSX rasterisation boxes (a 320x240 canvas), and enhanced is a fresh browser profile’s defaults. The scene is entered and the page’s animation loop stopped in one task, so no frame runs on the wall clock. Each frame is then the page’s own single step: PlayView.step(), then _frame(), which runs one sim tick and resyncs the sim clock.
Each line holds three hashes. gl is the default framebuffer read back, ov the 2D overlay canvas the menus and dialogue draw on, and st the engine’s state_json. A difference in st is the simulation; one in gl alone is the renderer. DOM text and a movie’s own canvas are not in it.
What makes agreeing mean something
A run fails if a scene is vacuous: under a tenth of its frames distinct, over half blank, a pad script that did not move the player, or a battle that never came up. These were all run:
| Check | How | Result |
|---|---|---|
| One bundle, two runs | Run twice, compare | Agree |
| The play bundle against the tool bundle, which links the same play host | One tree, a site with each on the play page | Agree |
| Two builds of one tree | build-wasm.sh --fast twice, the second into a fresh target directory | The same bytes, and agree |
| The shipped build against the iteration build | build-wasm.sh against --fast: different bytes | Agree |
| A pixel moved, by the driver | --poke scene/mode:frame changes one pixel before the read | Exactly that frame differs |
| A pixel moved, in the page | A scratch site whose frame clears one pixel | Every frame of every scene and mode differs, in the gl column only |
What was not repeatable, and what was
A hardware GPU’s MSAA resolve. On the real driver, the same frame on the same bundle comes back now and then with a few pixels off by a little, a different frame each run, in both modes and with every enhancement off. Ask for a context without MSAA and it stops; SwiftShader, which keeps the page’s context as it stands, never does it. So the default is SwiftShader and --gl egl --no-msaa is the fast form. No tolerance is applied to either.
A 2D canvas’s raster. Read back every frame, the browser moves the overlay from GPU to software raster at a point that depends on how much was drawn between reads, and the two differ by a few pixels on a scaled quad. So the driver asks every 2D context for willReadFrequently, and the hash reads the software raster on both sides.
Nothing else moved a frame. Each of these changes when things happen against the ticks, and hashes stayed line for line:
| Moved | How | Reads as |
|---|---|---|
| The wall clock | --real-clock | The stepped path reads no clock that reaches a frame |
| The audio pump and disc reads | --jitter seed | The sim is not paced by audio |
| The prewarm, and on an older bundle the disc fill | --idle ms, --no-fill-wait | Neither changes what is drawn |
| A movie’s read and its end | scene movie (by name) | The frames after a movie agree; the frames under its overlay hash the world’s state only |
A live frame’s ticks come from the wall clock, which the step replaces. The ocean’s wall-clock CLUT animation belongs to the world-overview page. A movie is paced by the mixer’s XA cursor, the page’s audio position or the wall clock, so a movie is outside this oracle.
When two runs differ
--compare names the column first, which splits simulation from renderer. --shots dir --shot-every n writes PNGs and waits between frames, which can hide a difference; --raw dir writes every frame’s raw RGBA after the run. --uncheck play-ssao,play-dyn-light clears the page’s own option boxes first, and --no-msaa asks for a context without MSAA. All of these are Sony-derived: a scratch directory, never the repo.