#!/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 --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())