#!/usr/bin/env python3 """PROCITY Lane F — THE WALKED BUDGET (R37 item 0.3: settle the budget of record). Two independent headless measurements disagreed and no proposal may spend against a number nobody can reproduce: • the city-patterns audit — "walked worst frame 280 draws of 300", 8 m steps down the main spine, "carrying the R+1 dispose window at up to 31 live chunks"; • the animal spike — 191 default / 167 classic, by teleport-walk, could not reach 280. This tool measures the same thing three ways on ONE tree, ONE box, ONE run, so the disagreement is a measurement rather than an argument. The three methods: walk CONTINUOUS MOTION through the real rAF loop. The player is advanced along the main spine a fixed distance per FRAME and every frame is sampled (`renderer.info` after the composer has run). The shell's own `chunks.update()` drives streaming, so the R+1 dispose residue, the 4 ms/frame build queue and the citizen LOD are all the real ones. `--settle` holds position while `chunks.pending > 0` so every sampled frame is a FULLY-STREAMED frame — which is the state a real 4.6 m/s walker is permanently in (see the drain measurement the tool prints: a boundary crossing drains in a few frames, and a real walker gets ~830 frames per chunk). Without --settle you measure a walker who outruns his own streamer, which UNDER-reports. stepwalk the audit's method: `DBG.teleport()` in 8 m steps (teleport does warmup+render). stepsettle [R38] the audit's stations at a FIXED yaw, but DRAINED and then SETTLED for `--holdframes` real rAF frames before sampling — so the frame carries the R+1 dispose residue AND a fully-acquired citizen near-tier at the same time. `stepwalk` under-reports the crowd (one frame per station); `dwell` under-reports the streamer (clean arrival). This is the only method that defeats both, and it is what a >300 claim must be measured with. bookmark the pre-audit method: `DBG.shot(name)` at a named bookmark, clean load. Every method reports draws AND tris, the worst frame, and where it stood. Run (port-isolated, no-store server, pinned tree): tools/.venv/bin/python tools/qa/budget_walk.py --root /web --port 8951 \ --town synthetic --boot default --method walk Headless = SwiftShader. Draw CALLS and TRIANGLES are driver-independent (they are counted by three.js on the JS side, not by the GPU), so these numbers are honest for budget purposes; what headless cannot tell you is frame TIME. Say so wherever the number is quoted. """ import argparse, functools, json, os, pathlib, socket, statistics, sys, threading, time from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer try: from playwright.sync_api import sync_playwright except ImportError: sys.exit("playwright not installed — tools/.venv/bin/python -m pip install playwright") ROOT = pathlib.Path(__file__).resolve().parent.parent.parent SEED = 20261990 # ── a no-store static server (the house dev server's 304s will serve you a stale module) ── class NoStore(SimpleHTTPRequestHandler): def end_headers(self): self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') self.send_header('Pragma', 'no-cache') super().end_headers() def log_message(self, *a): pass def serve(root, port): h = functools.partial(NoStore, directory=str(root)) srv = ThreadingHTTPServer(('127.0.0.1', port), h) threading.Thread(target=srv.serve_forever, daemon=True).start() for _ in range(50): with socket.socket() as s: s.settimeout(0.3) if s.connect_ex(('127.0.0.1', port)) == 0: return srv time.sleep(0.1) sys.exit(f'no-store server would not come up on :{port}') # ── the in-page spine builder: the same "follow the retail" walk the tram uses (tram.js) ── SPINE_JS = r""" (maxm) => { const P = window.PROCITY, plan = P.plan; const nodes = new Map(plan.streets.nodes.map(n => [n.id, n])); const mains = plan.streets.edges.filter(e => e.kind === 'main'); if (!mains.length) return null; const byNode = new Map(); for (const e of mains) for (const nid of [e.a, e.b]) { if (!byNode.has(nid)) byNode.set(nid, []); byNode.get(nid).push(e); } const lotById = new Map((plan.lots || []).map(l => [l.id, l])); const shopsOnEdge = new Map(); for (const s of (plan.shops || [])) { const l = lotById.get(s.lot); if (l && l.frontEdge != null) shopsOnEdge.set(l.frontEdge, (shopsOnEdge.get(l.frontEdge) || 0) + 1); } const edgeShops = e => shopsOnEdge.get(e.id) || 0; const walk = (start) => { const seq = [start]; const used = new Set(); let cur = start, shops = 0, metres = 0; for (;;) { const cands = (byNode.get(cur) || []).filter(e => !used.has(e.id)); if (!cands.length) break; let nx = cands[0]; for (const c of cands) if (edgeShops(c) > edgeShops(nx)) nx = c; used.add(nx.id); shops += edgeShops(nx); const A = nodes.get(cur), nid = nx.a === cur ? nx.b : nx.a, B = nodes.get(nid); if (A && B) metres += Math.hypot(B.x - A.x, B.z - A.z); cur = nid; seq.push(cur); } return { seq, shops, metres }; }; const starts = []; for (const [nid, es] of byNode) if (es.length === 1) starts.push(nid); if (!starts.length) starts.push(mains[0].a); let best = null; for (const s of starts) { const r = walk(s); if (!best || r.shops > best.shops || (r.shops === best.shops && r.metres > best.metres)) best = r; } let poly = best.seq.map(id => { const n = nodes.get(id); return [n.x, n.z]; }); // cumulative arc length const cum = [0]; for (let i = 1; i < poly.length; i++) cum.push(cum[i - 1] + Math.hypot(poly[i][0] - poly[i - 1][0], poly[i][1] - poly[i - 1][1])); const total = cum[cum.length - 1]; // window the walk on the town's RETAIL HEART (DBG.townAnchor) so a 3 km real-roads chain does // not spend the whole run in paddocks. Synthetic anchors at the origin (unchanged behaviour). let out = { poly, cum, total, shopsFronted: best.shops, mainEdges: mains.length, windowed: false, anchor: null }; const A = (window.DBG && window.DBG.townAnchor) || null; if (maxm > 0 && total > maxm) { const ax = A ? A.ref.x : 0, az = A ? A.ref.z : 0; let bs = 0, bd = 1e18; for (let i = 0; i < poly.length; i++) { const dd = (poly[i][0] - ax) ** 2 + (poly[i][1] - az) ** 2; if (dd < bd) { bd = dd; bs = cum[i]; } } const s0 = Math.max(0, Math.min(total - maxm, bs - maxm / 2)), s1 = s0 + maxm; // resample the window const posAt = (s) => { s = Math.max(0, Math.min(total, s)); let i = 0; while (i < cum.length - 2 && cum[i + 1] < s) i++; const seg = cum[i + 1] - cum[i] || 1, f = (s - cum[i]) / seg; return [poly[i][0] + (poly[i + 1][0] - poly[i][0]) * f, poly[i][1] + (poly[i + 1][1] - poly[i][1]) * f]; }; const np = []; const nc = []; for (let s = s0; s <= s1 + 1e-6; s += 4) { np.push(posAt(s)); nc.push(s - s0); } out = { poly: np, cum: nc, total: nc[nc.length - 1], shopsFronted: best.shops, mainEdges: mains.length, windowed: true, anchor: A ? A.ref : null }; } return out; } """ # ── the walker: real rAF loop, one sample per rendered frame ── WALK_JS = r""" (cfg) => { const P = window.PROCITY; const { poly, cum, total } = cfg.path; const posAt = (s) => { s = Math.max(0, Math.min(total, s)); let i = 0; while (i < cum.length - 2 && cum[i + 1] < s) i++; const seg = cum[i + 1] - cum[i] || 1, f = (s - cum[i]) / seg; const x = poly[i][0] + (poly[i + 1][0] - poly[i][0]) * f; const z = poly[i][1] + (poly[i + 1][1] - poly[i][1]) * f; let dx = poly[i + 1][0] - poly[i][0], dz = poly[i + 1][1] - poly[i][1]; const L = Math.hypot(dx, dz) || 1; dx /= L; dz /= L; return { x, z, dx, dz }; }; const W = window.__BW = { samples: [], done: false, frames: 0, holds: 0, laps: 0, drains: [], t0: performance.now() }; let s = 0, dir = 1, held = 0, lastChunk = null, sinceCross = 0, dwelt = 0; // face the direction of travel: three's camera looks down -Z, so yaw = atan2(-dx, -dz) const face = (q) => Math.atan2(-q.dx * dir, -q.dz * dir); const q0 = posAt(0); P.player.teleport(q0.x, q0.z, face(q0)); function step() { W.frames++; const r = P.renderer.info.render; const pos = P.player.position; const ck = P.chunks ? P.chunks.count : 0, pend = P.chunks ? P.chunks.pending : 0; const key = Math.round(pos.x / 64) + ',' + Math.round(pos.z / 64); if (key !== lastChunk) { if (lastChunk !== null) W.drains.push(sinceCross); lastChunk = key; sinceCross = 0; } sinceCross++; // sample the frame that just rendered (draws/tris are this frame's, position is where it stood) W.samples.push([r.calls, r.triangles, ck, pend, +pos.x.toFixed(1), +pos.z.toFixed(1), +s.toFixed(1), dir]); if (cfg.settle && pend > 0 && held < cfg.maxHold) { held++; W.holds++; return requestAnimationFrame(step); } // hold `holdFrames` further frames at each position. The engine does budgeted per-frame work the // chunk queue does not report: CitizenSim acquires at most a few rigs per frame (sim.js NEAR_MAX 24, // "if it fails this frame, ride as an impostor instead") and staggers mixers round-robin. A walker // who advances a metre per FRAME outruns that budget and measures a chronically half-dressed street. // holdFrames turns "1 m per frame" into "1 m per (1+holdFrames) frames" — i.e. a REAL 4.6 m/s pace // at holdFrames≈5 on this box — while still sampling every frame. if (cfg.holdFrames > 0 && dwelt < cfg.holdFrames) { dwelt++; W.holds++; return requestAnimationFrame(step); } dwelt = 0; held = 0; s += dir * cfg.stepm; if (s >= total) { s = total; dir = -1; W.laps++; } else if (s <= 0 && W.laps > 0) { W.done = true; return; } if (W.laps >= cfg.laps && dir < 0 && s <= 0) { W.done = true; return; } if (W.frames > cfg.maxFrames) { W.done = true; return; } const q = posAt(s); P.player.teleport(q.x, q.z, face(q)); requestAnimationFrame(step); } requestAnimationFrame(step); return { total, points: poly.length }; } """ # ── the DWELL diagnostic: stand at one pose and sample N frames. Answers "how much of the frame is # still being assembled after arrival?" — the crowd-fill lag that a fast walker never sees. DWELL_JS = r""" (cfg) => { const P = window.PROCITY; const W = window.__BW = { samples: [], done: false, frames: 0, holds: 0, drains: [], t0: performance.now() }; P.player.teleport(cfg.x, cfg.z, cfg.yaw); function step() { W.frames++; const r = P.renderer.info.render, pos = P.player.position; W.samples.push([r.calls, r.triangles, P.chunks.count, P.chunks.pending, +pos.x.toFixed(1), +pos.z.toFixed(1), 0, 1]); if (W.frames >= cfg.frames) { W.done = true; return; } requestAnimationFrame(step); } requestAnimationFrame(step); return { dwell: true }; } """ # ── the SWEEP: worst frame over POSITION × VIEW DIRECTION. A walk that always faces along the spine # only ever measures one bearing; a player turns their head. At each station the camera is settled, # then rotated through `yaws` bearings, each given `settle` frames before the sample is taken. SWEEP_JS = r""" (cfg) => { const P = window.PROCITY; const { poly, cum, total } = cfg.path; const posAt = (s) => { s = Math.max(0, Math.min(total, s)); let i = 0; while (i < cum.length - 2 && cum[i + 1] < s) i++; const seg = cum[i + 1] - cum[i] || 1, f = (s - cum[i]) / seg; return [poly[i][0] + (poly[i + 1][0] - poly[i][0]) * f, poly[i][1] + (poly[i + 1][1] - poly[i][1]) * f]; }; const stations = []; for (let s = 0; s <= total + 1e-6; s += cfg.stationm) stations.push([s, ...posAt(s)]); const W = window.__BW = { samples: [], done: false, frames: 0, holds: 0, drains: [], t0: performance.now() }; let si = 0, yi = 0, wait = cfg.settle; P.player.teleport(stations[0][1], stations[0][2], 0); function step() { W.frames++; const r = P.renderer.info.render, pos = P.player.position; if (wait > 0) { wait--; W.holds++; } else { W.samples.push([r.calls, r.triangles, P.chunks.count, P.chunks.pending, +pos.x.toFixed(1), +pos.z.toFixed(1), stations[si][0], +(yi * 360 / cfg.yaws).toFixed(0)]); yi++; if (yi >= cfg.yaws) { yi = 0; si++; wait = cfg.settle; } else { wait = cfg.yawSettle; } if (si >= stations.length) { W.done = true; return; } } const [, x, z] = stations[si]; P.player.teleport(x, z, yi * 2 * Math.PI / cfg.yaws); requestAnimationFrame(step); } requestAnimationFrame(step); return { stations: stations.length, total }; } """ # ── the audit's method: 8 m DBG.teleport steps (teleport = warmup + one render) ── STEPWALK_JS = r""" (cfg) => { const P = window.PROCITY, D = window.DBG; const { poly, cum, total } = cfg.path; const posAt = (s) => { s = Math.max(0, Math.min(total, s)); let i = 0; while (i < cum.length - 2 && cum[i + 1] < s) i++; const seg = cum[i + 1] - cum[i] || 1, f = (s - cum[i]) / seg; const x = poly[i][0] + (poly[i + 1][0] - poly[i][0]) * f; const z = poly[i][1] + (poly[i + 1][1] - poly[i][1]) * f; let dx = poly[i + 1][0] - poly[i][0], dz = poly[i + 1][1] - poly[i][1]; const L = Math.hypot(dx, dz) || 1; return { x, z, dx: dx / L, dz: dz / L }; }; const out = []; for (const dir of [1, -1]) { for (let k = 0; k <= Math.floor(total / cfg.stepm); k++) { const s = dir > 0 ? k * cfg.stepm : total - k * cfg.stepm; const q = posAt(s); const yaw = cfg.fixedYaw === null ? Math.atan2(-q.dx * dir, -q.dz * dir) : cfg.fixedYaw; const i = D.teleport(q.x, q.z, yaw); out.push([i.drawCalls, i.tris, i.chunks, 0, +q.x.toFixed(1), +q.z.toFixed(1), +s.toFixed(1), dir]); } if (cfg.laps < 2) break; } return out; } """ # ── [R38] the STEPSETTLE method: the audit's fixed-yaw stations, but SETTLED at each one ───────── # R37 named two under-reporting mechanisms and this round's budget question is what happens when you # defeat BOTH at once. `stepwalk` walks a station in (so it carries the R+1 dispose residue — up to # 31 live chunks) but samples ONE frame, so `CitizenSim`'s per-frame near-tier budget (sim.js # NEAR_MAX 24) has photographed a half-dressed street. `dwell` settles completely but arrives clean # (27 live chunks). Neither is the worst case. This method is: teleport to the station at a FIXED # yaw, then hold `hold` real rAF frames so the shell's own frame() runs the sim to steady state, and # sample the LAST of them. Walked-in chunk residue AND a fully-acquired crowd, in the same frame. # It is the method Lane B's R38 A/B used (drain, then render through the composer), and it is the # only one of the four that can answer "is the control's 346 a real transient or an artifact". STEPSETTLE_JS = r""" (cfg) => new Promise((resolve) => { const P = window.PROCITY, D = window.DBG; const { poly, cum, total } = cfg.path; const posAt = (s) => { s = Math.max(0, Math.min(total, s)); let i = 0; while (i < cum.length - 2 && cum[i + 1] < s) i++; const seg = cum[i + 1] - cum[i] || 1, f = (s - cum[i]) / seg; const x = poly[i][0] + (poly[i + 1][0] - poly[i][0]) * f; const z = poly[i][1] + (poly[i + 1][1] - poly[i][1]) * f; let dx = poly[i + 1][0] - poly[i][0], dz = poly[i + 1][1] - poly[i][1]; const L = Math.hypot(dx, dz) || 1; return { x, z, dx: dx / L, dz: dz / L }; }; const stations = []; for (const dir of [1, -1]) { for (let k = 0; k <= Math.floor(total / cfg.stepm); k++) stations.push([dir > 0 ? k * cfg.stepm : total - k * cfg.stepm, dir]); if (cfg.laps < 2) break; } const out = []; let si = 0, held = 0, drained = 0; const q0 = posAt(stations[0][0]); D.teleport(q0.x, q0.z, cfg.fixedYaw === null ? Math.atan2(-q0.dx, -q0.dz) : cfg.fixedYaw); function step() { const [s, dir] = stations[si]; const pend = P.chunks ? P.chunks.pending : 0; // drain first (a fully-streamed frame is what a real 4.6 m/s walker is permanently in), then // settle: the crowd is what the hold is actually for. if (pend > 0 && drained < cfg.maxDrain) { drained++; return requestAnimationFrame(step); } if (held < cfg.hold) { held++; return requestAnimationFrame(step); } const r = P.renderer.info.render, pos = P.player.position; out.push([r.calls, r.triangles, P.chunks ? P.chunks.count : 0, pend, +pos.x.toFixed(1), +pos.z.toFixed(1), +s.toFixed(1), dir]); si++; held = 0; drained = 0; if (si >= stations.length) return resolve(out); const [s2, d2] = stations[si], q = posAt(s2); P.player.teleport(q.x, q.z, cfg.fixedYaw === null ? Math.atan2(-q.dx * d2, -q.dz * d2) : cfg.fixedYaw); requestAnimationFrame(step); } requestAnimationFrame(step); }) """ def boot(pg, host, query, seg): pg.goto(f'{host}/index.html?seed={SEED}&dbg=1' + (('&' + query) if query else '')) 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'; }") try: pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)', timeout=20000) except Exception: pass if seg is not None: pg.evaluate('(s) => window.DBG.setSegment(s)', seg) pg.wait_for_timeout(600) # let the gig latches / venue queues settle at this segment def summarise(label, samples, extra=None): if not samples: return {'label': label, 'n': 0} draws = [s[0] for s in samples] tris = [s[1] for s in samples] wi = max(range(len(samples)), key=lambda i: samples[i][0]) ti = max(range(len(samples)), key=lambda i: samples[i][1]) w, t = samples[wi], samples[ti] out = { 'label': label, 'n': len(samples), 'draws_worst': w[0], 'draws_worst_at': {'x': w[4], 'z': w[5], 's': w[6], 'dir': w[7], 'live_chunks': w[2], 'pending': w[3], 'tris_here': w[1]}, 'draws_p50': int(statistics.median(draws)), 'draws_p95': sorted(draws)[int(.95 * (len(draws) - 1))], 'draws_mean': round(statistics.mean(draws), 1), 'tris_worst': t[1], 'tris_worst_at': {'x': t[4], 'z': t[5], 's': t[6], 'dir': t[7], 'live_chunks': t[2], 'draws_here': t[0]}, 'tris_p50': int(statistics.median(tris)), 'tris_p95': sorted(tris)[int(.95 * (len(tris) - 1))], 'live_chunks_max': max(s[2] for s in samples), 'live_chunks_at_worst_draw': w[2], } if extra: out.update(extra) return out def run_one(p, host, town, bootflag, method, seg, stepm, laps, maxm, settle, maxframes, keep, holdframes=0, dwell_at=None, dwell_frames=200, yaws=12, extra='', fixed_yaw=None, vw=1280, vh=720): q = [] if town != 'synthetic': q.append(f'plansrc=osm&town={town}') if bootflag == 'classic': q.append('classic=1') if extra: q.append(extra) query = '&'.join(x for x in q if x) b = p.chromium.launch() pg = b.new_page(viewport={'width': vw, 'height': vh}) 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))) try: boot(pg, host, query, seg) meta = pg.evaluate("""() => ({ name: window.PROCITY.plan.name, shops: (window.PROCITY.plan.shops||[]).length, edges: (window.PROCITY.plan.streets.edges||[]).length, indexChunks: null })""") path = pg.evaluate(SPINE_JS, maxm) if not path: return {'label': f'{town}/{bootflag}/{method}', 'error': 'no main edges'} if method == 'stepwalk': raw = pg.evaluate(STEPWALK_JS, {'path': path, 'stepm': stepm, 'laps': laps, 'fixedYaw': fixed_yaw}) extra = {} elif method == 'stepsettle': raw = pg.evaluate(STEPSETTLE_JS, {'path': path, 'stepm': stepm, 'laps': laps, 'fixedYaw': fixed_yaw, 'hold': max(1, holdframes), 'maxDrain': 240}) extra = {'hold_frames_per_station': max(1, holdframes)} else: if method == 'dwell': x, z, yaw = dwell_at pg.evaluate(DWELL_JS, {'x': x, 'z': z, 'yaw': yaw, 'frames': dwell_frames}) elif method == 'sweep': pg.evaluate(SWEEP_JS, {'path': path, 'stationm': stepm, 'yaws': yaws, 'settle': 8, 'yawSettle': 2}) else: pg.evaluate(WALK_JS, {'path': path, 'stepm': stepm, 'laps': laps, 'settle': settle, 'maxHold': 240, 'maxFrames': maxframes, 'holdFrames': holdframes}) pg.wait_for_function('() => window.__BW && window.__BW.done === true', timeout=max(120000, maxframes * 120)) got = pg.evaluate("""() => { const W = window.__BW; return { samples: W.samples, frames: W.frames, holds: W.holds, drains: W.drains, secs: (performance.now()-W.t0)/1000 }; }""") raw = got['samples'] dr = [d for d in got['drains'] if d > 0] extra = {'frames': got['frames'], 'held_frames': got['holds'], 'wall_s': round(got['secs'], 1), 'fps': round(got['frames'] / max(got['secs'], .001), 1), 'chunk_crossings': len(dr), 'frames_per_chunk_median': int(statistics.median(dr)) if dr else None} s = summarise(f'{town}/{bootflag}/{method}/seg{seg}', raw, extra) s.update({'town': town, 'boot': bootflag, 'method': method, 'seg': seg, 'stepm': stepm, 'extra': extra, 'viewport': f'{vw}x{vh}', 'fixed_yaw': fixed_yaw, 'plan': meta, 'path_m': round(path['total'], 1), 'path_windowed': path['windowed'], 'shops_fronted': path['shopsFronted'], 'console_errors': len(errs)}) if keep: s['samples'] = raw return s finally: b.close() def main(): ap = argparse.ArgumentParser() ap.add_argument('--root', default=str(ROOT / 'web'), help='web root to serve (pin a tree here)') ap.add_argument('--port', type=int, default=int(os.environ.get('PROCITY_QA_PORT', '8951'))) ap.add_argument('--town', default='synthetic') ap.add_argument('--boot', default='default', choices=['default', 'classic']) ap.add_argument('--method', default='walk', choices=['walk', 'stepwalk', 'stepsettle', 'dwell', 'sweep']) ap.add_argument('--yaws', type=int, default=12, help='bearings sampled per station (--method sweep)') ap.add_argument('--extra', default='', help='extra query string appended to every boot (e.g. r=3&shadows=1)') ap.add_argument('--fixed-yaw', type=float, default=None, help='stepwalk: hold this yaw at every station instead of facing travel (0 = DBG.teleport default)') ap.add_argument('--viewport', default='1280x720') ap.add_argument('--holdframes', type=int, default=0, help='extra frames held at each position (5 ≈ a real 4.6 m/s walk at --stepm 1)') ap.add_argument('--dwell-at', default=None, help='x,z,yaw for --method dwell') ap.add_argument('--dwell-frames', type=int, default=200) ap.add_argument('--seg', type=int, default=2) ap.add_argument('--stepm', type=float, default=0.5, help='metres advanced per FRAME (walk) or per STEP (stepwalk)') ap.add_argument('--laps', type=int, default=1, help='1 = out and back') ap.add_argument('--maxm', type=float, default=800, help='window the spine to this many metres (0 = whole chain)') ap.add_argument('--no-settle', action='store_true', help='do NOT hold while chunks.pending>0 (walker outruns the streamer)') ap.add_argument('--maxframes', type=int, default=20000) ap.add_argument('--out', default=None) ap.add_argument('--keep-samples', action='store_true') ap.add_argument('--matrix', default=None, help='comma list of town:boot:method:seg cells, e.g. synthetic:default:walk:2,...') a = ap.parse_args() srv = serve(pathlib.Path(a.root).resolve(), a.port) host = f'http://127.0.0.1:{a.port}' cells = [] if a.matrix: for c in a.matrix.split(','): parts = c.split(':') cells.append((parts[0], parts[1], parts[2], int(parts[3]))) else: cells = [(a.town, a.boot, a.method, a.seg)] results = [] with sync_playwright() as p: for (town, bootflag, method, seg) in cells: t0 = time.time() print(f'▶ {town} / {bootflag} / {method} / seg{seg} …', flush=True) try: dw = tuple(float(v) for v in a.dwell_at.split(',')) if a.dwell_at else None r = run_one(p, host, town, bootflag, method, seg, a.stepm, a.laps, a.maxm, not a.no_settle, a.maxframes, a.keep_samples, holdframes=a.holdframes, dwell_at=dw, dwell_frames=a.dwell_frames, yaws=a.yaws, extra=a.extra, fixed_yaw=a.fixed_yaw, vw=int(a.viewport.split('x')[0]), vh=int(a.viewport.split('x')[1])) except Exception as e: r = {'label': f'{town}/{bootflag}/{method}/seg{seg}', 'error': str(e)[:300]} r['wall_total_s'] = round(time.time() - t0, 1) results.append(r) if 'error' in r: print(f" ✗ {r['error']}", flush=True) else: w = r['draws_worst_at'] print(f" worst {r['draws_worst']} draws / {r['tris_worst']:,} tris · " f"p50 {r['draws_p50']}d · n={r['n']} · live chunks max {r['live_chunks_max']} " f"(at worst draw: {w['live_chunks']}) · {r.get('fps','-')}fps " f"[{r['wall_total_s']}s]", flush=True) srv.shutdown() blob = json.dumps(results, indent=1) if a.out: pathlib.Path(a.out).write_text(blob) print(f'\nwrote {a.out}') else: print(blob) if __name__ == '__main__': main()