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>
623 lines
36 KiB
Python
623 lines
36 KiB
Python
#!/usr/bin/env python3
|
||
"""PROCITY Lane D — R42 §42.4 gate: THE RECAST, WIRED.
|
||
|
||
tools/.venv/bin/python tools/qa/r42_cast.py [--seed N] [--outdir DIR] 0 green · 1 red
|
||
|
||
Ruling 1 (PROCITY is 1990s Australia) retired five ped bodies; Lane E sourced five replacements and
|
||
this gate is the proof that swapping them into `rigs.js` changed the CAST and nothing else. Six arms,
|
||
each with the control that makes it non-vacuous, every number measured on this tree.
|
||
|
||
1. CONTRACT the five replacement GLBs read off their own bytes — nodes / skin joints /
|
||
primitives / materials / images / mime / extensions / baked animations / tris /
|
||
bone-name set after `_canon` — and then the test that actually matters: replay
|
||
rigs.js's OWN track filter (canonicalise, keep `.quaternion`, drop
|
||
`Hips.quaternion`) over all 54 shipped clips and count bindable tracks against
|
||
the reference ped. CONTROL: `comical_boy_01` (an outgoing reject, still on disk)
|
||
must score STRICTLY LOWER — otherwise the replay cannot tell a good ped from a
|
||
bad one. It scores 3 132/3 460, because six of its thumb bones lost the
|
||
`mixamorig:` prefix, so the instrument discriminates on a real defect.
|
||
2. ROSTER fresh default and `?classic=1` contexts: the live fleet's names BY INDEX, the
|
||
three pool lengths, and a NETWORK sweep of every GLB the boot fetched. None of
|
||
the five retired bodies may be in the fleet or on the wire. CONTROL: the five
|
||
replacements are present, at the exact indices the retired five held.
|
||
3. DETERMINISM two fresh contexts, same seed: byte-equal IDENTITY signature (which is where
|
||
`pedIndex` lives) and byte-equal POSTURE signature over the whole active crowd.
|
||
CONTROL: a different seed differs. This is the R2 fleet-order bug's gate — an
|
||
in-place swap must leave every index where it was.
|
||
4. DRAWS ruling 4 gives the street nine draws. Measured per bookmark, and measured so the
|
||
crowd's wall-clock drift cannot forge the answer: the citizens group is hidden
|
||
and the frame re-rendered, so the CITIZEN CONTRIBUTION is a difference, not a
|
||
total. Asserts one draw per near rig plus one for the entire mid crowd.
|
||
5. ANIMATION each replacement spawned BY NAME through the real `makeActor` path, playing walk
|
||
and then idle: bound-track count, the mixer advancing, the skeleton actually
|
||
moving, and the R16 flat-body trap checked as spine tilt (head above hips) and
|
||
stature held across the clip. CONTROL: the reference ped runs the same course.
|
||
6. IMPOSTOR the mid-tier billboard against its own near-tier rig at the swap boundary — the
|
||
R2 failure (a 1.67x exposure mismatch from a double-tone-mapped impostor). Three
|
||
screenshots per subject (empty / rig / billboard) through the shell's real
|
||
composer; ped pixels are the ones that differ from empty. CONTROL: the ratio is
|
||
compared to a SHIPPED ped's ratio, so the claim is "as matched as it ever was"
|
||
rather than an invented tolerance.
|
||
|
||
Fresh headless contexts, own no-store server (this project's documented ES-module cache burn).
|
||
"""
|
||
import sys, os, re, io, json, time, math, struct, socket, subprocess, pathlib
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||
PORT = int(os.environ.get('PROCITY_R42_PORT', '8992'))
|
||
HOST = f'http://127.0.0.1:{PORT}'
|
||
SEED = 20261990
|
||
OUTDIR = ROOT / 'docs' / 'shots' / 'laneD'
|
||
if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1])
|
||
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
|
||
|
||
PEDS = ROOT / 'web' / 'models' / 'peds'
|
||
CLIPS = ROOT / 'web' / 'models' / 'clips'
|
||
REF = 'man_casual_01'
|
||
BASE_CLIPS = ['walk', 'idle', 'sit', 'look', 'dance_party', 'dance_medium', 'dance_drink', 'dance_sway']
|
||
|
||
# the swap, as wired in web/js/citizens/rigs.js — pool, index, out, in
|
||
SWAP = [('normal', 6, 'man_elder_01', 'man_casual_04'),
|
||
('normal', 9, 'man_soldier_ww2_01', 'person_casual_01'),
|
||
('djs', 4, 'dj_phrtt_01', 'man_smart_01'),
|
||
('comical', 0, 'comical_luchador_01', 'woman_smart_02'),
|
||
('comical', 1, 'comical_boy_01', 'person_youth_01')]
|
||
RETIRED = [o for _, _, o, _ in SWAP]
|
||
NEW = [n for _, _, _, n in SWAP]
|
||
BAD_CONTROL = 'comical_boy_01' # on disk, retired, and measurably non-contract (thumb bones)
|
||
POOL_LEN = {'normal': 17, 'djs': 5, 'comical': 2}
|
||
BOOKMARKS = ['street_noon', 'crossroads_busy', 'night_crowd', 'night_neon', 'district_posters',
|
||
'venue_night', 'market_square', 'patronage_door']
|
||
|
||
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 head(m): print(f"\n\033[1m{m}\033[0m")
|
||
def note(m): print(f" \033[33m·\033[0m {m}")
|
||
def check(c, m): (OK if c else FAIL)(m); return c
|
||
|
||
NOSTORE = r'''
|
||
import sys, http.server, functools
|
||
class H(http.server.SimpleHTTPRequestHandler):
|
||
def end_headers(self):
|
||
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||
super().end_headers()
|
||
def log_message(self, *a): pass
|
||
http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), functools.partial(H, directory=sys.argv[2])).serve_forever()
|
||
'''
|
||
|
||
# ══ ARM 1: the contract, read off the GLB bytes (no browser, no deps) ═══════════════════════════════
|
||
_canon = lambda s: re.sub(r'mixamorig\d+', 'mixamorig', s or '')
|
||
# glTF animation channel path -> the suffix three's GLTFLoader gives the track. rigs.js `_rotOnly`
|
||
# filters on `.quaternion`, which is three's name for glTF `rotation` — comparing against the raw
|
||
# glTF path finds nothing at all and silently "passes".
|
||
_PATH = {'rotation': 'quaternion', 'translation': 'position', 'scale': 'scale'}
|
||
|
||
|
||
def gltf(path):
|
||
d = path.read_bytes()
|
||
if d[:4] != b'glTF': raise ValueError(f'{path} is not a GLB')
|
||
n = struct.unpack('<I', d[12:16])[0]
|
||
return json.loads(d[20:20 + n]), len(d)
|
||
|
||
|
||
def ped_facts(name):
|
||
j, size = gltf(PEDS / (name + '.glb'))
|
||
names = [n.get('name', '') for n in j.get('nodes', [])]
|
||
joints = j['skins'][0]['joints'] if j.get('skins') else []
|
||
tris = sum(j['accessors'][p['indices']]['count'] // 3
|
||
for m in j.get('meshes', []) for p in m['primitives'] if 'indices' in p)
|
||
return dict(name=name, bytes=size, nodes=len(names), joints=len(joints),
|
||
jnames=sorted(_canon(names[i]) for i in joints),
|
||
prims=sum(len(m.get('primitives', [])) for m in j.get('meshes', [])),
|
||
mats=len(j.get('materials', [])), imgs=len(j.get('images', [])),
|
||
mime=[i.get('mimeType') for i in j.get('images', [])],
|
||
ext=j.get('extensionsUsed', []), anims=len(j.get('animations', [])), tris=tris,
|
||
nodeSet=set(_canon(x) for x in names))
|
||
|
||
|
||
def clip_corpus():
|
||
"""Every clip the fleet can bind: the 8 base ped clips + Lane E's 46-clip library."""
|
||
out = []
|
||
for c in BASE_CLIPS:
|
||
p = PEDS / (c + '.glb')
|
||
if p.exists(): out += [(c + ':' + nm, tr) for nm, tr in _tracks(p)]
|
||
for p in sorted(CLIPS.glob('*.glb')):
|
||
out += [(p.stem + ':' + nm, tr) for nm, tr in _tracks(p)]
|
||
return out
|
||
|
||
|
||
def _tracks(path):
|
||
j, _ = gltf(path)
|
||
names = [n.get('name', '') for n in j.get('nodes', [])]
|
||
res = []
|
||
for a in j.get('animations', []):
|
||
tr = [f"{_canon(names[c['target']['node']])}.{_PATH.get(c['target']['path'], c['target']['path'])}"
|
||
for c in a.get('channels', []) if 'node' in c.get('target', {})]
|
||
res.append((a.get('name', ''), tr))
|
||
return res
|
||
|
||
|
||
def bind_score(ped, corpus):
|
||
"""rigs.js's own filter, replayed: canon (done) -> keep .quaternion -> drop Hips.quaternion."""
|
||
tot = bound = 0; worst = None; unb = set()
|
||
for nm, tr in corpus:
|
||
keep = [t for t in tr if t.endswith('.quaternion') and not re.search(r'Hips\.quaternion$', t, re.I)]
|
||
b = sum(1 for t in keep if t.rsplit('.', 1)[0] in ped['nodeSet'])
|
||
unb |= {t.rsplit('.', 1)[0] for t in keep if t.rsplit('.', 1)[0] not in ped['nodeSet']}
|
||
tot += len(keep); bound += b
|
||
if worst is None or b < worst[1]: worst = (nm, b)
|
||
return tot, bound, worst, sorted(unb)
|
||
|
||
|
||
def arm_contract():
|
||
head('1. CONTRACT — the five replacements measured off their own GLB bytes')
|
||
ref = ped_facts(REF)
|
||
corpus = clip_corpus()
|
||
rt, rb, rw, ru = bind_score(ref, corpus)
|
||
note(f"reference {REF}: {ref['nodes']} nodes / {ref['joints']} joints / {ref['prims']} prim / "
|
||
f"{ref['mats']} mtl / {ref['imgs']} img {ref['mime']} / ext {ref['ext']} / {ref['anims']} anims / "
|
||
f"{ref['tris']} tris")
|
||
note(f"clip corpus: {len(corpus)} clips ({len(BASE_CLIPS)} base + {len(list(CLIPS.glob('*.glb')))} "
|
||
f"library GLBs) → reference binds {rb}/{rt}, worst single clip {rw[1]}, unbound {ru}")
|
||
rows = []
|
||
for n in NEW:
|
||
p = PEDS / (n + '.glb')
|
||
if not p.exists():
|
||
FAIL(f'{n}.glb is not on the tree'); continue
|
||
f = ped_facts(n)
|
||
t, b, w, u = bind_score(f, corpus)
|
||
rows.append((f, t, b, w))
|
||
same = f['jnames'] == ref['jnames']
|
||
shape = (f['nodes'] == ref['nodes'] and f['joints'] == ref['joints'] and f['prims'] == 1
|
||
and f['mats'] == 1 and f['imgs'] == 1 and f['mime'] == ref['mime']
|
||
and f['anims'] == 0 and 'KHR_draco_mesh_compression' not in f['ext'])
|
||
check(shape and same and (b, t) == (rb, rt) and w[1] == rw[1],
|
||
f"{n}: {f['nodes']}/{f['joints']} nodes/joints · {f['prims']}/{f['mats']}/{f['imgs']} "
|
||
f"prim/mtl/img {f['mime'][0] if f['mime'] else '-'} · {f['anims']} baked anims · "
|
||
f"{f['tris']} tris · boneset {'IDENTICAL' if same else 'DIFFERENT'} · binds {b}/{t} "
|
||
f"(worst clip {w[1]}) — {'contract-clean' if shape and same else 'DEVIATES'}")
|
||
# CONTROL: the instrument must be able to fail
|
||
if (PEDS / (BAD_CONTROL + '.glb')).exists():
|
||
bf = ped_facts(BAD_CONTROL)
|
||
bt, bb, bw, bu = bind_score(bf, corpus)
|
||
check(bb < rb, f"CONTROL: retired {BAD_CONTROL} scores {bb}/{bt} (worst clip {bw[1]}) — STRICTLY "
|
||
f"LOWER than the reference's {rb}/{rt}. Unbound: {bu[:3]}… so the replay "
|
||
f"discriminates; it was a real, silent contract break on a shipped ped.")
|
||
else:
|
||
note(f'{BAD_CONTROL}.glb not on disk — bind-score control unavailable')
|
||
if rows:
|
||
note('tri band ' + ' · '.join(f"{f['name']} {f['tris']}" for f, *_ in rows))
|
||
return {'ref': {k: ref[k] for k in ('nodes', 'joints', 'prims', 'mats', 'imgs', 'tris')},
|
||
'refBind': [rb, rt], 'rows': [{'name': f['name'], 'tris': f['tris'], 'bind': [b, t]}
|
||
for f, t, b, w in rows]}
|
||
|
||
|
||
# ══ browser plumbing ════════════════════════════════════════════════════════════════════════════════
|
||
def port_up(p):
|
||
with socket.socket() as s:
|
||
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', p)) == 0
|
||
|
||
|
||
def serve():
|
||
pr = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
for _ in range(80):
|
||
if port_up(PORT): return pr
|
||
time.sleep(0.1)
|
||
pr.terminate(); raise SystemExit(f'could not serve on :{PORT}')
|
||
|
||
|
||
def new_page(p):
|
||
b = p.chromium.launch()
|
||
pg = b.new_page(viewport={'width': 1280, 'height': 720})
|
||
errs, reqs = [], []
|
||
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
|
||
pg.on('pageerror', lambda e: errs.append(str(e)))
|
||
pg.on('request', lambda r: reqs.append(r.url))
|
||
return b, pg, errs, reqs
|
||
|
||
|
||
def boot(pg, q='', fleet=True):
|
||
pg.goto(f'{HOST}/index.html?seed={SEED}' + (('&' + q) if q else '') + '&dbg=1')
|
||
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'; }")
|
||
if fleet:
|
||
pg.wait_for_function('() => window.PROCITY.fleet && window.PROCITY.fleet.ready', timeout=25000)
|
||
|
||
|
||
ROSTER = """() => { const f = window.PROCITY.fleet;
|
||
return { normal: f.normal.map(r => r.pedName), comical: f.comical.map(r => r.pedName),
|
||
all: f.all.map(r => r.pedName) }; }"""
|
||
|
||
# max draws over n natural frames, then the same frame with the citizens hidden: the difference IS the
|
||
# crowd's draw cost, and it is immune to the wall-clock drift that makes raw totals wobble +-3.
|
||
DRAW_SPLIT = r"""
|
||
async (n) => {
|
||
const P = window.PROCITY, C = P.citizens;
|
||
let mx = 0, mt = 0, near = 0, mid = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
await new Promise(r => requestAnimationFrame(() => r()));
|
||
const r = P.renderer.info.render;
|
||
if (r.calls > mx) { mx = r.calls; mt = r.triangles; near = C.stats.rigged; mid = C.stats.mid; }
|
||
}
|
||
// measure the same pose twice more: crowd in, crowd out. Nothing else changes between them.
|
||
const frame = async () => { await new Promise(r => requestAnimationFrame(() => r()));
|
||
return P.renderer.info.render.calls; };
|
||
const withCrowd = await frame();
|
||
const nearNow = C.stats.rigged, midNow = C.stats.mid;
|
||
C.group.visible = false;
|
||
const without = await frame();
|
||
C.group.visible = true;
|
||
await frame();
|
||
return { max: mx, tris: mt, nearAtMax: near, midAtMax: mid,
|
||
withCrowd, without, near: nearNow, mid: midNow,
|
||
budget: (P.budget || {}).draws || 300 };
|
||
}
|
||
"""
|
||
|
||
# each replacement spawned by name through the real makeActor path and actually animated
|
||
ANIM = r"""
|
||
async (names) => {
|
||
const P = window.PROCITY, THREE = P.THREE, fleet = P.fleet;
|
||
const rigs = await import('./js/citizens/rigs.js');
|
||
const out = [];
|
||
for (const name of names) {
|
||
const rig = fleet.all.find(r => r.pedName === name);
|
||
if (!rig) { out.push({ name, missing: true }); continue; }
|
||
const a = rigs.makeActor(rig, { walkClip: fleet.walkClip, idleClip: fleet.idleClip, nominalHeight: 1.75 });
|
||
if (!a) { out.push({ name, noActor: true }); continue; }
|
||
const stage = new THREE.Group(); stage.add(a.fig); P.scene.add(stage);
|
||
const bones = []; a.inner.traverse(o => { if (o.isBone) bones.push(o); });
|
||
const v = new THREE.Vector3();
|
||
const sample = () => { a.fig.updateWorldMatrix(true, true);
|
||
let crown = 0, foot = Infinity, hips = null, head = null, pts = [];
|
||
for (const o of bones) { o.getWorldPosition(v);
|
||
if (/head/i.test(o.name)) crown = Math.max(crown, v.y);
|
||
if (/Hips$/i.test(o.name)) hips = v.y;
|
||
if (/Head$/i.test(o.name) && head === null) head = v.y;
|
||
foot = Math.min(foot, v.y); pts.push(v.x, v.y, v.z); }
|
||
return { crown, foot, hips, head, stature: crown - foot, pts };
|
||
};
|
||
const legs = [];
|
||
for (const [mode, moving] of [['walk', true], ['idle', false]]) {
|
||
a.setMoving(moving, 0);
|
||
a.mixer.update(0.001);
|
||
const s0 = sample();
|
||
let travel = 0, minStat = s0.stature, maxStat = s0.stature, minTilt = 1e9;
|
||
const act = (a.mixer._actions || []).filter(x => x.getEffectiveWeight() > 0.5)[0] || null;
|
||
const t0 = act ? act.time : null;
|
||
let prev = s0.pts;
|
||
for (let k = 0; k < 24; k++) {
|
||
a.mixer.update(1 / 30);
|
||
const s = sample();
|
||
let d = 0; for (let i = 0; i < prev.length; i++) d += Math.abs(s.pts[i] - prev[i]);
|
||
travel += d; prev = s.pts;
|
||
minStat = Math.min(minStat, s.stature); maxStat = Math.max(maxStat, s.stature);
|
||
if (s.hips != null && s.head != null) minTilt = Math.min(minTilt, s.head - s.hips);
|
||
}
|
||
const tEnd = act ? act.time : null;
|
||
legs.push({ mode, clip: act ? act.getClip().name : null,
|
||
tracks: act ? act.getClip().tracks.length : 0,
|
||
advanced: (t0 != null && tEnd != null) ? +(tEnd - t0).toFixed(3) : null,
|
||
travel: +travel.toFixed(3), minStature: +minStat.toFixed(3),
|
||
maxStature: +maxStat.toFixed(3), minHeadAboveHips: +minTilt.toFixed(3) });
|
||
}
|
||
out.push({ name, height: a.height || 1.75, legs });
|
||
P.scene.remove(stage); a.dispose && a.dispose();
|
||
}
|
||
return out;
|
||
}
|
||
"""
|
||
|
||
# stage one subject at a fixed spot in front of the camera, as a near RIG or as a mid BILLBOARD, using
|
||
# the LIVE atlas the sim baked. Rendered by the shell's own composer, so the tone-map path is the real one.
|
||
IMPOSTOR_STAGE = r"""
|
||
async ({ name, kind }) => {
|
||
const P = window.PROCITY, THREE = P.THREE, C = P.citizens, fleet = P.fleet;
|
||
const rigs = await import('./js/citizens/rigs.js');
|
||
const impM = await import('./js/citizens/impostor.js');
|
||
if (window.__d42) { P.scene.remove(window.__d42.stage);
|
||
if (window.__d42.layer) { window.__d42.layer.mesh.geometry.dispose(); window.__d42.layer.material.dispose(); }
|
||
if (window.__d42.actor && window.__d42.actor.dispose) window.__d42.actor.dispose();
|
||
window.__d42 = null; }
|
||
C.group.visible = (kind === 'crowd');
|
||
if (kind === 'empty' || kind === 'crowd') return { ok: true, kind };
|
||
const idx = fleet.all.findIndex(r => r.pedName === name);
|
||
if (idx < 0) return { ok: false, why: 'not in fleet' };
|
||
const cam = P.camera; cam.updateMatrixWorld(true);
|
||
const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(cam.quaternion); fwd.y = 0; fwd.normalize();
|
||
const D = 7.0, H = 1.75;
|
||
const x = cam.position.x + fwd.x * D, z = cam.position.z + fwd.z * D;
|
||
const groundY = cam.position.y - 1.62; // DBG's eye height above the deck
|
||
const facing = Math.atan2(-(cam.position.x - x), -(cam.position.z - z)); // front (-Z) toward the lens
|
||
const stage = new THREE.Group(); P.scene.add(stage);
|
||
const rec = { stage, layer: null, actor: null };
|
||
if (kind === 'rig') {
|
||
const a = rigs.makeActor(fleet.all[idx], { walkClip: fleet.walkClip, idleClip: fleet.idleClip, nominalHeight: H });
|
||
a.setMoving(true, 0);
|
||
a.mixer.update(0.35 + idx * 0.017); // the EXACT pose sim.js bakes into the atlas
|
||
a.fig.position.set(x, groundY, z); a.fig.rotation.y = facing;
|
||
stage.add(a.fig); rec.actor = a;
|
||
} else {
|
||
const layer = new impM.ImpostorLayer(C.impostor.atlas, { maxInstances: 2 });
|
||
layer.mesh.position.copy(C.group.position); // the live layer rides the citizens group
|
||
stage.add(layer.mesh);
|
||
layer.update([{ x, z, groundY, height: H, subject: idx, facing }], cam);
|
||
rec.layer = layer;
|
||
}
|
||
window.__d42 = rec;
|
||
// The measurement BOX: the billboard's own quad (h*1.14 square, feet at -0.07h — impostor.js's own
|
||
// framing constants) projected to screen. Cropping to it is what keeps a chunk streaming in at the
|
||
// far end of the street out of a luminance number about one ped; an unbounded full-frame difference
|
||
// reported up to 68 000 "ped" pixels when a distant block popped between two shots.
|
||
const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), fwd).normalize();
|
||
const half = H * 1.14 * 0.5, W = window.innerWidth, Hp = window.innerHeight;
|
||
let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
|
||
for (const sx of [-1, 1]) for (const sy of [0, 1]) {
|
||
const w = new THREE.Vector3(x + right.x * half * sx, groundY - H * 0.07 + H * 1.14 * sy,
|
||
z + right.z * half * sx).project(cam);
|
||
const px = (w.x * 0.5 + 0.5) * W, py = (1 - (w.y * 0.5 + 0.5)) * Hp;
|
||
x0 = Math.min(x0, px); x1 = Math.max(x1, px); y0 = Math.min(y0, py); y1 = Math.max(y1, py);
|
||
}
|
||
const M = 10;
|
||
return { ok: true, kind, idx, box: [Math.max(0, Math.floor(x0 - M)), Math.max(0, Math.floor(y0 - M)),
|
||
Math.min(W, Math.ceil(x1 + M)), Math.min(Hp, Math.ceil(y1 + M))] };
|
||
}
|
||
"""
|
||
|
||
|
||
def crop_stats(png_a, png_b, box):
|
||
"""mean luminance of the pixels in `box` where b differs from a — i.e. the staged subject only.
|
||
|
||
Differencing against an EMPTY frame is what makes this a measurement of the ped rather than of
|
||
the street behind it: the background is identical in both shots, so every surviving pixel belongs
|
||
to the thing that was staged."""
|
||
from PIL import Image, ImageChops, ImageStat
|
||
a = Image.open(io.BytesIO(png_a)).convert('RGB').crop(box)
|
||
b = Image.open(io.BytesIO(png_b)).convert('RGB').crop(box)
|
||
d = ImageChops.difference(a, b).convert('L').point(lambda v: 255 if v > 8 else 0)
|
||
st = ImageStat.Stat(b.convert('L'), d)
|
||
n = st.count[0]
|
||
return (st.mean[0] if n else 0.0), n, b, d
|
||
|
||
|
||
def main():
|
||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||
from playwright.sync_api import sync_playwright
|
||
report = {'seed': SEED}
|
||
report['contract'] = arm_contract()
|
||
srv = serve()
|
||
try:
|
||
with sync_playwright() as p:
|
||
# ── 2. roster + period law ────────────────────────────────────────────────────────────
|
||
head('2. ROSTER — the swap is wired, in place, and nothing retired is on the wire')
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg)
|
||
pg.wait_for_timeout(2500)
|
||
r = pg.evaluate(ROSTER)
|
||
fetched = sorted({u.rsplit('/', 1)[-1].split('?')[0] for u in reqs
|
||
if '/models/peds/' in u and u.endswith('.glb')})
|
||
report['roster'] = {'default': r, 'fetched': fetched}
|
||
live = set(r['all'])
|
||
check(not (live & set(RETIRED)),
|
||
f"default boot fleet ({len(r['all'])} bodies) holds NONE of the five retired: "
|
||
f"{', '.join(RETIRED)}")
|
||
onwire = sorted(set(RETIRED) & {f[:-4] for f in fetched})
|
||
check(not onwire,
|
||
f'default boot fetched {len(fetched)} ped GLBs and none of them is a retired body '
|
||
f'(dj_phrtt_01 on the wire: {"YES" if "dj_phrtt_01.glb" in fetched else "no"})')
|
||
check(all(n in live for n in NEW), f"all five replacements present: {', '.join(NEW)}")
|
||
# `fleet.normal` is PED_NAMES.normal ++ PED_NAMES.djs (R36 w2.5 appends the gated tail),
|
||
# and `fleet.all` is that ++ comical — so the three source pools are slices of the live one.
|
||
pools = {'normal': r['normal'][:POOL_LEN['normal']],
|
||
'djs': r['normal'][POOL_LEN['normal']:],
|
||
'comical': r['comical']}
|
||
for pool, i, out, inn in SWAP:
|
||
lst = pools[pool]
|
||
got = lst[i] if len(lst) > i else None
|
||
allIdx = r['all'].index(got) if got in r['all'] else None
|
||
check(got == inn, f"{pool}[{i}] (fleet.all[{allIdx}]): {out} → {got} (expected {inn})")
|
||
check(len(r['normal']) == POOL_LEN['normal'] + POOL_LEN['djs'] and
|
||
len(r['comical']) == POOL_LEN['comical'] and len(r['all']) == 24,
|
||
f"pool lengths unmoved: normal+djs {len(r['normal'])} (17+5) · comical "
|
||
f"{len(r['comical'])} · fleet.all {len(r['all'])} — every pickRig index is where it was")
|
||
check(not errs, f'default boot: 0 console errors ({len(reqs)} requests swept)')
|
||
b.close()
|
||
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg, 'classic=1')
|
||
pg.wait_for_timeout(2500)
|
||
rc = pg.evaluate(ROSTER)
|
||
cf = sorted({u.rsplit('/', 1)[-1].split('?')[0] for u in reqs
|
||
if '/models/peds/' in u and u.endswith('.glb')})
|
||
report['roster']['classic'] = {'roster': rc, 'fetched': cf}
|
||
check(len(rc['normal']) == POOL_LEN['normal'] and not (set(rc['all']) & set(RETIRED)),
|
||
f"?classic=1: covenanted {len(rc['normal'])}-body pool, no retired body "
|
||
f"({len(rc['all'])} in fleet.all)")
|
||
check('dj_phrtt_01.glb' not in cf and not (set(RETIRED) & {f[:-4] for f in cf}),
|
||
f'?classic=1 fetched {len(cf)} ped GLBs, none retired')
|
||
check(not errs, '?classic=1: 0 console errors')
|
||
b.close()
|
||
|
||
# ── 3. determinism ────────────────────────────────────────────────────────────────────
|
||
head('3. DETERMINISM — same seed → same crowd, across fresh contexts (the R2 gate)')
|
||
sigs = []
|
||
for _ in range(2):
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg)
|
||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||
pg.wait_for_timeout(3000)
|
||
sigs.append((pg.evaluate("() => window.PROCITY.citizens.identitySignature().join('\\n')"),
|
||
pg.evaluate("() => window.PROCITY.citizens.postureSignature().join('\\n')")))
|
||
b.close()
|
||
n = len(sigs[0][0].splitlines())
|
||
check(sigs[0][0] == sigs[1][0] and n > 0,
|
||
f'{n} active citizens, two fresh contexts → byte-equal IDENTITY signature '
|
||
f'(id:pedIndex:pvar:height:speed:edge:forward)')
|
||
check(sigs[0][1] == sigs[1][1], 'and byte-equal POSTURE signature')
|
||
report['determinism'] = {'n': n, 'idsig_equal': sigs[0][0] == sigs[1][0],
|
||
'psig_equal': sigs[0][1] == sigs[1][1]}
|
||
b, pg, errs, reqs = new_page(p)
|
||
pg.goto(f'{HOST}/index.html?seed={SEED + 1}&dbg=1')
|
||
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'; }")
|
||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||
pg.wait_for_timeout(3000)
|
||
other = pg.evaluate("() => window.PROCITY.citizens.identitySignature().join('\\n')")
|
||
check(other != sigs[0][0], f'CONTROL: seed {SEED + 1} gives a DIFFERENT identity signature '
|
||
f'({len(other.splitlines())} citizens) — byte-equal is not constant')
|
||
b.close()
|
||
|
||
# ── 4. draws ──────────────────────────────────────────────────────────────────────────
|
||
head('4. DRAWS — the crowd\'s cost measured as a difference, not a total (ruling 4)')
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg)
|
||
pg.wait_for_timeout(6000)
|
||
print(f" {'bookmark':<20} {'max':>5} {'budget':>7} {'crowd in':>9} {'crowd out':>10} "
|
||
f"{'Δ':>4} {'near':>5} {'mid':>4} {'expected':>9}")
|
||
dtab = {}
|
||
for bm in BOOKMARKS:
|
||
pg.evaluate('(n) => window.DBG.shot(n)', bm)
|
||
pg.wait_for_timeout(2500)
|
||
pg.evaluate('(n) => window.DBG.shot(n)', bm)
|
||
d = pg.evaluate(DRAW_SPLIT, 24)
|
||
dtab[bm] = d
|
||
exp = d['near'] + (1 if d['mid'] else 0)
|
||
got = d['withCrowd'] - d['without']
|
||
print(f" {bm:<20} {d['max']:>5} {d['budget']:>7} {d['withCrowd']:>9} {d['without']:>10} "
|
||
f"{got:>4} {d['near']:>5} {d['mid']:>4} {exp:>9}")
|
||
check(d['max'] <= d['budget'], f'{bm}: {d["max"]} draws ≤ budget {d["budget"]}')
|
||
check(got == exp,
|
||
f'{bm}: the whole crowd costs {got} draws = {d["near"]} near rigs × 1 + '
|
||
f'{1 if d["mid"] else 0} instanced billboard layer for {d["mid"]} mid citizens')
|
||
report['draws'] = dtab
|
||
note('one primitive / one material per ped (arm 1) ⇒ a one-for-one swap cannot move this '
|
||
'number; the totals wobble only with which citizens the wall clock has walked into frame')
|
||
b.close()
|
||
|
||
# ── 5. animation ──────────────────────────────────────────────────────────────────────
|
||
head('5. ANIMATION — every new body plays walk and idle, and neither folds it')
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg)
|
||
pg.wait_for_timeout(2500)
|
||
anim = pg.evaluate(ANIM, NEW + [REF])
|
||
report['anim'] = anim
|
||
for a in anim:
|
||
if a.get('missing') or a.get('noActor'):
|
||
FAIL(f"{a['name']}: could not be spawned ({a})"); continue
|
||
bad = []
|
||
for leg in a['legs']:
|
||
if leg['tracks'] != 64: bad.append(f"{leg['mode']} bound {leg['tracks']} tracks (want 64)")
|
||
if not leg['advanced']: bad.append(f"{leg['mode']} mixer did not advance")
|
||
if leg['travel'] < 1.0: bad.append(f"{leg['mode']} skeleton barely moved ({leg['travel']})")
|
||
if leg['minStature'] < 0.85 * a['height']: bad.append(
|
||
f"{leg['mode']} FOLDED — stature fell to {leg['minStature']}m of {a['height']}m")
|
||
if leg['minHeadAboveHips'] < 0.35: bad.append(
|
||
f"{leg['mode']} FLAT BODY — head only {leg['minHeadAboveHips']}m above hips")
|
||
det = ' · '.join(f"{l['mode']} {l['tracks']}tr Δt{l['advanced']}s travel {l['travel']} "
|
||
f"stature {l['minStature']}–{l['maxStature']}m head+{l['minHeadAboveHips']}m"
|
||
for l in a['legs'])
|
||
check(not bad, f"{a['name']} ({a['height']}m): {det}" + (' ⟵ ' + '; '.join(bad) if bad else ''))
|
||
b.close()
|
||
|
||
# ── 6. impostor vs rig at the swap boundary ───────────────────────────────────────────
|
||
head('6. IMPOSTOR — the mid billboard against its own near rig (the R2 exposure trap)')
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg)
|
||
# settle hard before measuring: the bookmark pauses the day cycle, but the sky/PMREM and
|
||
# the exposure ramp reach steady state on their own clock, and ACES makes a global exposure
|
||
# change a NON-linear shift in any ratio measured off the canvas. Every subject in this arm
|
||
# is therefore measured inside ONE run, and the claim is a within-run comparison against the
|
||
# bodies that did not change — absolute ratios move a little run to run; that comparison does not.
|
||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||
pg.wait_for_timeout(4000)
|
||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||
pg.wait_for_timeout(8000)
|
||
# Measure EVERY body in the fleet, not just the new five. The bake lights the ped with its
|
||
# own key+hemi+env and the street lights it with the sun, so a billboard/rig ratio of
|
||
# exactly 1.000 was never the contract — what the R2 fix bought is that the ratio is the
|
||
# SAME KIND of number for every ped on the same code path. So the 19 unchanged bodies are
|
||
# the control population, and the question this arm answers is the only one that matters
|
||
# at a swap boundary: do the new five sit inside the spread the town already ships?
|
||
roster = pg.evaluate(ROSTER)['all']
|
||
imp = {}
|
||
strips = []
|
||
for name in roster:
|
||
pg.evaluate(IMPOSTOR_STAGE, {'name': name, 'kind': 'empty'})
|
||
pg.wait_for_timeout(320)
|
||
empty = pg.screenshot(type='png')
|
||
st = pg.evaluate(IMPOSTOR_STAGE, {'name': name, 'kind': 'rig'})
|
||
if not st.get('ok'):
|
||
FAIL(f"{name}: could not stage a rig ({st.get('why')})"); continue
|
||
BOX = tuple(st['box'])
|
||
pg.wait_for_timeout(320)
|
||
rig_png = pg.screenshot(type='png')
|
||
pg.evaluate(IMPOSTOR_STAGE, {'name': name, 'kind': 'billboard'})
|
||
pg.wait_for_timeout(320)
|
||
bill_png = pg.screenshot(type='png')
|
||
lr, nr, rig_img, _ = crop_stats(empty, rig_png, BOX)
|
||
lb, nb, bill_img, _ = crop_stats(empty, bill_png, BOX)
|
||
imp[name] = {'rigLum': round(lr, 2), 'rigPx': nr, 'billLum': round(lb, 2), 'billPx': nb,
|
||
'ratio': round(lb / lr, 4) if lr else None, 'new': name in NEW}
|
||
if name in NEW or name == REF: strips.append((name, rig_img, bill_img))
|
||
note(f"{name:<20}{'NEW ' if name in NEW else ' '} rig {lr:6.2f} ({nr:5d} px) · "
|
||
f"billboard {lb:6.2f} ({nb:5d} px) · ratio {imp[name]['ratio']}")
|
||
pg.evaluate(IMPOSTOR_STAGE, {'name': REF, 'kind': 'crowd'})
|
||
report['impostor'] = imp
|
||
ctrl = sorted(v['ratio'] for k, v in imp.items() if not v['new'] and v['ratio'])
|
||
newr = sorted(imp[n]['ratio'] for n in NEW if imp.get(n) and imp[n]['ratio'])
|
||
if len(ctrl) >= 10 and len(newr) == len(NEW):
|
||
lo, hi, med = ctrl[0], ctrl[-1], ctrl[len(ctrl) // 2]
|
||
nmed = newr[len(newr) // 2]
|
||
note(f'CONTROL POPULATION — the {len(ctrl)} bodies this round did NOT touch span '
|
||
f'{lo:.3f}–{hi:.3f} (median {med:.3f}); the bake is lit by its own key+hemi+env and '
|
||
f'the street by the sun, so a per-albedo spread is the normal state of this seam and '
|
||
f'1.000 was never the contract')
|
||
# (a) no SYSTEMATIC shift — that is what R2's 1.67x actually was: every ped at once
|
||
check(0.85 <= nmed / med <= 1.18,
|
||
f'no systematic shift: the five new bodies\' median ratio {nmed:.3f} vs the '
|
||
f'untouched roster\'s {med:.3f} = {nmed / med:.2f}x (R2\'s break was 1.67x on '
|
||
f'the whole fleet)')
|
||
# (b) and no individual outlier beyond what the town already ships
|
||
for name in NEW:
|
||
v = imp[name]
|
||
check(lo <= v['ratio'] <= hi,
|
||
f"{name}: billboard/rig luminance {v['ratio']:.3f} sits inside the untouched "
|
||
f"roster's own {lo:.3f}–{hi:.3f} — the swap does not widen the near↔mid seam")
|
||
worst = max(imp.items(), key=lambda kv: abs(math.log(kv[1]['ratio'] or 1)))
|
||
note(f"worst-matched body in the whole roster: {worst[0]} at {worst[1]['ratio']:.3f} "
|
||
f"({'NEW' if worst[1]['new'] else 'shipped before this round'})")
|
||
else:
|
||
FAIL('control population too small — arm 6 is vacuous')
|
||
if strips:
|
||
from PIL import Image, ImageDraw
|
||
CW, CH = 240, 268 # per-subject crops differ by a pixel or two
|
||
sheet = Image.new('RGB', (CW * 2, (CH + 16) * len(strips)), (16, 16, 18))
|
||
dr = ImageDraw.Draw(sheet)
|
||
for i, (nm, ri, bi) in enumerate(strips):
|
||
y = i * (CH + 16) + 16
|
||
sheet.paste(ri.resize((CW, CH)), (0, y)); sheet.paste(bi.resize((CW, CH)), (CW, y))
|
||
dr.text((6, y - 13), f"{nm} LEFT near rig | RIGHT mid billboard "
|
||
f"billboard/rig luminance {imp[nm]['ratio']}", fill=(255, 230, 120))
|
||
sheet.save(OUTDIR / 'r42_impostor_boundary.jpg', quality=88)
|
||
note(f"contact strip → {OUTDIR / 'r42_impostor_boundary.jpg'}")
|
||
check(not errs, f'impostor arm: 0 console errors')
|
||
b.close()
|
||
finally:
|
||
srv.terminate()
|
||
|
||
(OUTDIR / 'r42_cast.json').write_text(json.dumps(report, indent=1))
|
||
print()
|
||
if fails:
|
||
print(f"\033[31m● RED\033[0m — {len(fails)} failure(s)")
|
||
for f in fails: print(' ' + f)
|
||
return 1
|
||
print("\033[32m● PASS\033[0m — contract, roster, determinism, draws, animation and the impostor "
|
||
"boundary all green")
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|