diff --git a/tools/flags_check.py b/tools/flags_check.py index 1f0bec7..8a2cb38 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -124,6 +124,114 @@ def plansrc_landed(p): finally: b.close() +# ── [R37 item 0.5] THE CLASSIC NETWORK ALLOW-LIST ──────────────────────────────────────────────── +# classic_regression made NO network assertion at all for 21 rounds. That is why R36's fetch-surface +# widening (5 trellis DJ bodies joining PED_NAMES.normal) needed a brand-new smoke to catch it: the +# covenant gate was watching the plan fingerprint, the flags, the draws and the console, and nothing +# was watching the wire. +# +# THE AUTHORITY IS GIT, NOT AN OPINION. The covenant preserves "the frozen v2 baseline", so the +# allow-list is DERIVED from the v2.0 tag's own file tree at gate time: +# • same-origin asset ⇒ its path must exist in `git ls-tree -r v2.0 -- web/`; +# • depot asset (cross-origin digalot.fyi/3god/a/) ⇒ its basename must appear in v2.0's own +# `web/assets/manifest.json`. This is how the four street furniture GLBs (bench · bin · +# bus_shelter · food_cart, `furniture.js` GLB_FILES, byte-identical at 24cef5a and v2.0) and the +# five record-shop fittings reached through `useGLB: !NOASSETS` (index.html, byte-identical at +# v2.0) are admitted. Those nine fetches are CORRECT — they are in the baseline the covenant +# preserves, not a breach — and this gate must never be "fixed" by removing them. +# • CODE is allowed as a class (same-origin .js/.mjs + the document). A new module that fetches +# nothing is not a fetch-surface change; every ASSET is enumerated. +# • blob:/data: are not network — GLTFLoader mints one blob per embedded texture. +# +# ALLOW-LIST, NEVER DENY-LIST: a deny-list only catches what you already thought of. The R36 breach +# was SAME-ORIGIN (`/models/peds/woman_dj_01.glb`), so an allow-list phrased as "the four depot GLBs +# plus same-origin" would have been green straight through it. Same-origin is NOT a free pass here; +# `/models/peds/` is enumerated by the v2.0 tree, which contains exactly the covenanted 21 files. +# +# POST-v2.0 ADDITIONS (Tier P) — measured, named, dated, and NOT silently swallowed. Booting the +# actual v2.0 tree and diffing its fetch surface against today's ?classic=1 gives a delta of exactly +# +3 files, all shipped years of rounds ago and never ruled on: +# /assets/audio/* R11 audio engine (f7dd44f, Lane B round-11) +# /assets/towns/index.json R20/R21 town selector (078245f, 4b01449) +# They are allowed here so the suite is not permanently yellow over shipped behaviour, and PRINTED +# every run so the delta can never go invisible. Fable's to ratify or retire — not F's to improvise. +V2_TAG = 'v2.0' +DEPOT_HOSTS = ('digalot.fyi',) +POST_V2_PREFIXES = ( + ('/assets/audio/', 'R11 audio engine (f7dd44f)'), + ('/assets/towns/index.json', 'R20/R21 town selector (078245f, 4b01449)'), +) +# The core that must ALL be observed, or the observer is not looking at a live boot (anti-vacuous): +# the four v2-era depot street GLBs + the covenanted ped fleet's shared clips. +CLASSIC_REQUIRED = ('procity_street_bench_01.glb', 'procity_street_bin_01.glb', + 'procity_street_bus_shelter_01.glb', 'procity_street_food_cart_01.glb', + '/models/peds/walk.glb', '/models/peds/idle.glb', + '/models/peds/man_worker_hivis_01.glb', '/assets/manifest.json') +_v2_cache = {} + + +def _v2_baseline(): + """{paths, depot_basenames} from the v2.0 TAG. Empty dict ⇒ the tag is unreachable and the gate + must FAIL rather than pass with no authority.""" + if _v2_cache: + return _v2_cache + ls = subprocess.run(['git', 'ls-tree', '-r', V2_TAG, '--name-only', '--', 'web/'], + cwd=str(ROOT), capture_output=True, text=True) + mf = subprocess.run(['git', 'show', f'{V2_TAG}:web/assets/manifest.json'], + cwd=str(ROOT), capture_output=True, text=True) + if ls.returncode != 0 or mf.returncode != 0: + return {} + paths = {ln.strip()[3:] for ln in ls.stdout.splitlines() if ln.strip().startswith('web/')} + depot = {tok.split('/')[-1] for tok in mf.stdout.split('"') if tok.endswith('.glb')} + _v2_cache.update({'paths': paths, 'depot': depot}) + return _v2_cache + + +def _classify_fetch(url, host, paths, depot): + """→ (verdict, tier). verdict ∈ {'ok','violation','ignored'}.""" + if url.startswith(('blob:', 'data:', 'about:')): + return 'ignored', 'blob/data (not network — GLTFLoader mints one per embedded texture)' + if url.startswith(host): + path = url[len(host):].split('?')[0].split('#')[0] or '/' + if path == '/': + path = '/index.html' + if path.endswith(('.js', '.mjs', '.html')): + return 'ok', 'code (same-origin module/document)' + if path == '/favicon.ico': + return 'ignored', 'browser noise' + for pre, why in POST_V2_PREFIXES: + if path.startswith(pre): + return 'ok', f'POST-v2.0 addition, allowed + unratified — {why}' + if path in paths: + return 'ok', f'same-origin asset present in the {V2_TAG} tree' + return 'violation', f'same-origin asset NOT in the {V2_TAG} tree' + h = url.split('/')[2] if '://' in url else '' + if any(d in h for d in DEPOT_HOSTS): + f = url.split('/')[-1].split('?')[0] + if f in depot: + return 'ok', f'depot asset named in the {V2_TAG} manifest' + return 'violation', f'depot asset NOT in the {V2_TAG} manifest' + return 'violation', f'THIRD-PARTY origin ({h})' + + +def classic_network_verdict(reqs, host): + """Run every captured request through the allow-list → (violations, tiers, observed).""" + b = _v2_baseline() + if not b: + return None, None, None # the authority is unreachable; the caller FAILS + paths, depot = b['paths'], b['depot'] + violations, tiers, observed = [], {}, [] + for u in reqs: + v, tier = _classify_fetch(u, host, paths, depot) + if v == 'ignored': + continue + observed.append(u) + tiers.setdefault(tier, []).append(u) + if v == 'violation': + violations.append((u, tier)) + return violations, tiers, observed + + # ── the three checks ───────────────────────────────────────────────────────── def classic_regression(p): # [R16 — THE FLIP] The prime-law covenant MOVED from "flags off" to "?classic=1": the classic boot must @@ -132,6 +240,8 @@ def classic_regression(p): # flags-off gate, now booted with ?classic=1 (the four flipped flags are off, everything else v2-default). head('CLASSIC REGRESSION (?classic=1 → the frozen v2 covenant)') b, pg, errs = new_page(p) + reqs = [] # [R37 0.5] the wire, watched from before the first byte + pg.on('request', lambda r: reqs.append(r.url)) try: boot(pg, 'classic=1') state = pg.evaluate("""() => { @@ -226,6 +336,67 @@ def classic_regression(p): if errs: FAIL(f"{len(errs)} console error(s) under ?classic; first: {errs[0][:140]}") else: OK('0 console errors under ?classic') + # ── [R37 item 0.5] THE NETWORK ALLOW-LIST ──────────────────────────────────────────────── + # Everything above this line has been true since R16 and none of it watches the wire. The + # capture covers the whole gate path: boot + the fleet + the dig-inert leg's real shop entry + # (which is what reaches the depot's interior fittings). + pg.wait_for_timeout(1200) # let late/streamed fetches land before the verdict + vio, tiers, observed = classic_network_verdict(reqs, HOST) + if vio is None: + FAIL(f"classic network allow-list has NO AUTHORITY — `git ls-tree {V2_TAG}` / " + f"`git show {V2_TAG}:web/assets/manifest.json` failed. The allow-list is derived from " + f"the {V2_TAG} tag; without it this arm asserts nothing and must not pass.") + else: + core_seen = [k for k in CLASSIC_REQUIRED if any(k in u for u in observed)] + if len(core_seen) != len(CLASSIC_REQUIRED): + missing = [k for k in CLASSIC_REQUIRED if k not in core_seen] + FAIL(f"classic network: the REQUIRED v2 core was not observed ({missing}) — the observer " + f"is not watching a live boot, so a clean allow-list verdict here would be vacuous") + if vio: + FAIL(f"CLASSIC FETCH SURFACE WIDENED — {len(vio)} fetch(es) outside the {V2_TAG} baseline: " + + '; '.join(f"{u.split('/')[-1]} [{why}]" for u, why in vio[:6]) + + (f" (+{len(vio)-6} more)" if len(vio) > 6 else '') + + ". Allow-list, not deny-list: either the asset belongs in classic and the covenant " + "needs a ruling + a re-pin, or it must be gated out of ?classic (R36 precedent).") + else: + depot_n = len(tiers.get(f'depot asset named in the {V2_TAG} manifest', [])) + v2_n = len(tiers.get(f'same-origin asset present in the {V2_TAG} tree', [])) + code_n = len(tiers.get('code (same-origin module/document)', [])) + OK(f"classic fetch surface CLEAN against the {V2_TAG} tag — {len(observed)} network " + f"requests, 0 outside the baseline ({code_n} code modules · {v2_n} same-origin assets " + f"present at {V2_TAG} · {depot_n} depot assets named in the {V2_TAG} manifest, incl. the " + f"four v2-era street GLBs, which are BASELINE and must never be 'fixed' away)") + for tier, urls in sorted(tiers.items()): + if tier.startswith('POST-v2.0'): + WARN_ONCE = f"classic carries a POST-{V2_TAG} fetch delta: {len(urls)} file(s) — " \ + f"{sorted({u.split('/')[-1] for u in urls})} — {tier.split('— ')[-1]}. " \ + f"Allowed here so the suite is not permanently yellow over shipped " \ + f"behaviour; UNRATIFIED. Fable's to bless or retire." + print(f" \033[33m·\033[0m {WARN_ONCE}") + + # ── THE CONTROL, demonstrated in-run: two fabricated fetches must both go RED ── + # (a) the R36 breach replayed exactly — a same-origin ped GLB added after v2.0. An + # allow-list phrased as "the four depot GLBs + same-origin" would be GREEN on this. + # (b) an asset name that has never existed — proves a brand-new file cannot slip through + # on a 404 (the request is still on the wire). + ctl = list(reqs) + pg.evaluate("""async () => { for (const u of ['models/peds/woman_dj_01.glb', + 'assets/gen/__gate_control_v8_palette.jpg']) + { try { await fetch(u); } catch (e) {} } }""") + pg.wait_for_timeout(400) + injected = [u for u in reqs if u not in ctl] + cvio, _, _ = classic_network_verdict(reqs, HOST) + names = sorted({u.split('/')[-1] for u, _ in (cvio or [])}) + if cvio is not None and len(cvio) == len(vio) + 2 and \ + 'woman_dj_01.glb' in names and '__gate_control_v8_palette.jpg' in names: + OK(f"CONTROL: the allow-list DISCRIMINATES — injecting the R36 breach itself " + f"(same-origin models/peds/woman_dj_01.glb) plus a never-existed asset turned a clean " + f"verdict into exactly 2 violations ({names}); {len(injected)} injected request(s) seen") + else: + FAIL(f"CONTROL FAILED: the fabricated fetches did not go red — the allow-list does not " + f"discriminate (before {len(vio)}, after {len(cvio) if cvio is not None else 'None'}, " + f"named {names}, injected {injected})") + # R7 escape hatch: ?roster=v1 must restore the fixed roster (streamMode:false), render, no errors. b2, pg2, errs2 = new_page(p) try: @@ -614,21 +785,293 @@ def smoke_shelfbuy(p): finally: b.close() +# [R37 item 0.2] the fenced subject. B's R20 per-town fence (tram.js:101-113) refuses the tram on a +# real-roads graph whose best main chain fronts < 5 shops. R21 measured katoomba/fremantle/bendigo +# fenced at 3/2/1 shops fronted; katoomba is the absence arm's subject. If it ever stops being fenced +# this gate FAILS rather than skipping — an absence arm with no subject is the vacuous gate again. +TRAM_FENCED_TOWN = 'katoomba_real' +TRAM_LANE = 3.2 # tram.js LANE — offset from the road centreline (drive on the left, AU) +TRAM_SPEED = 9.0 # tram.js SPEED (m/s) +TRAM_DWELL = 3.5 # tram.js DWELL (s at each stop) + +# The behavioural integration, run on an ISOLATED tram (same plan, throwaway scene) so the shell's own +# rAF tick cannot contaminate the dt sequence. Returns the whole trajectory's verdicts. +_TRAM_DRIVE_JS = r""" +async (cfg) => { + const P = window.PROCITY; + const m = await import('./js/world/tram.js'); + const scene = new P.THREE.Scene(); + const t = m.createTram({ scene, plan: P.plan, camera: P.camera, lighting: null }); + const info = t.routeInfo || {}; + if (info.fenced || !t.stops) return { fenced: !!info.fenced, stops: t.stops | 0, info }; + // independent geometry reference: the MAIN-edge segments straight off the plan (NOT tram.js's own + // polyline) — so "the tram is 3.2 m off the road centreline" is checked against Lane A's data. + const nodes = new Map(P.plan.streets.nodes.map(n => [n.id, n])); + const segs = P.plan.streets.edges.filter(e => e.kind === 'main') + .map(e => ({ a: nodes.get(e.a), b: nodes.get(e.b) })).filter(s => s.a && s.b); + const distToMain = (x, z) => { + let best = 1e18; + for (const s of segs) { + const dx = s.b.x - s.a.x, dz = s.b.z - s.a.z, L2 = dx * dx + dz * dz || 1; + let u = ((x - s.a.x) * dx + (z - s.a.z) * dz) / L2; u = Math.max(0, Math.min(1, u)); + const px = s.a.x + dx * u, pz = s.a.z + dz * u; + const d = Math.hypot(x - px, z - pz); if (d < best) best = d; + } + return best; + }; + const dt = cfg.dt, N = Math.round(cfg.seconds / dt); + const CRUISE = cfg.speed * dt * 1.05; // the most the schedule may move it in one tick, +5% + let prev = { x: t.group.position.x, z: t.group.position.z }; + let path = 0, maxStep = 0, stills = 0, dwells = 0, run = 0; + const jumps = []; // steps larger than CRUISE: the end-of-line LANE swap + const offs = []; + const runMin = Math.floor((cfg.dwellS * 0.8) / dt); + for (let i = 0; i < N; i++) { + t.update(dt); + const x = t.group.position.x, z = t.group.position.z; + const step = Math.hypot(x - prev.x, z - prev.z); + if (step > CRUISE) jumps.push(+step.toFixed(3)); + else { path += step; if (step > maxStep) maxStep = step; } + if (step < 1e-4) { run++; stills++; } + else { + if (run >= runMin) dwells++; + run = 0; + offs.push(+distToMain(x, z).toFixed(3)); + } + prev = { x, z }; + } + if (run >= runMin) dwells++; + offs.sort((a, b) => a - b); + t.dispose(); + // Three kinds of legitimate discontinuity exist in tram.js and all three are bounded by 2·LANE: + // (a) the END REVERSAL — `dir` flips ⇒ the LANE offset mirrors across the road ⇒ exactly 2·LANE; + // (b) the STOP SNAP — `s = stopS[nextStop]` when within EPS(2.5 m) of a mark; + // (c) the BEND KINK — the lane offset is taken off the SEGMENT direction, so at a polyline + // bend of θ the offset point steps 2·LANE·sin(θ/2) sideways. + // The gate asserts (a) happened (it reached the end and turned) and that NOTHING exceeds 2·LANE. + const big = jumps.filter(j => j >= cfg.lane * 2 - 0.5); + return { fenced: false, stops: t.stops | 0, info, path: +path.toFixed(1), maxStep: +maxStep.toFixed(4), + dwells, stills, stillS: +(stills * dt).toFixed(2), samples: offs.length, + jumps: jumps.length, reversals: big.length, + jumpMin: jumps.length ? Math.min(...jumps) : null, + jumpMax: jumps.length ? Math.max(...jumps) : null, + offMed: offs.length ? offs[offs.length >> 1] : null, + offMin: offs.length ? offs[0] : null, offMax: offs.length ? offs[offs.length - 1] : null }; +} +""" + +# Observe the LIVE tram under the shell's own rAF loop (nothing driven by the harness): does the thing +# the shell built actually move? `getObjectByName('tram')` cannot tell you — the fenced path adds a +# named EMPTY group, which is exactly why the old one-line check passed on towns with no tram. +_TRAM_LIVE_JS = r""" +async (frames) => { + const P = window.PROCITY; + const g = P.scene.getObjectByName('tram'); + const seen = []; + for (let i = 0; i < frames; i++) { + await new Promise(r => requestAnimationFrame(r)); + seen.push([+P.tram.group.position.x.toFixed(3), +P.tram.group.position.z.toFixed(3)]); + } + let moved = 0; + for (let i = 1; i < seen.length; i++) moved += Math.hypot(seen[i][0] - seen[i - 1][0], seen[i][1] - seen[i - 1][1]); + const uniq = new Set(seen.map(s => s.join(','))).size; + return { moved: +moved.toFixed(3), uniq, frames: seen.length, + namedGroupPresent: !!g, groupChildren: g ? g.children.length : null, + first: seen[0], last: seen[seen.length - 1] }; +} +""" + + +def _tram_state(pg): + """The tram's own published verdict + the OLD gate's one-liner, side by side.""" + return pg.evaluate("""() => { + const P = window.PROCITY, g = P.scene.getObjectByName('tram'); + const ri = P.tram ? (P.tram.routeInfo || {}) : null; + return { obj: !!P.tram, legacyPresent: !!g, groupChildren: g ? g.children.length : null, + fenced: ri ? !!ri.fenced : null, stops: P.tram ? (P.tram.stops | 0) : null, + routeEdges: ri ? (ri.routeEdges | 0) : null, routeMetres: ri ? (ri.routeMetres | 0) : null, + shopsFronted: ri ? (ri.shopsFronted | 0) : null, reason: ri ? (ri.reason || null) : null, + townEdges: (P.plan.streets.edges || []).length, town: P.plan.name }; + }""") + + +# The two predicates the gate is built from. They are FUNCTIONS, not inline asserts, precisely so the +# falsifiability control can point each one at the other arm's subject and show it go red (house law: +# every gate ships with its control demonstrated). +def _tram_runs_pred(st, drive): + """True iff this town's tram is a RUNNING tram: route resolved, stops sane, on the road, moving.""" + why = [] + if not st['obj']: why.append('PROCITY.tram is null') + if st['fenced'] is not False: why.append(f"routeInfo.fenced={st['fenced']}") + if not st['stops'] or not (1 <= st['stops'] <= 60): why.append(f"stops={st['stops']} outside [1,60]") + if (st['routeEdges'] or 0) < 2: why.append(f"routeEdges={st['routeEdges']} < 2") + if (st['routeMetres'] or 0) < 50: why.append(f"routeMetres={st['routeMetres']} < 50") + if drive is None or drive.get('fenced'): why.append('behavioural drive refused (fenced/no stops)') + else: + if drive['path'] < 0.5 * (st['routeMetres'] or 0): why.append(f"travelled {drive['path']}m < half the route") + if drive['maxStep'] > TRAM_SPEED * 0.05 * 1.05: why.append(f"max cruising tick {drive['maxStep']}m exceeds SPEED·dt") + if drive['dwells'] < 1: why.append('never dwelled at a stop') + # dwell ACCOUNTING: total stationary time must equal dwells × DWELL to within a tick. This is + # what proves the stops are real marks being consumed rather than the body simply being stuck. + if abs(drive['stillS'] - drive['dwells'] * TRAM_DWELL) > 0.5: + why.append(f"stationary time {drive['stillS']}s != {drive['dwells']} dwells × {TRAM_DWELL}s") + # REVERSAL: the only permitted discontinuity is the end-of-line lane swap (dir flips ⇒ the LANE + # offset mirrors ⇒ exactly 2·LANE across the road). Its presence proves it reached the end and + # turned; its SIZE proves nothing else is teleporting the tram. + if not drive['reversals']: + why.append('never reversed at an end (no 2·LANE lane-swap discontinuity in 400 s)') + if drive['jumpMax'] is not None and drive['jumpMax'] > 2 * TRAM_LANE + 0.5: + why.append(f"discontinuity {drive['jumpMax']}m exceeds 2·LANE {2*TRAM_LANE}m — the tram is being " + f"teleported by something other than the documented end-of-line lane swap") + if drive['offMed'] is None or not (TRAM_LANE - 0.35 <= drive['offMed'] <= TRAM_LANE + 0.35): + why.append(f"median offset from the main centreline {drive['offMed']}m != LANE {TRAM_LANE}m") + return (not why), why + + +def _tram_absent_pred(st, live): + """True iff this town's tram is GENUINELY absent: fenced verdict, no stops, empty group, never moves.""" + why = [] + if not st['obj']: why.append('PROCITY.tram is null (expected the fenced stub object)') + if st['fenced'] is not True: why.append(f"routeInfo.fenced={st['fenced']} (expected True)") + if st['stops'] != 0: why.append(f"stops={st['stops']} (expected 0)") + if st['groupChildren'] != 0: why.append(f"the named 'tram' group has {st['groupChildren']} children (expected 0)") + if live is not None and live['moved'] > 0.01: why.append(f"the fenced tram MOVED {live['moved']}m") + return (not why), why + + def smoke_tram(p): - """NEW (warn): ?tram=1 — Lane B tram loop. Auto-skips if not landed.""" + """[R37 item 0.2 — REPAIRED] the tram gate, rebuilt from a vacuous one-liner. + + What it was (flags_check.py:617-630, R9→R36): `!!scene.getObjectByName('tram')`, a printed SKIP + and an early `return` when absent, warn-level. Three faults, all fatal: + · it PASSED ON THE FENCED PATH — `tram.js:110-114` adds a *named empty group* to the scene + before returning the no-op stub, so the one assertion is true on every town where the tram + deliberately does not exist. The gate was green on precisely the towns it was meant to police. + · SKIP-and-return on absence: a missing subject exited 0. Vacuous-gate law, inside our own suite. + · it asserted nothing about the tram being a TRAM — no route, no stops, no motion. + + What it is now — four arms and a demonstrated control: + 1. RUNS (default boot, synthetic): route resolved (edges/metres), stops sane, and — under the + SHELL'S OWN rAF loop, nothing driven by the harness — the body measurably moves. + 2. BEHAVIOUR (isolated tram, same plan, throwaway scene, fixed dt): it traverses the line, + reverses at the end, dwells at stops, never exceeds SPEED·dt, and rides exactly LANE=3.2 m + off the centreline — measured against Lane A's own main-edge segments, not tram.js's polyline. + 3. GENUINELY ABSENT (a fenced real-roads town): the fenced verdict is published, stops == 0, the + named group is EMPTY, and the thing never moves. The old one-liner's `true` is printed next + to it as the standing record of what a vacuous gate looks like. + 4. OFF (`?tram=0` and `?classic=1`): `PROCITY.tram` is null and no 'tram' object is in the scene. + CONTROL, demonstrated every run: the run-predicate is applied to the FENCED town's subject and + must go RED, and the absence-predicate is applied to the RUNNING tram and must go RED. Each + predicate is proven to discriminate on this tree, in this run, rather than asserted to. + + STRICT (was warn-level). A missing subject FAILS.""" + head('SMOKE: tram (R37 0.2 — runs where it runs · genuinely absent where fenced · control both ways)') + + # ── arm 1+2: the running tram, on the default boot ────────────────────────────────────────── b, pg, errs = new_page(p) + run_st = run_live = run_drive = None try: - boot(pg, 'tram=1') - present = pg.evaluate("() => !!window.PROCITY.scene.getObjectByName('tram')") - if not present: - print("· tram=1 not landed (no 'tram' in scene) — skipping (Lane B)") - return - head('SMOKE: tram (?tram=1; new → warn-level)') - OK("tram=1: 'tram' present in scene") - if errs: WARN(f"tram: {len(errs)} console error(s); first: {errs[0][:140]}") + boot(pg, '') + run_st = _tram_state(pg) + if not run_st['obj']: + FAIL('tram: PROCITY.tram is NULL on the default boot — the subject is absent, so this gate ' + 'FAILS (the pre-R37 gate printed a SKIP here and exited 0)') + else: + run_live = pg.evaluate(_TRAM_LIVE_JS, 45) + run_drive = pg.evaluate(_TRAM_DRIVE_JS, + {'dt': 0.05, 'seconds': 400, 'dwellS': TRAM_DWELL, + 'speed': TRAM_SPEED, 'lane': TRAM_LANE}) + ok, why = _tram_runs_pred(run_st, run_drive) + if ok: + OK(f"tram RUNS on '{run_st['town']}': {run_st['stops']} stops over {run_st['routeMetres']} m " + f"({run_st['routeEdges']} main edges, {run_st['shopsFronted']} shops fronted)") + else: + FAIL(f"tram does NOT run on the default boot: {'; '.join(why)} [{run_st}]") + # motion under the SHELL's loop — the assertion the old gate never made + if run_live['moved'] > 0.5 and run_live['uniq'] > 3: + OK(f"tram MOVES under the shell's own loop — {run_live['moved']} m over {run_live['frames']} " + f"frames, {run_live['uniq']} distinct positions ({run_live['first']} → {run_live['last']})") + else: + FAIL(f"tram did not move under the shell's loop: {run_live}") + if run_drive and not run_drive.get('fenced'): + OK(f"tram BEHAVES: travelled {run_drive['path']} m of a {run_st['routeMetres']} m route in 400 s, " + f"{run_drive['dwells']} stop dwell(s) accounting for exactly {run_drive['stillS']} s of door " + f"time ({run_drive['dwells']}×{TRAM_DWELL} s), max cruising tick {run_drive['maxStep']} m ≤ " + f"SPEED·dt {round(TRAM_SPEED*0.05,3)} m, {run_drive['reversals']} end reversal(s) " + f"(2·LANE lane swap) of {run_drive['jumps']} bounded discontinuities " + f"({run_drive['jumpMin']}–{run_drive['jumpMax']} m ≤ 2·LANE), offset from A's main " + f"centreline med {run_drive['offMed']} m [{run_drive['offMin']}–{run_drive['offMax']}] " + f"vs LANE {TRAM_LANE} m") + if errs: FAIL(f"tram/default: {len(errs)} console error(s); first: {errs[0][:140]}") + else: print(' · 0 console errors (default boot)') finally: b.close() + # ── arm 3: the fenced town — genuinely absent, and the old one-liner's verdict on record ───── + b, pg, errs = new_page(p) + fen_st = fen_live = None + try: + boot(pg, f'plansrc=osm&town={TRAM_FENCED_TOWN}') + fen_st = _tram_state(pg) + if not fen_st['obj']: + FAIL(f"tram/fenced: PROCITY.tram is null on {TRAM_FENCED_TOWN} — the absence arm has no " + f"subject to measure; re-pick a fenced town") + elif fen_st['fenced'] is not True: + FAIL(f"tram/fenced: {TRAM_FENCED_TOWN} is NO LONGER FENCED ({fen_st['shopsFronted']} shops " + f"fronted, {fen_st['stops']} stops) — the absence arm lost its subject. Re-pick a fenced " + f"town (R21: katoomba 3 / fremantle 2 / bendigo 1 shops fronted) rather than skipping.") + else: + fen_live = pg.evaluate(_TRAM_LIVE_JS, 30) + ok, why = _tram_absent_pred(fen_st, fen_live) + if ok: + OK(f"tram GENUINELY ABSENT on {TRAM_FENCED_TOWN}: fenced=True, 0 stops, the named group is " + f"EMPTY (0 children), moved {fen_live['moved']} m over {fen_live['frames']} frames " + f"— reason: {str(fen_st['reason'])[:90]}") + else: + FAIL(f"tram/fenced: not genuinely absent — {'; '.join(why)} [{fen_st}]") + # the standing record of the hole this repair closed + if fen_st['legacyPresent']: + print(f" · [record] the pre-R37 assertion `!!scene.getObjectByName('tram')` returns TRUE here " + f"— a {fen_st['groupChildren']}-child group on a town with no tram. That is the whole bug.") + else: + WARN("tram/fenced: the fenced path no longer adds a named empty group — the R37 0.2 " + "premise moved; re-read tram.js:110-114 before trusting this gate's history") + if errs: FAIL(f"tram/fenced: {len(errs)} console error(s); first: {errs[0][:140]}") + finally: + b.close() + + # ── THE CONTROL, demonstrated: each predicate pointed at the other arm's subject must go RED ── + if run_st and fen_st and run_st.get('obj') and fen_st.get('obj'): + ok_ctl, why_ctl = _tram_runs_pred(fen_st, None) + if not ok_ctl: + OK(f"CONTROL A: the RUN predicate applied to the fenced town goes RED as required " + f"({why_ctl[0]}) — the run arm cannot pass on a town with no tram") + else: + FAIL('CONTROL A FAILED: the RUN predicate PASSES on the fenced town — this gate is vacuous again') + ok_ctl2, why_ctl2 = _tram_absent_pred(run_st, run_live) + if not ok_ctl2: + OK(f"CONTROL B: the ABSENCE predicate applied to the running tram goes RED as required " + f"({why_ctl2[0]}) — the absence arm cannot pass on a town where the tram runs") + else: + FAIL('CONTROL B FAILED: the ABSENCE predicate PASSES on the running tram — absence is unfalsifiable') + else: + FAIL('tram: controls unrun — one of the two subjects was missing (see the failures above)') + + # ── arm 4: the flag is a real off switch (?tram=0 and ?classic=1) ──────────────────────────── + for q, label in (('tram=0', '?tram=0'), ('classic=1', '?classic=1')): + b, pg, errs = new_page(p) + try: + boot(pg, q) + off = pg.evaluate("""() => ({ obj: window.PROCITY.tram, + named: !!window.PROCITY.scene.getObjectByName('tram') })""") + if off['obj'] is None and not off['named']: + OK(f"{label}: no tram object AND no 'tram' in the scene (the flag is a real off switch)") + else: + FAIL(f"{label}: tram leaked — {off}") + if errs: FAIL(f"tram/{label}: {len(errs)} console error(s); first: {errs[0][:140]}") + finally: + b.close() + def smoke_audio(p): """R11 audio house-law (Lane F): silent-and-happy, nothing plays pre-gesture, ?mute=1 silences, ?noassets=1 fetches zero audio, and interior beds play + release AudioNodes across enter/exit. @@ -2738,9 +3181,29 @@ def smoke_rotation(p): def main(): + # [R37] `--only name[,name…]` runs a subset of the smokes by function name (e.g. `--only smoke_tram`). + # Development ergonomics only: the suite's contract is still "run them all"; --only prints a banner + # so a partial run can never be mistaken for a green suite in a log. + only = None + for i, arg in enumerate(sys.argv[1:]): + if arg == '--only' and i + 2 <= len(sys.argv[1:]): + only = set(sys.argv[i + 2].split(',')) + elif arg.startswith('--only='): + only = set(arg.split('=', 1)[1].split(',')) + if only: + print(f"\033[33m● PARTIAL RUN — --only {sorted(only)} (NOT a suite verdict)\033[0m") + srv = ensure_server() try: with sync_playwright() as p: + if only: + g = globals() + for name in sorted(only): + fn = g.get(name) + if not callable(fn): + sys.exit(f"--only: no such check '{name}'") + fn(p) + raise SystemExit(_verdict(partial=True)) flags = list(KNOWN_FLAGS) if plansrc_landed(p): flags.append('plansrc=osm'); print("· plansrc=osm detected as landed — included") @@ -2780,15 +3243,19 @@ def main(): finally: if srv: srv.terminate() - head('VERDICT') + sys.exit(_verdict()) + + +def _verdict(partial=False): + head('VERDICT' + (' (PARTIAL — --only)' if partial else '')) print(json.dumps({'fails': len(fails), 'warns': len(warns)}, indent=0)) if fails: print(f"\033[31m● flags_check RED\033[0m — {len(fails)} failure(s), {len(warns)} warning(s).") for f in fails: print(' FAIL:', f) - sys.exit(1) - print(f"\033[32m● flags_check GREEN\033[0m — {len(warns)} warning(s).") + return 1 + print(f"\033[32m● flags_check {'PARTIAL-GREEN' if partial else 'GREEN'}\033[0m — {len(warns)} warning(s).") for w in warns: print(' warn:', w) - sys.exit(0) + return 0 if __name__ == '__main__': main() diff --git a/tools/qa/budget_walk.py b/tools/qa/budget_walk.py new file mode 100644 index 0000000..5a98dbd --- /dev/null +++ b/tools/qa/budget_walk.py @@ -0,0 +1,472 @@ +#!/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 /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()