PROCITY/tools/qa/interior_scale_check.py
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:49:49 +10:00

219 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""PROCITY Lane D — interior figure-scale gate (Round 10 regression guard).
Guards the R9 blocker: interior rig figures came out ~2x too tall (keeper head at 3.83 m in a
3.4 m room) because rigs.js `buildFigure` normalised by the head bone's world Y in BIND POSE —
which is head-above-hips (the skeleton origin), ~half the standing height — instead of the
feet->crown span. Fixed in R10 (normalise by `headY - minY`).
This gate boots the real game, enters a sample of open shops, spawns browser rigs at every browse
point (occupancy is 0 right after boot, so we inject it to exercise the browse spawn path
deterministically), and asserts for EVERY spawned interior figure:
1.4 m <= STATURE (crown - foot) <= 2.0 m (human-sized — platform-independent)
crown(head-bone world Y) < room dims.H (never clips the ceiling — its own line)
|feet(min-bone world Y)| < 0.05 m (planted on the floor)
Exits non-zero if any figure fails, so tools/qa.sh --strict fails a giant before it can ship.
[Lane F R13 — debt #6] The human-size check measures STATURE (feet→crown span), not the raw world
crown, so it stays correct the day a figure stands on a platform (a keeper on a 0.3 m riser is not a
giant). This is the same measure smoke_gigs uses for the band on Lane C's stage deck, now ported into
the shared gate; the world-crown "never clips the ceiling" assert is kept as its own separate line
(that is the real R10 failure mode). For floor figures foot≈0 ⇒ stature≈crown ⇒ fully backward-compatible.
Run: tools/.venv/bin/python tools/qa/interior_scale_check.py [--seed N]
Setup: the same Playwright venv as tools/soak.py / tools/flags_check.py.
Needs Lane B's window.DBG hook (?dbg=1) + window.PROCITY.interiorMode (Lane C/F).
"""
import sys, os, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = int(os.environ.get('PROCITY_QA_PORT', '8130')) # [R31] port-isolated suite runs (see flags_check.py)
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
if '--seed' in sys.argv:
SEED = int(sys.argv[sys.argv.index('--seed') + 1])
LO, HI = 1.4, 2.0 # human crown band (Round-10 gate)
SEAT_LO, SEAT_HI = 0.9, 1.5 # [R16 ledger #3] seated figs (the drummer): a seated human's feet→crown span, still < ceiling, feet planted
FOOT_EPS = 0.05 # feet must plant at floor y=0
SAMPLE_TYPES = ['opshop', 'toy', 'video', 'record', 'book', 'milkbar', 'clothes', 'hardware']
# [R36 w2 — E's flag (a)] the five trellis DJ bodies (b339402, swept R36 w1). Their bind skeletons
# measure ~0.950.99 UNITS tall vs the legacy roster's ~173180 (the cm convention) — absorbed by
# design at rigs.js:163 (span-normalise at spawn), but they joined PED_NAMES ungated and the browse
# sample above only ever draws pedIndex (i*7)%19, which can never reach fleet indices 1721. This leg
# spawns each of the five BY NAME and asserts the spawned stature lands human — the number E flagged,
# proven at the spawn path, not inferred from the file.
# [R42 §42.4] `dj_phrtt_01` -> `man_smart_01`: ruling 1 retired the unclothed trellis body and Lane D's
# in-place swap put a Mixamo Ch23 in its `PED_NAMES.djs` slot, so fleet index 21 is still the subject of
# this leg — only the body changed. The list is deliberately BY NAME (not derived from PED_NAMES) so that
# a roster edit which drops a gated body shows up here as "NOT IN FLEET" rather than silently shrinking
# the sample. The three native bind scales this leg now spans are 0.95-0.99 u (trellis), ~1.76 u (the R42
# Mixamo conversions) and ~173-180 u (the legacy cm roster) — all absorbed by rigs.js's span-normalise.
DJ_BODIES = ['woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'man_smart_01']
fails = []
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
def OK(m): print(f" \033[32m✓\033[0m {m}")
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4)
return s.connect_ex(('127.0.0.1', port)) == 0
def ensure_server():
if port_up(PORT):
return None
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)],
cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50):
if port_up(PORT): return proc
time.sleep(0.1)
proc.terminate(); sys.exit(f"could not start http.server on :{PORT}")
# JS: enter is done via DBG; this measures every interior fig after injecting browsers at browse points.
MEASURE_JS = r"""
(shopId) => {
const P = window.PROCITY, THREE = P.THREE, im = P.interiorMode, v = new THREE.Vector3();
const cur = im.current;
if (!cur || P.mode !== 'interior') return { err: 'not in interior' };
// occupancy is 0 right after boot; inject a browser rig at each browse point to exercise the
// browse spawn path (same spawnRig->buildFigure the shell uses when patronage fills a shop).
const bps = cur.browsePoints || [];
bps.slice(0, 3).forEach((pt, i) => {
try { im.keepers.spawn(cur.group, { x: pt.x, z: pt.z, ry: pt.ry, shopId: shopId, browse: true,
seedKey: 'qa#' + i, pedIndex: (i * 7) % 19 }); } catch (e) {}
});
const H = +cur.dims.H;
const figs = im.keepers.keepers.map(k => {
const a = k.actor; let crown = 0, foot = Infinity;
a.fig.updateWorldMatrix(true, true);
(a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v);
if (/head/i.test(o.name)) crown = Math.max(crown, v.y); foot = Math.min(foot, v.y); } });
return { kind: k.kind, browse: !!k.browse, crown: +crown.toFixed(3), foot: +foot.toFixed(3),
stature: +(crown - foot).toFixed(3), // [R13 debt #6] feet→crown span (platform-independent)
seated: !!(a.fig && a.fig.userData && a.fig.userData.procitySeated) }; // [R16 ledger #3] seated-fig tag
});
return { H: +H.toFixed(2), figs };
}
"""
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit("playwright not installed — python3 -m venv tools/.venv && tools/.venv/bin/pip install playwright && tools/.venv/bin/playwright install chromium")
print(f"\033[1mINTERIOR FIGURE-SCALE GATE\033[0m (seed {SEED}, band [{LO},{HI}] m, no giants)")
srv = ensure_server()
total_figs = 0
shops_checked = 0
try:
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1')
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=25000)
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=15000)
except Exception:
sys.exit("fleet never became ready — cannot test rig scale")
pg.evaluate("() => window.DBG.setSegment(2)") # midday: shops open
for t in SAMPLE_TYPES:
sid = pg.evaluate("""(t) => {
const P=window.PROCITY;
const s=(P.plan.shops||[]).find(x=>x.type===t && P.isOpen(x));
if(!s) return null; window.DBG.enterShop(s.id); return {id:s.id, name:s.name};
}""", t)
if not sid:
continue
pg.wait_for_timeout(400) # interior build + keeper/browser rigs settle
res = pg.evaluate(MEASURE_JS, sid['id'])
if isinstance(res, dict) and res.get('err'):
FAIL(f"{sid['name']} ({t}): {res['err']}")
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()"); pg.wait_for_timeout(150)
continue
H, figs = res['H'], res['figs']
shops_checked += 1
bad = []
for f in figs:
total_figs += 1
lo, hi = (SEAT_LO, SEAT_HI) if f.get('seated') else (LO, HI) # [R16 #3] seated exemption
if not (lo <= f['stature'] <= hi): bad.append(f"stature {f['stature']}m out of [{lo},{hi}]{' seated' if f.get('seated') else ''}")
elif not (f['crown'] < H): bad.append(f"crown {f['crown']}m >= ceiling {H}m")
elif abs(f['foot']) >= FOOT_EPS: bad.append(f"feet at {f['foot']}m (not planted)")
if bad:
FAIL(f"{sid['name']} ({t}, H={H}m): " + "; ".join(bad))
else:
stats = ", ".join(f"{f['stature']:.2f}" for f in figs)
OK(f"{sid['name']} ({t}, H={H}m): {len(figs)} figs statures=[{stats}] all human-sized")
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
pg.wait_for_timeout(150)
# ── [R36 w2] the five DJ bodies, spawned by name (E's flag (a)) ──────────
dj = pg.evaluate(r"""(DJ) => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
const s = (P.plan.shops || []).find(x => x.type === 'record' && P.isOpen(x));
if (!s) return { err: 'no open record shop' };
D.enterShop(s.id);
const im = P.interiorMode, cur = im.current;
if (!cur) return { err: 'interior did not build' };
const bps = cur.browsePoints || [];
const H = +cur.dims.H, fleet = P.fleet, figs = [];
DJ.forEach((name, i) => {
const idx = fleet.all.findIndex(r => r.pedName === name);
if (idx < 0) { figs.push({ name, missing: true }); return; }
// native bind span — the number E flagged (~0.950.99 units vs legacy ~173180)
const sc = fleet.all[idx].scene; sc.updateWorldMatrix(true, true);
let bh = 0, bm = Infinity;
sc.traverse(o => { if (o.isBone) { o.getWorldPosition(v);
if (/head/i.test(o.name)) bh = Math.max(bh, v.y); bm = Math.min(bm, v.y); } });
const pt = bps[i % Math.max(1, bps.length)] || { x: 0, z: 0, ry: 0 };
const k = im.keepers.spawn(cur.group, { x: pt.x, z: pt.z, ry: pt.ry, shopId: s.id,
browse: true, seedKey: 'qa-dj#' + i, pedIndex: idx });
const a = k.actor; a.fig.updateWorldMatrix(true, true);
let crown = 0, foot = Infinity;
(a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v);
if (/head/i.test(o.name)) crown = Math.max(crown, v.y); foot = Math.min(foot, v.y); } });
figs.push({ name, idx, kind: k.kind, usedPed: fleet.all[idx].pedName,
nativeSpan: +(bh - bm).toFixed(3), stature: +(crown - foot).toFixed(3),
foot: +foot.toFixed(3), crown: +crown.toFixed(3) });
im.keepers.remove(k);
});
D.exitShop();
return { shop: s.name, H, figs };
}""", DJ_BODIES)
if dj.get('err'):
FAIL(f"DJ-body leg (R36): {dj['err']} — the five trellis bodies went unmeasured")
else:
bad = []
for f in dj['figs']:
if f.get('missing'): bad.append(f"{f['name']} NOT IN FLEET"); continue
total_figs += 1
if f['kind'] != 'rig' or f['usedPed'] != f['name']:
bad.append(f"{f['name']} spawned as {f['kind']}/{f.get('usedPed')} — subject not exercised")
elif not (LO <= f['stature'] <= HI): bad.append(f"{f['name']} stature {f['stature']}m out of [{LO},{HI}] (bind span {f['nativeSpan']}u)")
elif not (f['crown'] < dj['H']): bad.append(f"{f['name']} crown {f['crown']}m >= ceiling {dj['H']}m")
elif abs(f['foot']) >= FOOT_EPS: bad.append(f"{f['name']} feet at {f['foot']}m (not planted)")
if bad:
FAIL(f"DJ bodies (R36, {dj['shop']}): " + "; ".join(bad))
else:
det = ", ".join(f"{f['name']} {f['nativeSpan']}u→{f['stature']:.2f}m" for f in dj['figs'])
OK(f"gated `djs` tail (R36/R42, {dj['shop']}): all 5 spawned as rigs, human-sized — rigs.js span-normalise absorbs every native bind scale [{det}]")
b.close()
finally:
if srv: srv.terminate()
print()
if shops_checked == 0:
sys.exit("\033[31m✗ no shops could be entered — gate inconclusive\033[0m")
if fails:
print(f"\033[31m● {len(fails)} FAIL\033[0m — interior giant(s) detected across {shops_checked} shop(s), {total_figs} figs.")
sys.exit(1)
print(f"\033[32m● PASS\033[0m — {total_figs} interior figs across {shops_checked} shops all human-sized [{LO},{HI}] m and under-ceiling.")
sys.exit(0)
if __name__ == '__main__':
main()