diff --git a/tools/flags_check.py b/tools/flags_check.py index 884203e..1f0bec7 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -1496,6 +1496,176 @@ def smoke_glance(p): b.close() +def smoke_djdance(p): + """R36 wave 2 — the 2 a.m. dance clips (046668e) through the gate, per E's wave-1 flags. + + Four Rokoko free-mocap clips (dance_party/medium/drink/sway — Rokoko's own Mixamo-skeleton + export, NOT Mixamo; new licence zone, LANE_E_NOTES R36) joined `loadPedFleet({dance:!CLASSIC})` + outside the round system with only "page boots" verified. This smoke proves the commit's claims: + + · the clips BIND (E flag (b)): gate on binds + NaN, never node counts — the Rokoko shape is + legitimately 68-node/52-joint, not the sit/look 66/65 template. Runtime census: post + _canon/_rotOnly each clip must bind all tracks except Rokoko's root `mixamorigReference` + (64/65 — matches E's node-side census and 90sDJsim's in-game measurement). + · the clips REACH THE GAME: a gig-night dancer's live action IS a dance clip (identity by + duration — the four durations 30.5–52.4 s are unambiguous vs the ~s idle), the mixer is + provably advancing, and fig.position.y stays FLAT (the procedural fallback is not running). + · `?classic=1` fetches ZERO dance clips (the zero-fetch-delta covenant; positive control = + the default boot fetched all 4, so the detector discriminates). + · `?noassets=1` falls back to PROCEDURAL sway: zero GLB fetches, dancers exist as + placeholders, and fig.position.y measurably bounces (>2 cm) — the exact inverse of the + clip-driven amplitude, so the pair proves which branch moved the body. + + [R36 HOLD — not this gate's to fix] the 5 trellis DJ BODIES (b339402) fetch under ?classic + too: they are unconditional PED_NAMES.normal roster members (rigs.js:22, Lane D's file), which + also widened classic's seeded crowd pool 17→22 on July 20. Suite stayed green (plan fp is + plan-side; draws in tolerance) but the classic fetch surface + crowd identity moved without a + ruling. WARNED here so the record is in the machine, held for Fable per the five-holds law.""" + head('SMOKE: dance clips (R36 — Rokoko binds · dancer driven · classic zero-fetch · noassets sway)') + if not os.path.exists(os.path.join(str(ROOT), 'web', 'models', 'peds', 'dance_party.glb')): + print('· SKIP: dance_party.glb not on disk — subject absent, nothing asserted (Lane E)'); return + DANCE_FILES = ['dance_party.glb', 'dance_medium.glb', 'dance_drink.glb', 'dance_sway.glb'] + DJ_FILES = ['woman_dj_01.glb', 'woman_dj_02.glb', 'woman_dj_03.glb', 'dj_techno_01.glb', 'dj_phrtt_01.glb'] + + # 1+2) default boot: binds + the driven dancer (with fetch capture as the positive control) + b, pg, errs = new_page(p) + reqs = [] + pg.on('request', lambda r: reqs.append(r.url)) + try: + boot(pg, '') + r = pg.evaluate("""async () => { + const P = window.PROCITY, D = window.DBG, fleet = P.fleet; + const clips = fleet.danceClips || []; + // runtime bind census vs a real rig's node set (post _canon/_rotOnly, exactly as bound) + const nodes = new Set(); fleet.all[0].scene.traverse(o => { if (o.name) nodes.add(o.name); }); + const census = clips.map(c => { + const un = [...new Set(c.tracks.filter(t => !nodes.has(t.name.split('.')[0])) + .map(t => t.name.split('.')[0]))]; + return { dur: +c.duration.toFixed(2), total: c.tracks.length, + bind: c.tracks.length - c.tracks.filter(t => !nodes.has(t.name.split('.')[0])).length, + unbound: un }; + }); + // gig night: the dancer's live action + D.setSegment(5); + D.enterShop(P.gigs.venueShopIds[0]); + await new Promise(res => setTimeout(res, 600)); + const crew = P.interiorMode.crew; + const dancers = crew ? crew.members.filter(m => m.part === 'crowd' && m.dance) : []; + const durs = new Set(clips.map(c => +c.duration.toFixed(2))); + const before = dancers.map(m => { + const acts = m.actor.mixer ? m.actor.mixer._actions : []; + return { kind: m.kind, clipDur: acts.length ? +acts[0]._clip.duration.toFixed(2) : null, + t: acts.length ? acts[0].time : null, y: m.actor.fig.position.y }; + }); + let minY = dancers.map(m => m.actor.fig.position.y), maxY = minY.slice(); + for (let k = 0; k < 14; k++) { + await new Promise(res => setTimeout(res, 50)); + dancers.forEach((m, i) => { const y = m.actor.fig.position.y; + minY[i] = Math.min(minY[i], y); maxY[i] = Math.max(maxY[i], y); }); + } + const after = dancers.map(m => { + const acts = m.actor.mixer ? m.actor.mixer._actions : []; + return acts.length ? acts[0].time : null; + }); + D.exitShop(); + return { nClips: clips.length, census, + dancers: before.map((d, i) => ({ ...d, tAfter: after[i], + isDance: d.clipDur != null && durs.has(d.clipDur), + amp: +(maxY[i] - minY[i]).toFixed(4) })) }; + }""") + if r['nClips'] == 4: OK('default boot: 4 dance clips loaded and canonicalised (party/medium/drink/sway)') + else: FAIL(f"default boot: expected 4 dance clips, fleet has {r['nClips']}") + badc = [c for c in r['census'] if c['bind'] != c['total'] - 1 or c['unbound'] != ['mixamorigReference']] + if not badc: + OK(f"Rokoko binds: every clip binds {r['census'][0]['bind']}/{r['census'][0]['total']} tracks, " + f"sole unbound = mixamorigReference (the Rokoko root — E's census, reproduced at runtime)") + else: + FAIL(f"dance clip bind census off: {badc[:2]}") + dn = r['dancers'] + if not dn: + FAIL('gig night: no dancers in the crowd — the subject is absent') + else: + not_dance = [d for d in dn if not d['isDance'] or d['kind'] != 'rig'] + stalled = [d for d in dn if d['tAfter'] is not None and d['t'] is not None and d['tAfter'] <= d['t']] + bouncing = [d for d in dn if d['amp'] > 0.02] + if not_dance: FAIL(f"gig night: dancer NOT driven by a dance clip: {not_dance[:2]}") + elif stalled: FAIL(f"gig night: dancer mixer not advancing (page stalled?) — amplitude read is vacuous: {stalled[:2]}") + elif bouncing: FAIL(f"gig night: dancer position.y bouncing {bouncing[0]['amp']}m — the PROCEDURAL fallback is running despite loaded clips") + else: + OK(f"gig night: {len(dn)} dancer(s) driven by real dance clips (durations {sorted(set(d['clipDur'] for d in dn))}s, " + f"mixers advancing, position.y flat — the clip moves the body, not the fallback)") + dance_fetches = [u for u in reqs if any(f in u for f in DANCE_FILES)] + if len(dance_fetches) == 4: OK('positive control: the default boot fetched all 4 dance clips (the detector can see the fetch)') + else: WARN(f"positive control: expected 4 dance-clip fetches on the default boot, saw {len(dance_fetches)}") + if errs: FAIL(f"dance/default: {len(errs)} console error(s); first: {errs[0][:120]}") + else: print(' · 0 console errors (default boot)') + finally: + b.close() + + # 3) ?classic=1 — zero dance-clip fetches; the DJ-body roster fetch is WARNED (held, see docstring) + b, pg, errs = new_page(p) + reqs = [] + pg.on('request', lambda r: reqs.append(r.url)) + try: + boot(pg, 'classic=1') + pg.wait_for_timeout(1500) + dance_fetches = [u.split('/')[-1] for u in reqs if any(f in u for f in DANCE_FILES)] + dj_fetches = sorted(u.split('/')[-1] for u in reqs if any(f in u for f in DJ_FILES)) + if not dance_fetches: OK('?classic=1: ZERO dance-clip fetches — the zero-fetch-delta covenant holds for the clips') + else: FAIL(f"?classic=1 FETCHED dance clips: {dance_fetches}") + if dj_fetches: + WARN(f"?classic=1 fetches the 5 trellis DJ BODIES ({len(dj_fetches)}) — they are unconditional roster " + f"members (rigs.js PED_NAMES, b339402), which also widened classic's crowd pool 17→22. " + f"HELD for Fable: bless the roster change or direct D to gate them out of ?classic") + else: + OK('?classic=1: zero DJ-body fetches') + if errs: FAIL(f"dance/classic: {len(errs)} console error(s); first: {errs[0][:120]}") + finally: + b.close() + + # 4) ?noassets=1 — the procedural sway fallback, alive and measured + b, pg, errs = new_page(p) + reqs = [] + pg.on('request', lambda r: reqs.append(r.url)) + try: + boot(pg, 'noassets=1') + r = pg.evaluate("""async () => { + const P = window.PROCITY, D = window.DBG; + D.setSegment(5); + D.enterShop(P.gigs.venueShopIds[0]); + await new Promise(res => setTimeout(res, 600)); + const crew = P.interiorMode.crew; + const dancers = crew ? crew.members.filter(m => m.part === 'crowd' && m.dance) : []; + let minY = dancers.map(m => m.actor.fig.position.y), maxY = minY.slice(); + for (let k = 0; k < 14; k++) { + await new Promise(res => setTimeout(res, 50)); + dancers.forEach((m, i) => { const y = m.actor.fig.position.y; + minY[i] = Math.min(minY[i], y); maxY[i] = Math.max(maxY[i], y); }); + } + D.exitShop(); + return { fleet: !!P.fleet, dancers: dancers.map((m, i) => ({ kind: m.kind, + amp: +(maxY[i] - minY[i]).toFixed(4) })) }; + }""") + glb_fetches = [u for u in reqs if '.glb' in u] + if not glb_fetches: OK('?noassets=1: zero GLB fetches (dance clips included)') + else: FAIL(f"?noassets=1: {len(glb_fetches)} GLB fetch(es) leaked; first {glb_fetches[0]}") + dn = r['dancers'] + swaying = [d for d in dn if d['amp'] > 0.02] + if not dn: + FAIL('?noassets=1: no dancers — fallback subject absent') + elif r['fleet']: + FAIL('?noassets=1: fleet exists — the boot is not asset-free') + elif len(swaying) == len(dn): + OK(f"?noassets=1: procedural sway ALIVE — {len(dn)} placeholder dancer(s) bouncing " + f"(amp {min(d['amp'] for d in dn)}–{max(d['amp'] for d in dn)}m vs the clip-driven 0.00 — the inverse pair proves the branch)") + else: + FAIL(f"?noassets=1: dancer(s) NOT swaying procedurally: {dn}") + if errs: FAIL(f"dance/noassets: {len(errs)} console error(s); first: {errs[0][:120]}") + else: print(' · 0 console errors (?noassets)') + finally: + b.close() + + def smoke_continuity(p): """R15 identity continuity (ledger #3): the ped who queued/surged into a venue turns up in its crowd. Exercises F's real relay end-to-end — VenueQueue.admitOne() → onAdmit → citizens.recordVenueEntry → @@ -2595,6 +2765,7 @@ def main(): smoke_gigs(p) # R12/13 — the district gig layer (schedule · band · crowd · budget · cover · noassets) smoke_continuity(p) # R15 — identity continuity (the ped you followed is in the crowd · settled tris) smoke_glance(p) # R29 — Spike 1: v6's first clip in the game (classic zero-fetch) + smoke_djdance(p) # R36 — the 2 a.m. dance clips: Rokoko binds · dancer driven · classic zero-fetch · noassets sway smoke_sell_routing(p) # R30 — the E-key sell routing (§9.4, the held join, live loop) smoke_save_determinism(p) # R30 gate a — export → fresh boot → import byte-equal, world untouched smoke_nopump(p) # R30 gate b — 5 real round trips monotonic loss + C's negative control diff --git a/tools/qa/interior_scale_check.py b/tools/qa/interior_scale_check.py index ee3f754..3ee7dc0 100644 --- a/tools/qa/interior_scale_check.py +++ b/tools/qa/interior_scale_check.py @@ -37,6 +37,13 @@ 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.95–0.99 UNITS tall vs the legacy roster's ~173–180 (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 17–21. 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. +DJ_BODIES = ['woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01'] fails = [] def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}") @@ -137,6 +144,57 @@ def main(): 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.95–0.99 units vs legacy ~173–180) + 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"DJ bodies (R36, {dj['shop']}): all 5 spawned as rigs, human-sized — rigs.js span-normalise absorbs the ~1-unit bind [{det}]") b.close() finally: if srv: srv.terminate()