At a glance

Base site New site PlayView.step(), _frame()one tick, pad keyed by frame Read back, hashgl, overlay, state Line for linefirst differing frame
Each scene runs in each mode on a fresh page, so no scene inherits another’s state. The hash run is never the timing run, since every frame reads back.
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 form

What a run does

SceneDrives
town01A dense town, walked down, up and left by the pad script
world_mapmap01, the same script
battlemap01, a forced fight, then the confirm button on a rhythm
castmap01, a forced fight with a big summon seeded in flight
bio_castlejouina, the same walk, every env morph live. By name only (--scene), for a change to them
menu, dialog, shopThe 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, levelupA 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:

CheckHowResult
One bundle, two runsRun twice, compareAgree
The play bundle against the tool bundle, which links the same play hostOne tree, a site with each on the play pageAgree
Two builds of one treebuild-wasm.sh --fast twice, the second into a fresh target directoryThe same bytes, and agree
The shipped build against the iteration buildbuild-wasm.sh against --fast: different bytesAgree
A pixel moved, by the driver--poke scene/mode:frame changes one pixel before the readExactly that frame differs
A pixel moved, in the pageA scratch site whose frame clears one pixelEvery 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:

MovedHowReads as
The wall clock--real-clockThe stepped path reads no clock that reaches a frame
The audio pump and disc reads--jitter seedThe sim is not paced by audio
The prewarm, and on an older bundle the disc fill--idle ms, --no-fill-waitNeither changes what is drawn
A movie’s read and its endscene 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.

See also