#!/usr/bin/env python3 """PROCITY Lane D — R41 §41.3 runtime gate: THE MOTION LIBRARY IN THE LIVE GAME. tools/.venv/bin/python tools/qa/r41_citizens.py [--seed N] 0 green · 1 red Six arms. Every arm carries the control that makes it non-vacuous, and every number is measured in a FRESH headless context against a no-store server (this project's documented ES-module cache burn). 1. BOOT LEDGER what the library actually costs: clip fetches at boot, bytes, groups resident, and the heap delta against `?clips=0` — a control arm that turns off the motion library and NOTHING else (`?classic=1` changes the ped pool, the fog, the game and half the shell, so it cannot measure this). 2. LAZY LOADING the four non-boot groups are NOT fetched at boot, and browse.glb arrives on the first interior with browsers. CONTROL: it is absent before that visit. 3. DETERMINISM two fresh contexts, same seed → byte-equal posture signature over the whole active crowd. CONTROL: a different seed differs. Plus: the assignment is independent of LOAD ORDER — the same run with the library forced late still produces the same signature, which is the property lazy loading could break. 4. DRAW TABLE worst street view and worst interior, `?clips=0` vs default, over the same bookmarks and the same seed. Ruling 4: the street law is the boot's own declared budget and there is no room, so the delta must be ≤ 0 in effect. 5. ASSET-FREE `?noassets=1` runs (chunks build, no console errors, 0 clip fetches, 0 clipbank module fetch) and `?classic=1` keeps its zero-fetch-delta covenant. 6. BENCH BINDING every bench station the sim derives for a LOADED chunk coincides with real instanced geometry in Lane B's scene, position and yaw. CONTROL: the same stations offset by 2 m must NOT match — otherwise "coincides" means nothing. Plus a leak arm: repeated interior enter/exit leaves geometries/textures flat. """ import sys, os, time, json, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = int(os.environ.get('PROCITY_R41_PORT', '8986')) HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1]) BOOT_GROUPS = {'idles.glb', 'locomotion.glb'} LAZY_GROUPS = {'browse.glb', 'sitlean.glb', 'venue.glb', 'social.glb'} BOOKMARKS = ['street_noon', 'crossroads_busy', 'night_crowd', 'market_square', 'night_neon'] 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}") def check(c, m): (OK if c else FAIL)(m); return c 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(p): with socket.socket() as s: s.settimeout(0.4); return s.connect_ex(('127.0.0.1', p)) == 0 def serve(): pr = 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 pr time.sleep(0.1) pr.terminate(); raise SystemExit(f'could not serve on :{PORT}') def new_page(p): b = p.chromium.launch(args=['--js-flags=--expose-gc']) pg = b.new_page(viewport={'width': 1280, 'height': 720}) errs, reqs = [], [] pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) pg.on('pageerror', lambda e: errs.append(str(e))) pg.on('request', lambda r: reqs.append(r.url)) return b, pg, errs, reqs def boot(pg, q='', dbg=True, wait=True): pg.goto(f'{HOST}/index.html?seed={SEED}' + (('&' + q) if q else '') + ('&dbg=1' if dbg else '')) if wait: if dbg: pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000) else: pg.wait_for_function('window.PROCITY && window.PROCITY.chunks && window.PROCITY.chunks.count > 0', timeout=45000) pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") def clip_reqs(reqs): return [u.rsplit('/', 1)[-1].split('?')[0] for u in reqs if '/models/clips/' in u or 'motion_manifest.json' in u or 'clipbank.js' in u] HEAP = "() => { if (window.gc) window.gc(); return performance.memory ? performance.memory.usedJSHeapSize : null; }" # max draw calls over N successive natural frames at the current pose DRAWMAX = r""" async (n) => { const P = window.PROCITY; let mx = 0, mt = 0; for (let i = 0; i < n; i++) { await new Promise(r => requestAnimationFrame(() => r())); const r = P.renderer.info.render; if (r.calls > mx) mx = r.calls; if (r.triangles > mt) mt = r.triangles; } return { draws: mx, tris: mt, budget: (P.budget || {}).draws || 300 }; } """ BENCHES = r""" async (offset) => { const P = window.PROCITY, C = P.citizens; const mod = await import('./js/citizens/sim.js'); const M4 = P.scene.matrixWorld.constructor; const m = new M4(); // every instanced world transform standing in the built scene right now const inst = []; P.scene.traverse(o => { if (!o.isInstancedMesh) return; o.updateWorldMatrix(true, false); for (let i = 0; i < o.count; i++) { o.getMatrixAt(i, m); const w = m.clone().premultiply(o.matrixWorld), e = w.elements; inst.push({ x: e[12], z: e[14], yaw: Math.atan2(e[8], e[10]) }); } }); // ...against the sim's own derivation, restricted to chunks Lane B has actually BUILT (a station // in an unstreamed chunk has no geometry to match and would be a false negative). const cam = P.camera.position; let tested = 0, hit = 0, yawOk = 0; const misses = []; for (let i = 0; i < C.edges.length; i++) { for (const st of mod.benchStationsFor(C.edges[i])) { if (Math.hypot(st.x - cam.x, st.z - cam.z) > 90) continue; // inside the streamed window const tx = st.x + offset, tz = st.z; tested++; let bd = 1e9, by = 0; for (const p of inst) { const d = Math.hypot(p.x - tx, p.z - tz); if (d < bd) { bd = d; by = p.yaw; } } if (bd <= 0.02) { hit++; let dy = Math.abs(((st.yaw - by) % (Math.PI * 2) + Math.PI * 3) % (Math.PI * 2) - Math.PI); if (dy < 1e-3 || Math.abs(dy - Math.PI) < 1e-3) yawOk++; // exact, mod 2pi } else if (misses.length < 5) misses.push({ x: +st.x.toFixed(2), z: +st.z.toFixed(2), nearest: +bd.toFixed(3) }); } } return { tested, hit, yawOk, misses, instances: inst.length }; } """ POSTURE_SIG = "() => window.PROCITY.citizens.postureSignature().join('\\n')" CLIP_STATS = "() => window.PROCITY.citizens.clipStats()" def main(): from playwright.sync_api import sync_playwright srv = serve() try: with sync_playwright() as p: # ── 1 + 2: boot ledger and lazy loading ────────────────────────────────────────────── head('1. BOOT LEDGER — what the motion library costs at boot, measured') b, pg, errs, reqs = new_page(p) boot(pg) pg.wait_for_function("() => window.PROCITY.fleet && window.PROCITY.fleet.bank && window.PROCITY.fleet.bank.manifest", timeout=20000) pg.wait_for_timeout(1500) boot_fetch = clip_reqs(reqs) st_on = pg.evaluate(CLIP_STATS) heap_on = pg.evaluate(HEAP) glbs = [f for f in boot_fetch if f.endswith('.glb')] check(set(glbs) == BOOT_GROUPS, f'boot fetches exactly the 2 eager groups: {sorted(glbs)} (+ manifest + clipbank.js ' f'= {len(boot_fetch)} requests)') check(st_on['manifest'], f"manifest parsed — catalogue {st_on['catalogue']} clips") note(f"resident at boot: {st_on['groups']} groups / {st_on['clips']} clips / {st_on['bytes']} B " f"({st_on['bytes'] / 1048576:.2f} MB of Lane E's 3.34 MB)") note(f"heap after boot (library ON): {heap_on / 1048576:.1f} MB" if heap_on else 'heap unavailable') head('2. LAZY LOADING — the other four groups are not paid for until they are wanted') check(not (set(glbs) & LAZY_GROUPS), f'none of {sorted(LAZY_GROUPS)} fetched at boot') # let the street run: sitlean.glb should arrive on the first sit/lean INTENT, not before pg.evaluate("() => window.DBG.shot('crossroads_busy')") pg.wait_for_timeout(12000) after_street = clip_reqs(reqs) check('sitlean.glb' in after_street, f'sitlean.glb arrives on the first street sit/lean intent (not at boot)') check('browse.glb' not in after_street and 'venue.glb' not in after_street, 'browse.glb + venue.glb still unfetched — CONTROL for the interior arm below') check('social.glb' not in after_street, 'social.glb never fetched (no two-person state yet — R42)') # first interior with browsers pulls browse.glb shop = pg.evaluate("""() => { const P = window.PROCITY, C = P.citizens; const c = (P.plan.shops || []).map(s => ({ id: s.id, n: C.occupancyOf(s.id).count })) .filter(s => s.n > 0).sort((a, b) => b.n - a.n)[0]; if (c) window.DBG.enterShop(c.id); return c || null; }""") pg.wait_for_timeout(3000) after_int = clip_reqs(reqs) if shop: check('browse.glb' in after_int, f'browse.glb arrives on the first interior with browsers (shop {shop["id"]})') else: note('no shop had occupants during this window — browse arm skipped') st_int = pg.evaluate(CLIP_STATS) note(f"resident after a street run + one interior: {st_int['groups']} groups / " f"{st_int['clips']} clips / {st_int['bytes']} B") check(not errs, f'0 console errors on the default boot ({len(reqs)} requests swept)') # ── 6b: leak — repeated enter/exit with more clips resident ─────────────────────────── head('6b. LEAK — repeated interior enter/exit with the library resident') pg.evaluate("() => window.DBG.exitShop()") pg.wait_for_timeout(800) # WARM FIRST, then measure. Cycle 1 legitimately grows the caches (that room's stock GLBs, # its wallpaper texture, the browse group) — counting it as a leak measures cold-start, not # retention. Same shop every cycle so the comparison is like for like. leak_shop = pg.evaluate("""() => { const P = window.PROCITY, C = P.citizens; const c = (P.plan.shops || []).map(s => ({ id: s.id, n: C.occupancyOf(s.id).count })) .filter(s => s.n > 0).sort((a, b) => b.n - a.n)[0]; return c ? c.id : (P.plan.shops[0] || {}).id; }""") pg.evaluate("(id) => window.DBG.enterShop(id)", leak_shop) pg.wait_for_timeout(1400) pg.evaluate("() => window.DBG.exitShop()") pg.wait_for_timeout(900) base = pg.evaluate("() => window.DBG.info()") for _ in range(6): pg.evaluate("(id) => window.DBG.enterShop(id)", leak_shop) pg.wait_for_timeout(900) pg.evaluate("() => window.DBG.exitShop()") pg.wait_for_timeout(700) after = pg.evaluate("() => window.DBG.info()") st_end = pg.evaluate(CLIP_STATS) dg = after['geometries'] - base['geometries'] dt = after['textures'] - base['textures'] check(abs(dg) <= 2 and abs(dt) <= 2, f'shop {leak_shop}, warm baseline then 6 more enter/exit cycles: geometries ' f'{base["geometries"]}→{after["geometries"]} ({dg:+d}), textures ' f'{base["textures"]}→{after["textures"]} ({dt:+d})') check(st_end['clips'] == st_int['clips'] and st_end['groups'] == st_int['groups'], f'clip bank does not grow on re-entry: {st_end["groups"]} groups / {st_end["clips"]} clips ' f'(one fetch per group, promise-cached)') b.close() # ── 1b: the ?clips=0 control arm (heap + fetch delta) ──────────────────────────────── head('1b. CONTROL ARM ?clips=0 — the library off, and nothing else changed') b, pg, errs, reqs = new_page(p) boot(pg, 'clips=0') pg.wait_for_timeout(3000) off_fetch = clip_reqs(reqs) heap_off = pg.evaluate(HEAP) st_off = pg.evaluate(CLIP_STATS) check(not off_fetch, f'?clips=0 fetches NOTHING clip-related — not the GLBs, not the manifest, ' f'not even clipbank.js ({len(reqs)} requests swept)') check(st_off['groups'] == 0 and st_off['clips'] == 0, '?clips=0: no bank, 0 clips resident') check(not errs, '?clips=0: 0 console errors') if heap_on and heap_off: note(f'HEAP DELTA (library ON − OFF): {(heap_on - heap_off) / 1048576:+.2f} MB ' f'(on {heap_on / 1048576:.1f} MB · off {heap_off / 1048576:.1f} MB)') b.close() # ── 3: determinism ─────────────────────────────────────────────────────────────────── head('3. DETERMINISM — same seed → same postures, byte-equal across fresh contexts') sigs = [] for i in range(2): b, pg, errs, reqs = new_page(p) boot(pg) pg.evaluate("() => window.DBG.shot('street_noon')") pg.wait_for_timeout(2500) sigs.append(pg.evaluate(POSTURE_SIG)) b.close() n = len(sigs[0].splitlines()) check(sigs[0] == sigs[1] and n > 0, f'{n} active citizens, two fresh browser contexts → byte-equal posture signature') b, pg, errs, reqs = new_page(p) pg.goto(f'{HOST}/index.html?seed={SEED + 1}&dbg=1') 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'; }") pg.evaluate("() => window.DBG.shot('street_noon')") pg.wait_for_timeout(2500) other = pg.evaluate(POSTURE_SIG) check(other != sigs[0], f'CONTROL: seed {SEED + 1} gives a DIFFERENT signature ' f'({len(other.splitlines())} citizens) — byte-equal is not constant') b.close() # load-order independence: the property lazy loading could plausibly break b, pg, errs, reqs = new_page(p) pg.route('**/models/clips/*.glb', lambda route: (time.sleep(2.0), route.continue_())[1]) boot(pg) pg.evaluate("() => window.DBG.shot('street_noon')") pg.wait_for_timeout(2500) slow = pg.evaluate(POSTURE_SIG) check(slow == sigs[0], 'CONTROL: with every clip GLB delayed 2 s, the signature is IDENTICAL — posture is ' 'assigned from (citySeed, id) alone, never from what happens to be resident') b.close() # ── 4: draw table ──────────────────────────────────────────────────────────────────── head('4. DRAW TABLE — worst street view and worst interior, library OFF vs ON') table = {} for arm, q in (('clips=0', 'clips=0'), ('default', '')): b, pg, errs, reqs = new_page(p) boot(pg, q) pg.wait_for_timeout(6000) row = {} for bm in BOOKMARKS: pg.evaluate('(n) => window.DBG.shot(n)', bm) pg.wait_for_timeout(2500) pg.evaluate('(n) => window.DBG.shot(n)', bm) d = pg.evaluate(DRAWMAX, 24) row[bm] = d # worst interior: enter the biggest room type we can reach pg.evaluate("() => window.DBG.enterShop('record')") pg.wait_for_timeout(2500) row['interior(record)'] = pg.evaluate(DRAWMAX, 24) pg.evaluate("() => window.DBG.exitShop()") table[arm] = row b.close() print(f" {'view':<20} {'clips=0':>10} {'default':>10} {'Δ':>6} budget") worst = {'clips=0': 0, 'default': 0} for k in list(table['clips=0']): a0, a1 = table['clips=0'][k]['draws'], table['default'][k]['draws'] bud = table['default'][k]['budget'] if not k.startswith('interior') else 350 if not k.startswith('interior'): worst['clips=0'] = max(worst['clips=0'], a0); worst['default'] = max(worst['default'], a1) flag = '' if a1 <= bud else ' OVER' print(f" {k:<20} {a0:>10} {a1:>10} {a1 - a0:>+6} <={bud}{flag}") check(a1 <= bud, f'{k}: {a1} draws <= {bud}') note(f"worst STREET view: clips=0 {worst['clips=0']} · default {worst['default']} " f"(delta {worst['default'] - worst['clips=0']:+d})") ints = (table['clips=0']['interior(record)']['draws'], table['default']['interior(record)']['draws']) note(f'interior(record): clips=0 {ints[0]} · default {ints[1]} (delta {ints[1] - ints[0]:+d}), ' f'margin to 350 = {350 - ints[1]}') # ── 5: asset-free + classic ────────────────────────────────────────────────────────── head('5. ASSET-FREE — ?noassets=1 still runs, ?classic=1 keeps its zero-fetch-delta covenant') for q, label in (('noassets=1', '?noassets=1'), ('classic=1', '?classic=1')): b, pg, errs, reqs = new_page(p) boot(pg, q) pg.wait_for_timeout(4000) cr = clip_reqs(reqs) st = pg.evaluate(CLIP_STATS) chunks = pg.evaluate("() => window.PROCITY.chunks.count") act = pg.evaluate("() => window.PROCITY.citizens.stats.active") check(not cr, f'{label}: 0 clip fetches, 0 manifest fetch, 0 clipbank.js fetch') check(st['groups'] == 0 and st['clips'] == 0, f'{label}: no bank (clipStats {st["groups"]}/{st["clips"]})') check(chunks > 0 and act > 0, f'{label}: town builds and the crowd walks ({chunks} chunks, {act} active)') check(not errs, f'{label}: 0 console errors') b.close() # ── 6: bench binding ───────────────────────────────────────────────────────────────── head('6. BENCH BINDING — the sim\'s bench stations ARE Lane B\'s benches') b, pg, errs, reqs = new_page(p) boot(pg) pg.evaluate("() => window.DBG.shot('crossroads_busy')") pg.wait_for_timeout(5000) real = pg.evaluate(BENCHES, 0.0) ctrl = pg.evaluate(BENCHES, 2.0) check(real['tested'] >= 10 and real['hit'] == real['tested'], f"{real['hit']}/{real['tested']} derived stations coincide with real instanced " f"geometry within 2 cm (scene holds {real['instances']} instances)") check(real['yawOk'] == real['hit'], f"{real['yawOk']}/{real['hit']} also match the bench yaw exactly (mod 2pi)") check(ctrl['hit'] == 0, f"CONTROL: the same stations shifted 2 m match {ctrl['hit']}/{ctrl['tested']} — " f"so 'coincide' is a real measurement, not a hit on any nearby prop") if real['misses']: note(f"misses: {real['misses']}") b.close() # ── 7: the gig crowd's widened vocabulary ──────────────────────────────────────────── head("7. VENUE CLIPS — the gig crowd's vocabulary widens, the v3 dance pick survives") b, pg, errs, reqs = new_page(p) boot(pg, 'gigs=1') pg.evaluate("() => window.DBG.setSegment(2)") # MIDDAY — no gig anywhere pg.wait_for_timeout(5000) pre = clip_reqs(reqs) check('venue.glb' not in pre, 'CONTROL: at midday, with no gig on, venue.glb is not fetched at all') pg.evaluate("() => window.DBG.setSegment(5)") # NIGHT — the doors open pg.wait_for_timeout(5000) mid = clip_reqs(reqs) check('venue.glb' in mid, 'venue.glb arrives on GIG NIGHT, from the street — sim.setGig kicks it when F opens ' 'the doors, so it is resident before the player walks in') g = pg.evaluate("""async () => { const P = window.PROCITY, D = window.DBG; if (!P.gigs || !P.gigs.venueShopIds || !P.gigs.venueShopIds.length) return { ok: false, why: 'no gig venue' }; const id = P.gigs.venueShopIds[0]; D.enterShop(id); await new Promise(r => setTimeout(r, 3500)); const crew = P.interiorMode.crew; if (!crew) return { ok: false, why: 'no crew (gig not on)', state: P.gigs.stateOf(id) }; const mem = crew.members.filter(m => m.part === 'crowd'); return { ok: true, state: P.gigs.stateOf(id), crowd: mem.length, swapped: mem.filter(m => m.venueId).length, kept: mem.filter(m => !m.venueId).length, pending: mem.filter(m => m.want).length, clips: [...new Set(mem.map(m => m.venueId).filter(Boolean))].sort(), dancers: mem.filter(m => m.dance).length }; }""") print(' gig:', json.dumps(g)) if g.get('ok'): check(g['swapped'] > 0 and g['kept'] > 0, f"widening, not replacement: {g['swapped']}/{g['crowd']} crowd slots take a venue clip, " f"{g['kept']} keep their R13 pick verbatim ({', '.join(g['clips'])})") check(g['pending'] == 0, 'every swapped slot has its clip installed (self-heal drained)') else: note(f"gig arm skipped: {g.get('why')}") check(not errs, '?gigs=1: 0 console errors') b.close() finally: srv.terminate() print() if fails: print(f"\033[31m● RED\033[0m — {len(fails)} failure(s)") for f in fails: print(' ' + f) return 1 print("\033[32m● PASS\033[0m — boot ledger, lazy loading, determinism, draw table, asset-free and bench binding all green") return 0 if __name__ == '__main__': sys.exit(main())