From 10025d408241b2e42e867817e5c14d8b8f02b33e Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 18:44:07 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20D=20R29=20(v6=20Spike=201):=20look.glb?= =?UTF-8?q?=20bound=20and=20wired=20=E2=80=94=20peds=20glance,=20feet=20pl?= =?UTF-8?q?anted=20(wave=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spike reaches the game. E's look.glb (mesh-free, 65 joints, 6.5s 'Look') is bound through the fleet gate; street peds now glance around during window-shop stops. `lean` is absent and never existed (E's R28 measurement). Goldens untouched — clips sit outside every plan hash; the synthetic anchor stays R27's amended 0x5f76e76. WIRED: loadPedFleet(base, {sit, look}) -> fleet.lookClip, the exact twin of the R16 sit gate; the shell passes look:!CLASSIC so ?classic fetches NOTHING (a raw fetch would have breached the zero-fetch-delta covenant — E flagged it, the gate is the answer). makeActor gains lookA, played only on setLooking(true) — crossfaded (0.3s), not snapped like sit, since idle->idle-variant reads naturally and there's no replant for a fade to fight. sim.js gains a dedicated `glance` rng stream, rolled ALWAYS (deterministic) but only flipped when a clip exists => classic inert at the source. Mutually exclusive with bench-sit (sit rolls first at 35%, glance takes 40% of the rest). RigPool threads lookClip; release snaps the glance off so no rig is pooled mid-look. THE FINDING — my own prediction was wrong, and the measurement caught it. I wrote that a STANDING clip needs no foot replant ("no hip descent to recover — that trap was sit's alone"). Measured over the full loop: idle keeps the lowest bone within 4cm of the ground (-0.04..0.00); look.glb ranges -0.009..+0.205 — the ped HOVERS up to 20.5cm, drifting through the loop. Same trap, different coat: _rotOnly drops the Hips POSITION track for rig-height independence, so the clip's authored weight-shift dip can't move the hips and comes out as the FEET RISING. Sit's one-time replant can't fix it — the float varies per frame. FIX: plantFeet() — the per-frame twin of sit's replant. Reset to bind plant -> measure posed feet -> drop inner by float/scale. ABSOLUTE (reset->measure->correct), never incremental, so it cannot drift. Reproduces the authored motion exactly: the hips dip relative to the ground instead of the feet leaving it. Scale-aware like sit's. Foot bones cached once; cost paid only by glancing peds, on frames their mixer ran (both mixer sites — the drummer's post-mix lean taught that coupling rule). PROOFS: foot float 0.205m -> 0.000m (pinned); head yaw sweep span 1.735 (-0.85..+0.89) so the glance still animates; ?classic — no look fetch, no Look action on any rig, glance never flips (0); identity signature unchanged, no glance term; sit AND glance never simultaneous (0); 0 NaN; goldens 0x5f76e76 untouched. A glancing ped is NOT tagged seated/posed — standing pose at standing stature, so F's no-giants gate needs no arm. D->F: the pose gate HAS ITS SUBJECT — un-SKIP it. Fresh ?localdepot=1 boot shows a ped glancing on the footpath, head sweeping, feet planted. Spike 2 readiness: plantFeet() is the general machinery the brief asked for ("data, not new code") — riffle and wall-lean should land as clip + roll; the lean's wall-contact placement is the only genuinely new part. FLAGGED (A's file, A rests): selfcheck.js:301 still says "the synthetic golden above stays 0x3fa36874" while line 267 pins the amended 0x5f76e76. A stale comment, not a rule — but the same species as the epoch's five holds, so: flagged, not touched. Co-Authored-By: Claude Opus 4.8 --- docs/LANES/LANE_D_NOTES.md | 64 ++++++++++++++++++++++++++++++++++++++ web/index.html | 2 +- web/js/citizens/rigs.js | 64 +++++++++++++++++++++++++++++++++++--- web/js/citizens/sim.js | 24 ++++++++++++-- 4 files changed, 145 insertions(+), 9 deletions(-) diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index b43fc3c..da91944 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -6,6 +6,70 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra --- +## ROUND 29 — v6 SPIKE 1: `look.glb` bound and wired — peds glance (wave 0) + +*PROCITY-D, 2026-07-17. The spike finally reaches the game: E's `look.glb` (mesh-free, 65 joints, 6.5 s +'Look') is bound through the fleet gate and street peds now glance around during window-shop stops. `lean` +is not here and never was — E measured the stash (R28). Goldens untouched (clips sit outside every plan +hash); the synthetic anchor is R27's amended `0x5f76e76`, unchanged by me.* + +### What shipped +- **`rigs.js` — `loadPedFleet(base, { sit, look })`** → `fleet.lookClip`, the exact twin of the R16 sit gate. + The shell passes `look: !CLASSIC`, so **`?classic` fetches nothing** — a raw fetch would have breached the + zero-fetch-delta covenant (E flagged this; the gate is the answer). +- **`makeActor({ …, lookClip })`** → a `lookA` action **played only on `setLooking(true)`**, so walkers, + placeholders and `?classic` never advance it. `setLooking` **crossfades** (0.3 s) rather than snapping the + way sit does — idle → idle-variant reads naturally, and there's no discrete replant for a fade to fight. +- **`sim.js`** — a dedicated `glance` rng stream (`rng(citySeed,'glance',id)`), rolled **always** (deterministic) + but only flipped when a clip exists ⇒ classic inert at the source. **Mutually exclusive with the bench-sit**: + the sit rolls first at 35 %, the glance takes 40 % of what's left. `RigPool` threads `lookClip`; release + snaps the glance off so no rig is pooled mid-look. + +### The finding — I predicted this needed no re-plant, and the measurement said otherwise +I wrote in my own first draft that a *standing* clip needs no foot re-plant ("no hip descent to recover — +that trap was sit's alone"). **Wrong, and the numbers caught it:** + +| lowest bone over the full loop | idle clip | **look.glb, no plant** | +|---|---|---| +| min → max | −0.04 → 0.00 (span **4 cm**) | −0.009 → **+0.205** (span **21 cm**) | + +The ped **hovers up to 20.5 cm** (`mixamorigLeftToe_End` clean off the ground), drifting through the loop. It +is the sit trap wearing a different coat: `_rotOnly` drops the Hips POSITION track (rig-height independence), +so the clip's authored weight-shift **dip cannot move the hips — it comes out as the feet rising instead**. +Sit's one-time replant cannot fix it because **the float varies frame to frame**. + +**Fix: `plantFeet()` — the per-frame twin of sit's replant.** Reset to the bind plant → measure the posed +feet → drop `inner` by float / scale. **Absolute (reset→measure→correct), never incremental, so it cannot +drift.** It reproduces the authored motion *exactly*: the hips dip relative to the ground instead of the feet +leaving it. Scale-aware like sit's. Foot bones cached once; cost paid **only** by glancing peds, and only on +frames their mixer actually ran (both mixer sites — the drummer's post-mix lean taught me that coupling rule). + +### Proofs +| | before | after | +|---|---|---| +| foot float over the loop | **0.205 m** | **0.000 m** (pinned) | +| head yaw sweep (the glance itself) | — | **span 1.735** (−0.85 → +0.89) | +| `?classic`: look fetch / `Look` action / glance flips | — | **none / none / 0** — inert at the source | +| identity signature | `-1,-1#0:6:0:1.777:1.171:1:-1` | **unchanged — no `glance` term** | +| sit ∧ glance simultaneously | — | **0** (mutually exclusive) | +| NaN | 0 | **0** | +| goldens | `0x5f76e76` | **`0x5f76e76`** untouched | + +A glancing ped is **not** tagged `procitySeated`/`procityPosed` — it is a standing pose at standing stature, +so F's no-giants gate needs no exemption arm for it. + +### Handshakes +- **D → F:** **the pose gate has its subject** — un-SKIP it. A fresh `?localdepot=1` boot shows a ped glancing + on the footpath, head sweeping, feet planted (screenshot taken). Classic/`?noassets` untouched. +- **Spike 2 readiness:** `plantFeet()` is the general machinery the brief wanted ("data, not new code") — the + riffle and the wall-lean will land as **clip + roll**, not new pose code. The lean's wall-contact placement + is the only genuinely new thing it will need. +- **Stale line spotted (A's file, A rests):** `selfcheck.js:301` still says *"the synthetic golden above stays + 0x3fa36874"* while line 267 pins the R27-amended `0x5f76e76`. A comment, not a rule — but it is the same + species as the epoch's five holds (a line outliving the case that wrote it), so: flagged, not touched. + +--- + ## ROUND 28 — v5.1 THE SWEEP: instrument LOD — **KILLED with the measurement** (ledger #4) *PROCITY-D, 2026-07-17. The oldest tri item on the books (v3.1-era). The brief attached the wind-sway rule: diff --git a/web/index.html b/web/index.html index 6599447..6ff7186 100644 --- a/web/index.html +++ b/web/index.html @@ -186,7 +186,7 @@ const minimap = createMinimap(plan); // unless) a GLB lands, so the town is fully playable the instant it boots. // • rig fleet: models/peds/*.glb → shared GLB actors; CitizenSim + keepers upgrade from placeholders. // • manifest: Lane E's assets/manifest.json → interior fitting GLBs (loaded from the 3GOD depot). -const fleet = NOASSETS ? null : loadPedFleet('models/peds/', { sit: !CLASSIC }); // [R16] sit.glb on the default boot (the seated drummer); ?classic keeps the zero-fetch-delta covenant +const fleet = NOASSETS ? null : loadPedFleet('models/peds/', { sit: !CLASSIC, look: !CLASSIC }); // [R16] sit.glb (the seated drummer) + [R29] look.glb (the street glance) on the default boot; ?classic keeps the zero-fetch-delta covenant if (!NOASSETS) preloadManifest(); // prime the manifest so the first interior can upgrade its fittings // [Lane F integration] interior bridge — owns the 'interior' branch (Lane C buildInterior + Lane D keeper) // [Lane F §F2] ?dig=1 enables Lane C's crate-riffle inside record interiors (v2). Off ⇒ byte-identical to v1. diff --git a/web/js/citizens/rigs.js b/web/js/citizens/rigs.js index 02dde59..fdfbdc9 100644 --- a/web/js/citizens/rigs.js +++ b/web/js/citizens/rigs.js @@ -55,10 +55,10 @@ function loadRig(ref) { // sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default // boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls // back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism. -export function loadPedFleet(base = 'models/peds/', { sit = false } = {}) { +export function loadPedFleet(base = 'models/peds/', { sit = false, look = false } = {}) { const fleet = { normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas - walkClip: null, idleClip: null, sitClip: null, + walkClip: null, idleClip: null, sitClip: null, lookClip: null, ready: false, whenReady: null, }; // DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array @@ -82,6 +82,13 @@ export function loadPedFleet(base = 'models/peds/', { sit = false } = {}) { if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only const c = r && r.anims && r.anims[0]; if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } })); + // [R29 Spike 1] E's look.glb (mesh-free, 65 joints, 6.5 s 'Look'). Same gate as sit — the shell passes + // `look: !CLASSIC`, so ?classic fetches nothing and the zero-fetch-delta covenant holds. `_rotOnly` is + // correct here for the same reason as walk/idle: it's a STANDING clip, so there's no hip descent to + // recover and no foot re-plant needed (that trap was sit's alone — R16). + if (look) jobs.push(loadRig(`${base}look.glb`).then(r => { + const c = r && r.anims && r.anims[0]; + if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.lookClip = _rotOnly(c); } })); fleet.whenReady = Promise.all(jobs).then(() => { fleet.normal = nSlots.filter(Boolean); fleet.comical = cSlots.filter(Boolean); @@ -223,7 +230,7 @@ export function seatedLean(spine, angle = 0.34) { // ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim // scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ---- -export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeight = 1.75 } = {}) { +export function makeActor(rig, { walkClip, idleClip, sitClip = null, lookClip = null, nominalHeight = 1.75 } = {}) { if (!rig || !rig.scene) return null; const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight); const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit @@ -233,9 +240,16 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeig // R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where // sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op. const sitA = sitClip ? _action(mixer, inner, sitClip) : null; + // [R29 Spike 1] the glance — same opt-in shape as sitA: PLAYED ONLY when setLooking(true), so walkers, + // placeholders and ?classic (lookClip null) are byte-identical — lookA never advances the mixer. + const lookA = lookClip ? _action(mixer, inner, lookClip) : null; let seatBone = null; if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; }); - let sitting = false; + // [R29] foot bones, cached once for the per-frame plant (see plantFeet). Only built when a posed clip + // exists, so walkers/?classic pay nothing. + let footBones = null; + if (lookA) { const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); }); footBones = fb.length ? fb : null; } + let sitting = false, looking = false; let moving = null; // tri-state so the first setMoving always applies // both actions play; exactly one holds weight 1 at rest, setMoving transfers between them if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); } @@ -279,13 +293,53 @@ export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeig fig.userData.procitySeated = false; } } + // [R29 Spike 1] the standing glance during a window-shop loiter. Deliberately SIMPLER than setSitting: + // it's a standing clip, so there is no posed foot-replant and no scale trap — the feet never leave the + // bind plant. Unlike sit (a snap, which had to avoid a crossfade fighting the discrete replant), this + // crossfades: idle → idle-variant reads naturally, and the sim only calls it while the ped is stopped. + // No-op when lookClip is absent (?classic / ?noassets) ⇒ byte-identical by construction. + function setLooking(l, fade = 0.3) { + if (!lookA || l === looking) return; + looking = l; + moving = null; // force the next setMoving to re-apply on the way out + if (l) { + lookA.reset().play(); + if (idleA) idleA.crossFadeTo(lookA, fade, false); + else lookA.setEffectiveWeight(1); + if (walkA) walkA.setEffectiveWeight(0); + } else { + if (idleA) { idleA.reset().play(); lookA.crossFadeTo(idleA, fade, false); } + else lookA.setEffectiveWeight(0); + inner.position.y = bindPlantY; // drop the plant correction — back to the standing bind plant + } + } + // [R29] MEASURED, and it corrected my own assumption: I expected a standing clip to need no re-plant + // ("no hip descent to recover"). It does. `_rotOnly` drops the Hips POSITION track to stay rig-height + // independent, so look.glb's authored weight-shift dip cannot move the hips — it comes out as the FEET + // rising instead: lowest bone ranges −0.009…+0.205 m over the loop vs idle's −0.04…0.00. A 20 cm hover. + // Sit's one-time re-plant can't fix it because the float VARIES per frame, so this is the per-frame twin: + // reset to the bind plant, measure the posed feet, drop `inner` by the float / scale. Absolute (reset → + // measure → correct), never incremental, so it cannot drift. The result reproduces the authored motion + // exactly — the hips dip relative to the ground instead of the feet leaving it. Scale-aware like sit's. + // Cost is paid ONLY by glancing peds (a handful), and only on frames their mixer actually ran. + function plantFeet() { + if (!footBones || !looking) return; + inner.position.y = bindPlantY; // reference pose + inner.updateWorldMatrix(true, true); + let w = Infinity; + for (const b of footBones) { b.getWorldPosition(_wp); if (_wp.y < w) w = _wp.y; } + if (!isFinite(w)) return; + const S = fig.scale.y || 1; + inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath + } // seed the clip phase so the crowd isn't in lockstep function setPhase(p) { if (walkA) walkA.time = p * (walkA.getClip().duration || 1); if (idleA) idleA.time = p * (idleA.getClip().duration || 1); + if (lookA) lookA.time = p * (lookA.getClip().duration || 1); // R29: glances desync too } function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); } - return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setPhase, dispose, + return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setLooking, plantFeet, setPhase, dispose, hasClips: !!(walkA && idleA) }; } diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index 9c1f398..6b9eccf 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -35,6 +35,7 @@ const PATRON_STRIDE = 10; // m walked between patronage check const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day) const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded) +const GLANCE_FRAC = 0.40; // R29: fraction of the REMAINING (standing) loiters that glance around // time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush, // near-empty at night). Sampled at 8 control points, linearly interpolated. @@ -93,6 +94,7 @@ class RigPool { release(actor) { if (!actor) return; if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant + if (actor.setLooking) actor.setLooking(false, 0); // R29: nor mid-glance — snap back to idle before pooling let arr = this.free.get(actor.pedIndex); if (!arr) this.free.set(actor.pedIndex, arr = []); arr.push(actor); @@ -258,6 +260,7 @@ export class CitizenSim { turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id), patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id), sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron + glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched }; if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } } this._placeOnLane(c); @@ -307,6 +310,7 @@ export class CitizenSim { turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id), sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron + glance: false, glanceRng: rng(this.citySeed, 'glance', id), // R29: ditto — independently keyed, signature untouched }; // assign a real ped type if the fleet is already up (roster can grow after upgrade) if (this.mode === 'rig' && this.fleet.ready) { @@ -522,7 +526,7 @@ export class CitizenSim { this._setImpostorLayer(atlas); this.mode = 'rig'; - this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip }); // sitClip null under ?classic → bench-sit inert + this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip, lookClip: this.fleet.lookClip }); // sit/lookClip null under ?classic → bench-sit + glance inert // assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs const assign = c => { const pk = pickRig(this.fleet, c.pedRoll); @@ -589,6 +593,12 @@ export class CitizenSim { // so ?classic (no sit.glb) is inert at the source, not just at the render. const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false; c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip); + // [R29 Spike 1] a standing glance — the other way to spend a window-shop stop. Same discipline as + // the sit roll: draw ALWAYS from a dedicated stream (deterministic; never shifts turn/loit/patron), + // but only flip the flag when a clip exists ⇒ ?classic inert at the source. Mutually exclusive with + // the sit: you can't bench-sit and stand glancing, so the sit wins and the glance takes the rest. + const wantsGlance = c.glanceRng ? (c.glanceRng() < GLANCE_FRAC) : false; + c.glance = !c.sit && wantsGlance && !!(this.fleet && this.fleet.lookClip); } } this._placeOnLane(c); @@ -732,7 +742,11 @@ export class CitizenSim { // so every non-sitting ped is byte-identical. const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting); if (a.setSitting) a.setSitting(wantSit); - if (!wantSit) a.setMoving?.(c.loiter <= 0); + // [R29 Spike 1] the glance rides the same seam: a stopped ped either sits, glances, or plain idles. + // No-op for walkers, placeholders and ?classic (no lookClip) ⇒ every non-glancing ped byte-identical. + const wantLook = !wantSit && !!(c.glance && c.loiter > 0 && a.setLooking); + if (a.setLooking) a.setLooking(wantLook); + if (!wantSit && !wantLook) a.setMoving?.(c.loiter <= 0); } else if (want === 'mid') { mid.push(c); } @@ -747,7 +761,9 @@ export class CitizenSim { const c = near[i], a = c.actor; c._acc += dt; if (a.mixer) { - if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; } else extra.push(c); + // [R29] plantFeet must follow the mixer that posed the feet — same coupling rule the drummer's + // post-mix lean taught us. Only glancing peds pay it; it self-guards on `looking`. + if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; if (c.glance) a.plantFeet?.(); } else extra.push(c); } else { a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder } @@ -758,6 +774,8 @@ export class CitizenSim { for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) { const c = extra[(this._mixerCursor + k) % extra.length]; c.actor.mixer.update(c._acc); c._acc = 0; + if (c.glance) c.actor.plantFeet?.(); // R29: the round-robin tier plants on the frames its mixer ran + } this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length; }