John looked at R41's proof shots and named three faults. All three are fixed, at zero draw cost, and every lane corrected somebody's numbers on the way — including its own. E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief, quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library called colour was baked AO. So the fix was a palette imposed on colour already present, in place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints and origins provably did not move. (A first-pass bbox check ignored node rotations and "found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s office teal and went burgundy; the beer umbrella was white and went brewery green. THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench = 1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl, red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43, not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage). E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the R41 deny-list's failure class through a different door: an innocent name from another pipeline. Only rendering all 24 and LOOKING found it. Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran 65..99 bones; named characters carry facial/hair bones). THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo, Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real. B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then measured: the atlas canvases are already byte-identical across boots, the game ships NO webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600, ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks 2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default). THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached 0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed otherwise — the side term was already right, which is why it survived 41 rounds of reading. Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth. C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control. E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every booth part sat 25mm sunk into its own worktop. D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex is in the identity signature and the impostor atlas; removal would renumber the town). Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a fresh default boot fetches 32 ped GLBs, none retired. THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only 3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently dropping six tracks per clip since it shipped. Testing the thing being thrown away found it. Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s race against a live sim), not deterministically red. F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law (0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified; CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict. R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there — the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with everything else frozen was the last thing moving at 53-62 px. ?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed. Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647, 0x5f76e76 unmoved. No tag — John's playtest rules the epoch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
323 lines
19 KiB
Python
323 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""PROCITY Lane F — R42 §42.6 · THE BYTE-HASH GATE. The R41 ruling, reversed on measurement.
|
|
|
|
tools/.venv/bin/python tools/qa/r42_repro.py [--boots 3] [--goldens PATH] [--write-goldens]
|
|
|
|
R41 §41.6 ruled: **no gate may pin a screenshot byte-hash**, because 4 of 5 classic bookmarks gave 3
|
|
distinct hashes over 3 boots. F blamed the sign-atlas font race. **F was wrong about the cause.**
|
|
Lane B proved it in §42.5-C: the atlas canvases are byte-identical across boots, `document.fonts` is
|
|
an EMPTY set in this game (all text is canvas) and `fonts.ready` resolves ~55 ms before the first
|
|
`fillText` on 6 of 6 boots. What moves the frame is **the CROWD** — the citizen sim advances on
|
|
wall-clock time, so two boots settled for the same 2.5 s are two different streets (983 changed px,
|
|
all of them in the pedestrian band, rows 361-418).
|
|
|
|
So the condition for a byte-stable frame is not "the font landed". It is **"nothing in frame runs on
|
|
wall-clock time"**, and that is a thing a harness can ASSERT rather than hope for:
|
|
|
|
?classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0 + DBG.freeze() after the pose
|
|
and DBG.freeze().stillLive must come back ALL FALSE.
|
|
|
|
`?pop=0` is NOT a substitute and never was (`index.html` read `|| 140`, and 0 is falsy — fixed this
|
|
round in F's own file, and arm 0 below is the regression test for that fix).
|
|
|
|
TWO MORE THINGS HAD TO BE FOUND BEFORE THIS RAN 5/5, and both are load-bearing lines below:
|
|
· **settle until `DBG.info()` stops changing**, never for a fixed number. The CHUNK STREAMER is the
|
|
wall-clock process nobody had named; `crossroads_busy` read 2-of-3 inside a full gate run and
|
|
3-of-3 byte-identical standalone, and the only difference was machine load.
|
|
· **pin the RENDER, not the instrument.** With everything else frozen, the settled scene tuple
|
|
identical on every boot and the camera equal to nine decimals, two bookmarks still split — by
|
|
53-62 px at (1252,82), which is `#pc-fps` printing `fps 20` vs `fps 21`. An element screenshot
|
|
does not exclude a DOM overlay, so `#pc-dbg` is hidden for the capture. The whole-page hash is
|
|
still taken and reported so "the render moved" and "the instrument moved" stay distinguishable.
|
|
|
|
WHAT THIS GATE PINS, and why it is not a committed golden. It boots each bookmark N times in FRESH
|
|
contexts and requires ONE distinct sha256 across them. That is the property the ruling is about —
|
|
"a screenshot of this frame is a deterministic function of the tree" — and it cannot rot when Lane E
|
|
repaints a couch. A committed cross-round golden VALUE is available (`--goldens PATH`) but is not
|
|
wired into qa.sh: R42 alone moved 46 prop colours, five bodies, a bench yaw and the booth
|
|
composition, and a committed value would have gone red on all four while telling us nothing.
|
|
|
|
ARMS
|
|
0. `?pop=0` means zero — F's fix to index.html:454; the CONTROL is the same boot
|
|
without the flag, which must still hold a full roster.
|
|
1. freeze reports stillLive false — the assertion the ruling now requires of any hash-pinning gate.
|
|
2. N boots, one hash per bookmark — the pin itself, five bookmarks.
|
|
3. CONTROL: no freeze — the same bookmarks, same boots, WITHOUT the freeze, which must
|
|
produce more than one hash somewhere. A pin that is green with
|
|
and without its precondition is not testing the precondition.
|
|
"""
|
|
import sys, os, json, time, socket, hashlib, subprocess, pathlib
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
|
PORT = int(os.environ.get('PROCITY_R42_REPRO_PORT', '8987'))
|
|
HOST = f'http://127.0.0.1:{PORT}'
|
|
SEED = 20261990
|
|
BOOTS = int(sys.argv[sys.argv.index('--boots') + 1]) if '--boots' in sys.argv else 3
|
|
GOLDENS = pathlib.Path(sys.argv[sys.argv.index('--goldens') + 1]) if '--goldens' in sys.argv else None
|
|
WRITE = '--write-goldens' in sys.argv
|
|
# NB there is deliberately no fixed settle constant here — see settle() below. The 2.5 s every lane
|
|
# has been using is a bet on how fast this box streams chunks today, and it is the bet that made the
|
|
# first cut of this gate report a bookmark 2-of-3 that was 3-of-3 standalone.
|
|
|
|
# The five classic bookmarks R41 measured. `?classic=1` because that is the arm whose whole point is
|
|
# byte-stability (the zero-fetch-delta covenant), and because it is the arm every previous round's
|
|
# hash table used — so today's numbers are comparable to R41's and B's.
|
|
BOOKMARKS = ['street_noon', 'shopfront_detail', 'crossroads_busy', 'market_square', 'night_neon']
|
|
FLAGS = 'classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0'
|
|
|
|
fails = []
|
|
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
|
|
def OK(m): print(f" \033[32m✓\033[0m {m}")
|
|
def head(m): print(f"\n\033[1m{m}\033[0m")
|
|
def note(m): print(f" \033[33m·\033[0m {m}")
|
|
|
|
NOSTORE = r'''
|
|
import sys, http.server, functools
|
|
class H(http.server.SimpleHTTPRequestHandler):
|
|
def end_headers(self):
|
|
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
|
super().end_headers()
|
|
def log_message(self, *a): pass
|
|
http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])),
|
|
functools.partial(H, directory=sys.argv[2])).serve_forever()
|
|
'''
|
|
|
|
|
|
def port_up(port):
|
|
with socket.socket() as s:
|
|
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
|
|
|
|
|
|
def serve():
|
|
p = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
for _ in range(80):
|
|
if port_up(PORT): return p
|
|
time.sleep(0.1)
|
|
p.terminate(); raise SystemExit(f'could not serve on :{PORT}')
|
|
|
|
|
|
def new_page(p):
|
|
b = p.chromium.launch()
|
|
pg = b.new_page(viewport={'width': 1280, 'height': 720})
|
|
errs = []
|
|
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
|
|
pg.on('pageerror', lambda e: errs.append(str(e)))
|
|
return b, pg, errs
|
|
|
|
|
|
def boot(pg, query):
|
|
pg.goto(f'{HOST}/index.html?seed={SEED}&{query}')
|
|
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
|
|
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
|
|
|
|
|
KEYS = ('drawCalls', 'tris', 'chunks', 'geometries', 'textures')
|
|
|
|
|
|
def settle(pg, cap_ms=12000, step=250):
|
|
"""WAIT UNTIL THE SCENE STOPS CHANGING, don't wait a number someone picked.
|
|
|
|
A fixed 2.5 s settle is a bet on how fast this box streams chunks today, and it is the bet that
|
|
made the first cut of this gate report `crossroads_busy` as 2-of-3: the same three boots run
|
|
standalone were 3-of-3 byte-identical, and the difference was six browser launches of machine
|
|
load in front of them. The chunk streamer is the wall-clock process nobody had named — the crowd
|
|
is only the one you can see. So poll `DBG.info()` and require the whole tuple (draws · tris ·
|
|
chunks · geometries · textures) to repeat before capturing, and report the tuple so a hash
|
|
mismatch arrives WITH its diagnosis instead of as a bare inequality."""
|
|
prev, waited = None, 0
|
|
while waited < cap_ms:
|
|
i = pg.evaluate("() => window.DBG.info()")
|
|
cur = tuple(i.get(k) for k in KEYS)
|
|
if cur == prev: return cur, waited
|
|
prev = cur
|
|
pg.wait_for_timeout(step); waited += step
|
|
return prev, waited
|
|
|
|
|
|
def shot_hash(pg, bookmark, freeze):
|
|
"""One capture. `DBG.shot` poses the camera and pins the clock; `DBG.freeze` takes the crowd out.
|
|
ORDER MATTERS: shot() first (it teleports and warms chunks, which SPAWNS citizens), freeze()
|
|
second. Freezing before the pose leaves the newly streamed chunk's crowd in the picture."""
|
|
pg.evaluate(f"() => window.DBG.shot('{bookmark}')")
|
|
tup, waited = settle(pg)
|
|
fz = pg.evaluate("() => window.DBG.freeze()") if freeze else None
|
|
pg.wait_for_timeout(400)
|
|
fz2 = None
|
|
if freeze:
|
|
# re-assert: the sim keeps promoting citizens to near-tier rigs behind the hidden group
|
|
# (measured: rigged 6 → 9 across a 2.5 s settle), and a chunk that lands after the first
|
|
# freeze arrives with its own crowd. The second call must report a ZERO draw delta — that
|
|
# is the proof nothing came back, and it is what makes the pin an assertion.
|
|
fz2 = pg.evaluate("() => window.DBG.freeze()")
|
|
# PIN THE RENDER, NOT THE INSTRUMENT. This is the last thing standing between PROCITY and a
|
|
# byte-stable frame, and it took a pixel diff to find: with the crowd frozen, the clock paused,
|
|
# the settled scene tuple IDENTICAL on every boot and the camera transform equal to nine decimal
|
|
# places, `crossroads_busy` and `night_neon` still split 2/3. The whole difference was **53-62
|
|
# pixels in a 7x10 box at (1252,82)** — `#pc-fps` in the `?dbg=1` readout printing `fps 20` on
|
|
# one boot and `fps 21` on the next. Nothing in the game moved. The frame rate is not the frame.
|
|
#
|
|
# NB an element screenshot does NOT exclude an overlay: the canvas is full-viewport and the HUD
|
|
# is a DOM layer composited on top of it, so `locator('#canvas').screenshot()` still captures the
|
|
# HUD's pixels — measured, the bbox was identical before and after switching to the locator.
|
|
# `#pc-dbg` is therefore hidden for the capture and restored after. `visibility` rather than
|
|
# `display` so nothing reflows. The whole-page hash is still taken and reported so the
|
|
# distinction between "the render moved" and "the instrument moved" is in the log every run.
|
|
sel = pg.evaluate("() => { const c = window.PROCITY.renderer.domElement; "
|
|
"if (!c.id) c.id = 'pc-gl-shot'; "
|
|
"const d = document.getElementById('pc-dbg'); if (d) d.style.visibility = 'hidden'; "
|
|
"return '#' + c.id; }")
|
|
gl = pg.locator(sel).screenshot(type='png')
|
|
pg.evaluate("() => { const d = document.getElementById('pc-dbg'); if (d) d.style.visibility = ''; }")
|
|
page = pg.screenshot(type='png')
|
|
return (hashlib.sha256(gl).hexdigest(), hashlib.sha256(page).hexdigest()), tup, waited, fz, fz2
|
|
|
|
|
|
def main():
|
|
srv = serve()
|
|
rc = 0
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
with sync_playwright() as p:
|
|
# ── arm 0: ?pop=0 actually means zero (F's index.html fix) ───────────────────────────
|
|
head('0. `?pop=0` MEANS ZERO — the regression test for F\'s §42.6 fix to index.html:454')
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
boot(pg, f'{FLAGS}&roster=v1&pop=0')
|
|
pg.wait_for_timeout(1200)
|
|
st = pg.evaluate("() => ({ ...window.PROCITY.citizens.stats, target: window.PROCITY.citizens.target })")
|
|
note(f"?roster=v1&pop=0 → {json.dumps(st)}")
|
|
if (st.get('active') or 0) != 0:
|
|
FAIL(f"?pop=0 still boots {st.get('active')} active citizens — the `|| 140` fix did not land")
|
|
else:
|
|
OK('?roster=v1&pop=0 → 0 active citizens (before the fix this was 140 / 96 active — B §42.5-C)')
|
|
if errs: FAIL(f'pop=0 arm: console error(s) {errs[:2]}')
|
|
finally:
|
|
b.close()
|
|
b, pg, errs = new_page(p)
|
|
try: # CONTROL: the same boot WITHOUT the flag must still hold a full roster
|
|
boot(pg, f'{FLAGS}&roster=v1')
|
|
pg.wait_for_timeout(1200)
|
|
st = pg.evaluate("() => ({ ...window.PROCITY.citizens.stats, target: window.PROCITY.citizens.target })")
|
|
if (st.get('active') or 0) == 0:
|
|
FAIL('CONTROL VACUOUS: ?roster=v1 with no pop flag also boots 0 citizens — '
|
|
'the arm above proves nothing')
|
|
else:
|
|
OK(f"CONTROL — the same boot with no `pop` flag holds {st['active']} active "
|
|
f"(target {st.get('target')}): the default is untouched, only 0 changed meaning")
|
|
finally:
|
|
b.close()
|
|
|
|
# ── arms 1-2: the pin ────────────────────────────────────────────────────────────────
|
|
head(f'1-2. THE PIN — {BOOTS} fresh contexts per bookmark, ?{FLAGS} + DBG.freeze()')
|
|
table, goldens = {}, {}
|
|
for bm in BOOKMARKS:
|
|
hs, pgs, live, rows, tups = [], [], [], [], []
|
|
for i in range(BOOTS):
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
boot(pg, FLAGS)
|
|
(h, ph), tup, waited, fz, fz2 = shot_hash(pg, bm, freeze=True)
|
|
hs.append(h); pgs.append(ph); tups.append(tup)
|
|
live.append(fz2['stillLive'])
|
|
rows.append(dict(crowd=fz['draws']['before'] - fz['draws']['after'],
|
|
after=fz['draws']['after'], active=fz['peds']['active'],
|
|
back=fz2['draws']['before'] - fz2['draws']['after'], settle=waited))
|
|
if errs: FAIL(f'{bm} boot {i}: console error(s) {errs[:2]}')
|
|
finally:
|
|
b.close()
|
|
n, np_ = len(set(hs)), len(set(pgs))
|
|
table[bm] = dict(distinct=n, page=np_, hashes=hs, rows=rows, settled=[list(t) for t in tups])
|
|
goldens[bm] = hs[0]
|
|
# arm 1 — the assertion the reversed ruling REQUIRES of a hash-pinning gate
|
|
bad = [k for st in live for k, v in st.items() if v]
|
|
if bad:
|
|
FAIL(f'{bm}: DBG.freeze().stillLive is not all-false — {sorted(set(bad))} still run on '
|
|
f'wall-clock time. A hash pinned on this frame is a coin toss.')
|
|
came_back = [r['back'] for r in rows if r['back']]
|
|
if came_back:
|
|
FAIL(f'{bm}: the second freeze() still removed {came_back} draw(s) — something re-entered '
|
|
f'the frame after the first freeze, so the capture raced the streamer')
|
|
# arm 2 — the pin, and its diagnosis when it fails
|
|
if n != 1:
|
|
diag = ('the settled scene tuple differs between boots: '
|
|
+ ' vs '.join(str(t) for t in sorted(set(tups)))) if len(set(tups)) > 1 else \
|
|
(f'the settled scene tuple is IDENTICAL on all {BOOTS} boots {tups[0]} — '
|
|
f'so the difference is in the render, not in what was loaded')
|
|
FAIL(f'{bm}: {n} distinct hashes over {BOOTS} frozen boots — not byte-reproducible. {diag}')
|
|
else:
|
|
OK(f'{bm}: canvas 1 of {BOOTS} — {hs[0][:16]}… · the crowd was {rows[0]["crowd"]} of '
|
|
f'{rows[0]["crowd"] + rows[0]["after"]} draws ({rows[0]["active"]} active citizens) · '
|
|
f'0 came back · settled in {max(r["settle"] for r in rows)} ms · stillLive all-false')
|
|
if np_ != 1 and n == 1:
|
|
note(f'{bm}: the whole-PAGE hash is {np_} of {BOOTS} while the RENDER is 1 of {BOOTS} — '
|
|
f"that difference is `#pc-fps` in the ?dbg=1 readout (measured: 53-62 px at "
|
|
f'(1252,82), "fps 20" vs "fps 21"). Pin the render; the frame rate is not the frame.')
|
|
|
|
# ── arm 3: THE CONTROL — the same pin with the freeze taken away ─────────────────────
|
|
head('3. CONTROL — the identical loop WITHOUT DBG.freeze(): the pin must be able to FAIL')
|
|
unstable = []
|
|
for bm in BOOKMARKS:
|
|
hs = []
|
|
for i in range(BOOTS):
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
boot(pg, FLAGS)
|
|
(h, _ph), _t, _w, _f, _f2 = shot_hash(pg, bm, freeze=False)
|
|
hs.append(h)
|
|
finally:
|
|
b.close()
|
|
n = len(set(hs))
|
|
table[bm]['nofreeze'] = n
|
|
if n > 1: unstable.append(f'{bm} {n}/{BOOTS}')
|
|
note(f'{bm}: {n} distinct hash(es) over {BOOTS} unfrozen boots')
|
|
if not unstable:
|
|
FAIL(f'CONTROL VACUOUS: every bookmark was byte-stable WITHOUT the freeze too, so this '
|
|
f'run does not show the freeze is what buys the pin. Re-run: the crowd has to be '
|
|
f'in at least one of these frames for the control to mean anything.')
|
|
else:
|
|
OK(f'the freeze is load-bearing — unfrozen, {len(unstable)} of {len(BOOKMARKS)} bookmarks '
|
|
f'give more than one hash ({", ".join(unstable)}); frozen, all give one')
|
|
|
|
# ── optional: the committed-golden mode, deliberately NOT wired into qa.sh ───────────
|
|
if GOLDENS:
|
|
if WRITE:
|
|
GOLDENS.parent.mkdir(parents=True, exist_ok=True)
|
|
GOLDENS.write_text(json.dumps({'schema': 'procity-shot-goldens/2', 'seed': SEED,
|
|
'viewport': [1280, 720], 'flags': FLAGS,
|
|
'recipe': 'DBG.shot(bookmark) -> settle until DBG.info() repeats (draws·tris·'
|
|
'chunks·geometries·textures) -> DBG.freeze() -> 400 ms -> freeze() '
|
|
'again (must remove 0 draws, stillLive all-false) -> hide #pc-dbg -> '
|
|
'sha256 of a PNG of the RENDERER CANVAS (never the whole page: the '
|
|
'HUD fps digit is the last non-determinism in a frozen frame)',
|
|
'settle': 'adaptive, cap 12000 ms, step 250 ms',
|
|
'boots_agreeing': BOOTS, 'regenerate':
|
|
'tools/.venv/bin/python tools/qa/r42_repro.py --goldens <this file> --write-goldens',
|
|
'recheck': 'the same command without --write-goldens',
|
|
'hashes': goldens}, indent=1) + '\n')
|
|
OK(f"wrote {len(goldens)} goldens → {GOLDENS}")
|
|
else:
|
|
old = json.loads(GOLDENS.read_text())['hashes']
|
|
for bm, h in goldens.items():
|
|
if old.get(bm) != h: FAIL(f'{bm}: golden {str(old.get(bm))[:16]}… ≠ measured {h[:16]}…')
|
|
OK(f'{len(goldens)} committed goldens re-measured')
|
|
|
|
print('\n' + json.dumps({k: {'frozen_canvas': v['distinct'], 'frozen_page': v['page'],
|
|
'unfrozen_canvas': v.get('nofreeze')}
|
|
for k, v in table.items()}, indent=1))
|
|
finally:
|
|
srv.terminate()
|
|
print('')
|
|
if fails:
|
|
print(f'\033[31m● FAIL\033[0m — {len(fails)} problem(s):')
|
|
for f in fails: print(f' · {f}')
|
|
rc = 1
|
|
else:
|
|
print(f'\033[32m● PASS\033[0m — every bookmark pins to one sha256 over {BOOTS} frozen boots, '
|
|
f'stillLive all-false, and the unfrozen control can still fail')
|
|
return rc
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|