0.2 — smoke_tram was ONE assertion (`!!getObjectByName('tram')`), a printed SKIP + early return
on absence, and it PASSED ON THE FENCED PATH because tram.js:110-114 adds a named EMPTY group.
Rebuilt STRICT, four arms + both controls demonstrated every run: RUNS (route/stops/metres, and
the body measurably moves under the SHELL'S own rAF loop) · BEHAVES (an isolated tram on a
throwaway scene, fixed dt: traverses, dwell time accounts to exactly dwells x 3.5 s, max cruising
tick <= SPEED*dt, every discontinuity bounded by 2*LANE with >=1 end reversal, and it rides
3.2 m off Lane A's OWN main-edge centreline) · GENUINELY ABSENT on the fenced katoomba_real
(fenced verdict, 0 stops, 0 group children, 0 m moved) · OFF under ?tram=0 and ?classic=1.
CONTROL A points the run-predicate at the fenced town and CONTROL B points the absence-predicate
at the running tram; both must go red or the gate fails. A missing subject now FAILS.
0.5 — classic_regression made no network assertion at all for 21 rounds, which is why R36's
fetch-surface widening needed a brand-new smoke. It now watches the wire from before the first
byte, with the allow-list DERIVED FROM THE v2.0 TAG at gate time (git ls-tree + the v2.0
manifest), never a deny-list. The four v2-era depot street GLBs and the five record-shop fittings
are BASELINE and admitted by provenance, not by exception. Same-origin is NOT a free pass -- the
R36 breach WAS same-origin, so /models/peds is enumerated by the v2.0 tree (exactly the
covenanted 21 files). Anti-vacuous: a required v2 core must be observed or the clean verdict is
refused. CONTROL, run every time: injecting the R36 breach itself (models/peds/woman_dj_01.glb)
plus a never-existed asset turns the clean verdict into exactly 2 violations.
Measured and carried, not fixed here: booting the actual v2.0 tree and diffing its fetch surface
against today's ?classic=1 shows a POST-v2.0 delta -- 5 audio files (R11 f7dd44f) and
assets/towns/index.json (R20/R21). Allowed on the list so the suite is not permanently yellow
over 26 rounds of shipped behaviour, printed every run so it cannot go invisible. Fable's to
ratify or retire.
Also: tools/qa/budget_walk.py (R37 0.3, the measurement rig -- continuous-motion walk through the
real rAF loop, bearing sweep, dwell, and a stepwalk that reproduces the audit's method) and
`flags_check.py --only` for single-gate development runs, which prints a PARTIAL banner so a
subset can never be read as a suite verdict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
473 lines
23 KiB
Python
473 lines
23 KiB
Python
#!/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).
|
||
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 <tree>/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;
|
||
}
|
||
"""
|
||
|
||
|
||
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 = {}
|
||
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', '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()
|