#!/usr/bin/env python3 """PROCITY Lane F — R42 §42.6 · THE INTERIOR BUDGET, WITH THE INSTRUMENT PINNED (Lane C's §42 ask). tools/.venv/bin/python tools/qa/r42_budget.py WHY THIS FILE EXISTS. R41 closed with a two-round-old argument settled the wrong way round: C published the worst interior as **122** draws and F measured **123** on two instruments, and F wrote it off as "C's measurement environment". C found the actual cause in §42.2b and it is neither lane's bookkeeping — **it is the CAMERA ASPECT**. `renderer.info.render.calls` counts what survives frustum culling; the sweep camera holds a fixed VERTICAL fov (70) and inherits its aspect from the window, so a wider window puts more room in frame and issues more draws: 1280x1024 aspect 1.250 -> 116 1280x800 1.600 -> 122 1280x720 1.778 -> 123 Both numbers were honest readings of one tree through two windows. **123 is the law's number**, because the law must be the worst a player can produce and a player's window is wide. C's filing to F was exact: *"set `S.camera.aspect` explicitly inside SWEEP rather than inheriting the viewport, so the gate cannot drift when someone changes `new_page`."* That is what this does. `r41_integration.py` arm 2 happens to be right today only because its `new_page` is 1280x720, which IS 16:9 — a coincidence one line in an unrelated helper can end. Here the aspect is a constant in the measurement, the viewport is irrelevant, and the sweep ships an ASPECT CONTROL that reproduces C's 1.25 reading on purpose, so "the instrument is pinned" is a measurement rather than a comment. ARMS 1. GLB off / GLB on / GLB on + ?stock=real, aspect pinned 16:9 — the law is worst ≤ 350. 2. CONTROL (aspect): the identical sweep at aspect 1.25 must read LOWER, and by C's own margin. 3. CONTROL (viewport): the 16:9 sweep run in a 1280x1024 window must return the SAME number as the one run in a 1280x720 window — which is the actual claim "the instrument no longer drifts". """ import sys, os, json, time, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = int(os.environ.get('PROCITY_R42_BUDGET_PORT', '8986')) HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 DRAW_LAW = 350 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) 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() ''' # F's own sweep — the same scene bookkeeping as R41 arm 2 (strip EVERY interior first, including the # page's own `current`; that omission is what carried a phantom second room through R39/R40) — with # ONE change: the camera's aspect is set from an argument and restored afterwards, so the number is # a property of the tree and not of the window the harness happened to open. SWEEP = r""" async ({ glb, stock, aspect }) => { const S = window.PROCITY_C, THREE = S.THREE; const strip = () => { for (const o of S.scene.children.filter(o => o.userData && o.userData.kind === 'interior')) S.scene.remove(o); }; strip(); let manifest = null; if (glb) manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null); const inherited = S.camera.aspect; const measure = async (type, arch) => { const r = S.buildInterior({ id: type, type, seed: 1990, storeys: 1 }, THREE, { archetype: arch, useGLB: glb, manifest, stock: stock ? 'real' : undefined, stockBase: (stock && type === 'opshop') ? S.WARDROBE_BASE : undefined }); S.scene.add(r.group); if (glb) await r.glbReady; S.camera.position.set(r.dims.W * 0.3, r.dims.H * 0.6, r.dims.D / 2 - 0.8); S.camera.lookAt(0, 0.9, -r.dims.D * 0.15); // THE PIN. A fixed vertical fov + an inherited aspect means the frustum is a function of the // window; set it here and the sweep measures the tree instead of the browser. S.camera.aspect = aspect; S.camera.updateProjectionMatrix(); S.renderer.info.reset(); S.renderer.render(S.scene, S.camera); const d = S.renderer.info.render.calls; S.scene.remove(r.group); r.dispose(); return d; }; let worst = 0, worstAt = '', rooms = 0; const perType = {}; for (const t of S.SHOP_TYPES) { let tw = 0; for (const a of [undefined, ...S.ARCHETYPE_KEYS]) { const d = await measure(t, a); rooms++; if (d > tw) tw = d; if (d > worst) { worst = d; worstAt = `${t}/${a || 'auto'}`; } } perType[t] = tw; } S.camera.aspect = inherited; S.camera.updateProjectionMatrix(); S.rebuild(); return { worst, worstAt, rooms, perType, inheritedAspect: +inherited.toFixed(3), pinnedAspect: aspect }; } """ 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 open_test(p, w, h): b = p.chromium.launch() pg = b.new_page(viewport={'width': w, 'height': h}) 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))) pg.goto(f'{HOST}/interior_test.html?seed={SEED}&stock=real') pg.wait_for_function('window.PROCITY_C && window.PROCITY_C.SHOP_TYPES', timeout=45000) pg.wait_for_timeout(1500) # the page's own stock preloads return b, pg, errs def main(): srv = serve() try: from playwright.sync_api import sync_playwright with sync_playwright() as p: head(f'1. THE INTERIOR LAW — F\'s own sweep, 12 types x 6 archetypes, aspect PINNED 16:9, law ≤{DRAW_LAW}') b, pg, errs = open_test(p, 1280, 720) res = {} try: for label, opts in (('GLB off', dict(glb=False, stock=False, aspect=16 / 9)), ('GLB on', dict(glb=True, stock=False, aspect=16 / 9)), ('GLB on + stock=real', dict(glb=True, stock=True, aspect=16 / 9))): r = pg.evaluate(SWEEP, opts) res[label] = r note(f"{label}: worst {r['worst']} @ {r['worstAt']} over {r['rooms']} rooms " f"(margin {DRAW_LAW - r['worst']}) · per type {json.dumps(r['perType'])}") check(r['worst'] <= DRAW_LAW, f'{label}: worst room {r["worst"]} ≤ {DRAW_LAW} — MARGIN {DRAW_LAW - r["worst"]}') on = res['GLB on'] check(on['worstAt'].startswith('dept'), f"the worst room is a dept room ({on['worstAt']}) — C's headline, on F's instrument") head('2. CONTROL (aspect) — the identical sweep at 1.25, which must read LOWER') narrow = pg.evaluate(SWEEP, dict(glb=True, stock=False, aspect=1.25)) note(f"aspect 1.250: worst {narrow['worst']} @ {narrow['worstAt']} · " f"aspect 1.778: worst {on['worst']} @ {on['worstAt']}") check(narrow['worst'] < on['worst'], f"a narrower frustum draws less ({narrow['worst']} < {on['worst']}) — the sweep is " f"aspect-sensitive, which is exactly why it has to be pinned. This IS the 122-vs-123 " f"argument, reproduced on demand instead of relitigated.") check(narrow['worst'] == 116, f"…and it reproduces Lane C's published control value exactly: {narrow['worst']} = 116 " f"@ aspect 1.25 (LANE_C_NOTES §42)") check(not errs, f'0 console errors over {sum(r["rooms"] for r in res.values()) + narrow["rooms"]} room builds') finally: b.close() head('3. CONTROL (viewport) — the SAME pinned sweep in a 1280x1024 window must agree') b2, pg2, errs2 = open_test(p, 1280, 1024) try: other = pg2.evaluate(SWEEP, dict(glb=True, stock=False, aspect=16 / 9)) note(f"window 1280x1024 (inherited aspect {other['inheritedAspect']}): " f"worst {other['worst']} @ {other['worstAt']}") check(other['worst'] == res['GLB on']['worst'] and other['worstAt'] == res['GLB on']['worstAt'], f"a window that would have read {other['inheritedAspect']} reads " f"{other['worst']} @ {other['worstAt']} — identical to the 1280x720 run. The number is " f"now a property of the TREE. Unpinned, this same window read 116 in C's table.") check(not errs2, '0 console errors on the viewport-control run') print('\n' + json.dumps({'law': DRAW_LAW, 'aspect': '16:9', 'glb_off': res['GLB off']['worst'], 'glb_on': res['GLB on']['worst'], 'glb_on_at': res['GLB on']['worstAt'], 'glb_on_stock_real': res['GLB on + stock=real']['worst'], 'margin': DRAW_LAW - res['GLB on']['worst'], 'control_aspect_125': narrow['worst'], 'control_viewport_1280x1024': other['worst']}, indent=1)) finally: b2.close() finally: srv.terminate() print('') if fails: print(f'\033[31m● FAIL\033[0m — {len(fails)} problem(s):') for f in fails: print(f' · {f}') return 1 print('\033[32m● PASS\033[0m — the interior law holds with the instrument pinned, and both controls fired') return 0 if __name__ == '__main__': sys.exit(main())