THE RECONCILIATION. The brief's naive sum (292 + washing 1 + magpie 1 = 294) is wrong, in our
favour. The pinned worst-DRAW frame is a NIGHT frame and both of wave 1's draw-spending layers are
structurally zero at night: the washing comes in (count 0), the magpie roosts (count 0). Wave 1's
one draw is spent in daylight, where the walked peak sits ten draws lower.
Two pinned trees, assets symlinked to the live tree on BOTH sides, 802 stations at 2 m down the
main spine at yaw 0 (the worst bearing), citizen sim and tram LIVE, 1280x720:
NIGHT worst draws 292 -> 292 (+0) tris 120,145 -> 122,879
NOON worst draws 282 -> 283 (+1) tris 99,872 -> 103,118
?classic=1 269 / 90,580 unmoved
fitzroy_real 172 / 130,727 unmoved bendigo_real 204 / 131,492 unmoved
(even with ?magpie=1 forced)
The control reproduced R37's pin TO THE BYTE, twice, on a fresh checkout. Every revert is exact:
?yards=0 at night returns all three control numbers; ?yards=0 at NOON returns 282/99,872, which
isolates the noon +1 to the washing and nothing else. MARGIN IS STILL EIGHT DRAWS.
LANE B's "control 346", answered rather than dismissed. As a delta it is the wall-clock ped-spawn
artifact R37 named, and B was right to hide the sim. But 346 > 300 is a claim about the world, and
neither stepwalk (walks the chunks in, photographs a half-dressed street) nor dwell (fills the
street, arrives clean at 27 chunks) can adjudicate it. budget_walk.py gains --method stepsettle:
the audit's fixed-yaw stations, each DRAINED then held N real rAF frames — R+1 dispose residue AND
a fully-acquired crowd in the same frame. Like for like on the same 101 stations, the settle is
worth +3 draws at noon and -9 at night, and the SIGN FLIP is the finding: at night the settled
street retires near rigs to impostors (tris 120,145 -> 100,939) — the crowd goes home, and a
sampler that never waits does not see it happen. The highest number anywhere in the reconciliation
(4 methods x 2 trees x 2 segments x 5 towns) is 292, the pin. NOTHING READ OVER 300.
A CORRECTION TO MY OWN R37 PIN: the corpus TRI worst is 130,727 on fitzroy_real, not 124,793 — and
it reads 130,727 on the CONTROL tree, so it is my R37 station spacing (12-bearing 8 m sweep) versus
this round's 2 m stations, not a wave-1 cost. Station spacing, not settle policy, is the biggest
lever on a walked worst frame. A pin is only as good as its spacing, and mine had not said so.
FOUR GATES, FOUR CONTROLS DEMONSTRATED (smoke_ambient / smoke_character / smoke_magpie /
smoke_yards, wired into flags_check.main()). A gate that cannot fail is not a gate — this round
earned the sentence twice, R37 killing two vacuous gates and B catching its own season mechanism
producing a constant that would have gated green under ?magpie=1.
1.1 audio — 6 sources anchored 63/8/81/32/39/57 (B's numbers exactly); dog AUDIBLE at 5.9 m with
1 play scheduled (for a one-shot, `audible` is only published once plays>0, so one word covers
fetch/decode/gain/schedule/counter); HOURS enforced (sprinkler audible 06:30 -> idle 12:30, same
pose); DAY-OF-WEEK enforced (mower idle Mon -> audible Sat, same pose); PAN is a bearing (yaw
0/90/180/270 -> +0.00 +0.85 -0.00 -0.85); ZERO DRAWS byte-identical to ?ambient=0. THE CONTROL:
a second server whose root is web/ symlink-for-symlink except one patched manifest with
sfx['dog-bark-fence'] renamed -> no-entry, 0 plays, never audible, other five unchanged, 0
console errors. Printed on every run: audible OUTPUT is unverifiable headlessly. Nobody in this
loop has ears.
1.2 character — classic is the frozen v1 literals, all tints null, 5 ground meshes; all 23 shipped
keys matched PER TOWN including the two carrying state:''; the control fires (unknown key ->
matched:false); 21 distinct palettes across 23 towns (one shared palette FAILS by name); and the
two orphans are ON THE GPU — ground-gravel.jpg bound on Castlemaine, ground-reddust.jpg on
Darwin, read off the live material.
1.3 magpie — the latch arm verbatim with its positive control FIRST; day-roll clears it;
localStorage gained NO new key (baseline taken after a game.save() — the first cut blamed the
magpie for v7's own write); the season is 23 distinct positions across 24 towns run UNFORCED,
against the first cut's 24-of-24-at-214, newtown_real in season on day 1, 129/1000 seeds against
a 11.5% design ratio; territory 6 -> 157 synthetic -> bendigo; +1 draw / +182 tris at ONE pose
reused across both boots.
1.4 yards — 181 lots, 181 hoists -> 394 items, 256 active, 0 at night; cost decomposition at one
pose with the sim quiet: box props +0 draws (in fact -2, the 36 `yard` lots used to fall through
to buildShopfront) with +1,936 tris, washing exactly +1 draw / +512 tris; and on real towns it
SKIPS LOUDLY — a ⊘ SCOPE line printing 0 house/yard lots / 0 hoists / mesh never created, beside
the synthetic 181 that makes the zero attributable.
Caught in my own work: the pan arm's first draft used two bearings and read +0.00/-0.00 because
that anchor sat on the forward axis — it would have called a working panner inconclusive. Four
bearings now. The gate's first draft failed the standard the gate exists to enforce.
SUITE: tools/qa.sh --strict --matrix GREEN on the true final tree (74d6257) — 7 passed, 0 failed,
0 warn, 0 skipped; flags_check GREEN 0/0 with all four new gates inside it; MATRIX GREEN 10 towns
x 7 gates, 0 fetches under ?noassets on all ten.
Held: nothing. Recorded not fixed: magpie.js says 154 triangles at :9/:123 and 182 at :42 (measured
182); E's three published GLBs are manifest-and-depot but not under web/assets/models (staged in
pipeline/_normalized, nothing loads them, the validator's contract covers it — not a defect).
NO TAG — wave 1 is content; the epoch tags when it is whole.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
539 lines
26 KiB
Python
539 lines
26 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).
|
||
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 <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;
|
||
}
|
||
"""
|
||
|
||
|
||
# ── [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()
|