# LANE D — NOTES (measured budgets + clip wishlist) *Written by PROCITY-D, 2026-07-14. Standalone verification via `web/citizens_test.html`. Reference stack ported from `90sDJsim/web/world/index.html` ~405–520 (loadRig/spawnRig/_canon/ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.* --- ## ROUND 17 — v3.2 THE TAIL: the drummer's pelvic-lean (ledger #1) *PROCITY-D, 2026-07-16. The R16 honest limit — the upright bent-leg sit read stiff — is fixed. Files `rigs.js`, `band.js`. Byte-identical proof holds (my signature discipline).* The sit clip can't carry a pelvic tilt (`Hips.quaternion` is unusable on these rigs — R16), so the R16 drummer sat bolt-upright. **Fix: a small constant forward torso lean applied POST-MIX.** `rigs.js` `spawnRig({seated})` now caches the mid-spine bone (`seatBone`), and `seatedLean(spine)` rotates it forward ~0.34 rad each frame AFTER the mixer resets it (so it's a constant offset, never an accumulation). `band.js` calls it for `m.seated` members only. The drummer now leans into the kit — verified **head 0.28 m forward of the hips**, reads as a real drummer from the side (the R16 stiffness is gone). **Byte-identical proof**: standing rigs get **no `seatBone`** (found only in `spawnRig`'s seated block) and `seatedLean` is only called for seated figs. Verified with the lean live: streamed walkers **188/188** face travel, keeper is a standing rig (stature **1.718**, `hasSeatBone:false`, `innerRotY:π`). No existing consumer moves. Seated-only, parallel path — same discipline as R16. --- ## ROUND 17 — v3.2 THE TAIL: bench-sit loiter (ledger #2, the carried R16 stretch) *PROCITY-D, 2026-07-16. The loiter hook that's waited since v1: street peds occasionally sit during a window-shop stop. Files `rigs.js` (`makeActor` gains an opt-in `setSitting`), `sim.js` (the seeded choice + the near-tier toggle). Byte-identical for every non-sitting ped — the load-bearing proof.* ### The finding I had to solve first The R16 seated re-plant lives in `spawnRig` (drummer path) and is **scale-naive** — it drops `inner` by the posed lowest-bone height, which only lands at y=0 for **unscaled** rigs. But the street crowd runs on `makeActor` rigs that the sim scales **per-citizen** (`fig.scale = height/1.75`). So bench-sit is *not* the one-line "reuse the existing seam" the brief hoped for — the re-plant had to become **scale-aware**. ### What shipped - **`makeActor(rig, {…, sitClip})`** gains a third action `sitA`, **played only on `setSitting(true)`** — so walkers, placeholders, and `?classic` (no `sitClip` → `sitA` null → `setSitting` no-ops) are byte-identical: the sit action never advances the mixer and the plant never moves. - **`setSitting(s)`** snaps in/out of the sit (no crossfade — a background ped popping to a sit is fine) and does the **scale-aware re-plant**: after baking the pose it measures the posed lowest bone's *world* y and drops `inner` by `(worldLowestY − figY) / fig.scale.y`, landing the posed soles on the footpath at any citizen scale. Standing up restores the cached `bindPlantY` exactly. Tags `procitySeated`. - **`sim.js`**: a **dedicated** rng stream `sitRng = rng(citySeed,'benchsit',id)` (independently keyed → does **not** shift `turn`/`loit`/`patron`, so the walk/loiter pattern and the identity signature are unchanged). At the window-shop loiter trigger the roll fires ALWAYS (deterministic) but only flips `c.sit` when a sit clip is loaded (`?classic` inert at the source). The near-tier update toggles `setSitting(c.sit && loiter>0)`. `RigPool.release` forces `setSitting(false)` so a pooled rig is never handed out mid-sit. - No lean: bench-sitters sit **upright** (natural on a verandah step), so the drummer's `seatedLean` is not applied here — only `setSitting`'s pose + re-plant. No bench-position binding — they sit where they loitered (the footpath/verandah edge under the awnings). ### Byte-identical proof (in-shell, default boot, seed 20261990) Forced a near rig through stand → sit → stand, bone-measured (posed skeleton; `Box3` is bind-pose for skinned meshes and lies here): | metric | standing | seated | +1 s sit-idle | stood back | |---|---|---|---|---| | head bone y | 1.466 | **1.070** | 1.071 | 1.466 | | bone span | 1.667 | **1.299** (∈ seated gate [0.9,1.5]) | 1.285 | 1.667 | | lowest bone y (feet) | 0.027 | **0.000** (planted) | 0.005 (holds) | 0.027 | | `inner.position.y` | 0.929 | 0.548 | 0.548 | **0.929** (restored) | | `procitySeated` | false | **true** | true | false | Head drops 40 cm, feet re-plant to the footpath at citizen scale (0.978), the one-time plant **holds** through the sit-idle, and standing up restores the rig **byte-for-byte**. `innerRotY:π` throughout (facing convention intact). Draws **35 · tris 6k** at the seated block (seated peds are already-near rigs — ~0 draw delta). - **Seeded path fires**: 6–9 distinct peds sat naturally per ~100 s soak (of ~10 window-shop loiters). - **Determinism unchanged**: identity signature `-4,2#0:5:1:1.711:1.370:0:-1` — no `sit` term; plan golden `0x3fa36874`, 15277/15277. - **Classic covenant airtight at the source**: `?classic=1` → `fleetSitClip:false`, 31 loiters over the soak but `everSat:0` (the gate never flips `c.sit`), zero fetch delta, `anySeatedNow:0`. Both R17 ledger items (#1 pelvic-lean, #2 bench-sit) are delivered — the tail is drained. --- ## ROUND 16 — v3.1 THE FLIP: the drummer finally sits + backline unification *PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's `backline[]`). Files `rigs.js`, `band.js`. Verified against C's landed `stage.backline[]` + E's `sit.glb`; F's flip/gate pending (I built to F's published seated-stature contract). **Byte-identical proof holds** (the load-bearing requirement).* ### The drummer sits (ledger #2) — and Fable's prescribed `_rotWithHips` is WRONG for these rigs The R14 recon + F's brief prescribed a `_rotWithHips` variant (keep `Hips.quaternion` to preserve the pelvic tilt). **In-shell it MANGLES the pose** — head folds to hip height, body laid flat — because the fleet rigs' bind-pose Hips orientation differs from the Mixamo clip's, exactly the failure `_rotOnly`'s own comment documents ("a different-orientation source lays it flat"). So the sit clip **drops `Hips.quaternion` like every other clip** (rides the existing `_rotOnly`); the seated read comes from the leg-bone bends + a new **seated foot-replant** in `spawnRig`. `_rotWithHips` is removed. - **`spawnRig({seated})`** (OPT-IN): after the clip plays, tick the mixer once and re-plant the POSED lowest bone to y=0 so the drummer's feet sit on the deck (recovers the hip descent). Standing spawns skip it entirely. - **The drummer**: uses `fleet.sitClip` when present (retiring the R14 `SEAT_DROP` sink — it sits on the riser and the re-plant seats it), tags `fig.userData.procitySeated = true` (F's gate). Reads seated from the **crowd POV** (the kit hides the lower body); measured stature **~1.1–1.3 m** (F's seated gate [0.9,1.5] ✓). Falls back to the `SEAT_DROP` standing-sunk hack if `sitClip` is absent (?noassets, or before F wires `{sit}`), so it never breaks a boot. - **Honest limit**: without the pelvic tilt (Hips.quaternion unusable), it's an upright bent-leg sit — a bit stiff from a hard side/overhead angle, hidden by the kit from the front. A cleaner pose is a manual pelvic-lean pass (v3.2). It's a genuine improvement over the standing-sunk hack (real bent legs, feet on the deck, passes the seated gate) — good enough for the flip. ### Byte-identical proof (keepers / browsers / walkers) — the load-bearing part The changes are a PARALLEL/opt-in path: `_rotOnly` untouched (walk/idle unchanged), `spawnRig`'s re-plant is `seated`-gated (default off), `fleet.sitClip` is additive, `makeActor` (walkers) untouched. Verified in-shell with `fleet.sitClip` **null AND loaded**: streamed walkers **188/188** face travel, keeper is a standing rig (stature **1.718**, `innerRotY:π`, `replanted:false` — the seated re-plant does NOT run). No existing consumer moves. ### → Lane F: the `{sit}` load seam (classic-safe by construction) `loadPedFleet(base, { sit })` — pass **`sit: `** (true on the default boot, **false under `?classic=1`**). Gated because a raw `sit.glb` fetch would break the classic zero-fetch-delta covenant; default is `sit:false`, and absent/off → `fleet.sitClip` null → the drummer falls back gracefully. My code is committed graceful, so it lands safely before F's commit; the drummer only sits once F passes `sit:true` (same commit as F's [0.9,1.5] seated-stature gate). ### Amp at C's `stage.backline[]` (ledger #5) `_addBackline` now plants at **C's `stage.backline[0]`** (`{x,z,ry,y}`, up-stage, clear of every bandPose + watchPoint) instead of D's R15 hardcoded pose — C confirmed `backline[0]` == that pose, so nothing moves on screen (pub 1.44, 0.32, −3.27, ry=π; **0.75 m bass clearance**). Since C dropped its primitive ampStacks, D now owns the whole backline: **placeholder-persists** (a primitive amp shows immediately, the `guitar_amp` GLB swaps in), and the primitive is the **`?noassets` fallback** (fleet-gated, no fetch). Verified: GLB swaps in, primitive under `?noassets`, no crash, 0 fetch. --- ## ROUND 15 — v3.0 close: the amp one-liner (ledger #4) *PROCITY-D, 2026-07-16. Sole task: move `band.js _addBackline` to C's §3 pose. Everything else (continuity, ratified sit-clip deferral) is closed.* The R13 amp sat at `z = stage.z + 0.15` (down-stage) — 0.29 m from the bass rig, interpenetrating the bassist (C's audit caught it). Moved up-stage-right to C's blessed pose **`(stage.x + w·0.36, deckY, stage.z − d·0.22)`** = (1.44, 0.32, −3.27) at the pub: **0.75 m clear** of the bass, behind the front line, flanking C's primitive ampStacks. Verified: draw/tri delta **0** (same GLB moved — pub interior 50 draws · ~183k tris), `?noassets` unaffected (amp is fleet-gated dressing → `props 0`, band placeholders, no crash). --- ## ROUND 14 — v3.0 release: identity continuity (the ped you followed is in the crowd) *PROCITY-D, 2026-07-16. The last charter promise. Behind `?gigs=1`; files `sim.js`, `band.js`, `queue.js`. **No golden moves** — both goldens are plan-gen hashes (independent of runtime crowd identity), and an empty roster is byte-identical to R13. Verified in-shell against A's 3-venue plan.* ### The tonight roster (sim.js) — who came to the gig A per-venue **Map** (`_venueRoster`) of the identities who entered a gig venue **tonight**, from two sources: - **Surge occupants** — a streamed ped that patronage steers into a gig venue self-records in `_enter` (gated `this._gigVenues.has(shopId)`), keyed by the ped's stable `c.id` so re-entry doesn't dup. Unlike R9 occupancy (which clears on emerge), the roster **persists past the ped's dwell** — "who came tonight". - **Queue admits** — F relays each `VenueQueue.admitOne()` via `citizens.recordVenueEntry(venueShopId, {pedIndex,height,key})`. It's **bounded + per-night**: cleared automatically on the `setGig(id, false)` on→off transition (the gig ends → the crowd disperses), so it survives interior exit/re-enter (gig stays on) but resets each night. Pure bookkeeping — **no rng draw, no GPU** (draw/tri delta 0). ### The crowd consumes it (band.js) — the front slots become the roster `GigCrew.spawn(roomGroup, { …, roster })`: crowd slot `i` fills from `roster[i]` (its `{pedIndex,height}`), seeded strangers fill the remainder. This is the release-round's load-bearing safety: each crowd slot is an **independently-keyed rng stream** (`gig`/`crowd::`), and `_make`'s existing override branches already skip a slot's own height+pickRig draws when identity is supplied — so a roster override touches **only that slot**, never another slot / the sway-phase stream / the band / either golden. An **empty roster is byte-identical to R13** (verified). Roster heights are the sim's 1.55–1.95 m, inside the no-giants [1.4,2.0] gate. `crew.spawn` returns `{ band, crowd, fromRoster }` and each member carries `member.pedIndex` (for F's continuity smoke — assert on the seam, not pixels). ### → Lane F: the seam (three touches; the clear is automatic) ```js // 1. interior — pass the venue's roster into the crowd (interior_mode.js:239): crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints, gig: tonight, roster: citizens.tonightRoster(shop.id) }); // 2. street — relay each queue admit into the roster (index.html per-venue loop ~380-393): q.spawn(scene, { queueZone, gigId, onAdmit: (id) => citizens.recordVenueEntry(venueShopId, id) }); // 3. nothing — the roster auto-clears on your existing setGig(id, false) at the gig's end. ``` Accessor for your smoke: `window.PROCITY.citizens.tonightRoster(venueShopId) → [{pedIndex, height}]` (insertion-ordered, deduped, bounded). Assert **crowd ⊇ roster ∩ cap** via `crew.crewInfo.fromRoster` + `member.pedIndex ∈ roster`. `admitOne()` now returns `{pedIndex,height,key}` (was `1`) — truthy, so your existing `if (q.admitOne())` still holds. Also: `crew.spawn` is now null-`gig`-safe (quiet-night guard). ### Verified in-shell (seed 20261990, The Exchange Hotel #116) | check | result | |---|---| | roster API — record / dedup-by-key / invalid-pedIndex-ignore / per-venue / clear-on-gig-off | all ✓ | | **surge continuity** — real peds ducked in → roster → crowd | **8/8** (same pedIndex + height) | | **queue continuity** — admits relayed via `onAdmit` → roster | **4/4** | | consume — front slots = roster, tail = seeded strangers unchanged | ✓ | | **determinism** — empty-roster crowd == R13 baseline (pedIndex + stature per slot) | byte-identical ✓ | | **soak** — 7 nights × 3 venues, enter/exit + re-spawn | leak **geo Δ0 / tex Δ0**, roster **bounded** (≤ cap, cleared per night), **no identity churn** | | budget | interior gig **64 draws ≤350**, **0 new rigs** (continuity is bookkeeping) | | `?noassets=1&gigs=1` | crowd = placeholders using roster heights, **no crash, 0 fetch** | ### Debt #2 (the drummer sit clip) — INVESTIGATED, deferred to v3.1 (real blockers, not a punt) The clip **does exist** — `~/Documents/mixamo-fetch/out/` is NOT on m3ultra (Fable's path is stale; the bank lives on the tailnet source box `johnking@100.91.239.7`). `Sitting_Idle.fbx` there converts cleanly via Blender to a 394 KB **mesh-free `sit.glb`** that binds (65/65 bones; the fleet's `_canon` keeps the colon, `mixamorig:Hips`, so raw Mixamo naming is already canonical). **But wiring it is a v3.1 change, not a release-week one, for two real reasons:** 1. **`_rotOnly` breaks a seated pose** — it drops `Hips.quaternion` (pelvic tilt) + all positions (hip descent), giving a "levitating L-sit". Correct wiring needs a parallel `_rotWithHips` filter + a post-pose foot re-plant in `spawnRig` — a change to the **shared** rig stack (keepers/browsers/band/crowd). 2. **A genuinely seated figure is ~1.2 m crown-above-feet**, which **fails F's no-giants stature floor [1.4,2.0]** (`flags_check.py:695`) — it needs an F smoke exemption for the seated band member. Both land a qa-red change pending an F handshake, in the freeze round, for a polish item — the drummer already reads seated from the crowd POV (money shot) via the `SEAT_DROP` hack. **Rides Fable's existing v3.1 item "bench-sit loiter from the same `sit` clip".** Recipe (for v3.1): scp Sitting_Idle.fbx → Blender `--background --python` import/export GLB (no bone rename needed) → `web/models/peds/sit.glb` → `_rotWithHips` loader + `spawnRig` re-plant + F stature exemption. See LANE_D_NOTES wishlist #1. --- ## ROUND 13 — v3.0-beta the district: the RY fix, instruments/drummer, the queue, multi-venue surge *PROCITY-D, 2026-07-15. Behind `?gigs=1`; verified in-shell against A's landed 3-venue plan (pub #116 `pubrock` / rsl #404 `covers` / band_room #490 `grunge`, 14 gigs / 14 posters) and C's landed 4-pose stage (guitar/vocal/bass + a seated `drums` on the riser). Files: `rigs.js`, `band.js`, new `queue.js`, `sim.js`. `keepers.js` / `placeholder.js` / `impostor.js` UNCHANGED — the RY fix at the source corrects them for free.* ### Debt #4 — the RY audit: fixed once at the source (⚠ this changes the flags-off street VISUAL) **Finding (confirmed in-scene):** the imported ped GLBs' visual front is local **+Z**, but *everything downstream assumes −Z* — `sim.js` heading math (`facing = atan2(-dx,-dz)`), C's pose `ry` (counter.stand / bandPoses / watchPoints / browse points), the placeholder (`placeholder.js:61` builds toes to −Z *on purpose*, "like the GLB rigs"), and the impostor bake/pick convention. So the GLB was the lone outlier: **keepers faced the back wall, browsers faced away, and every streamed rig walker moon-walked (faced anti-travel)** — all subtly, for ~12 rounds, because near+mid were consistently-wrong-together (seamless) and peds are mostly seen from behind. R12's band carried a local `RY_FLIP=π` that patched only the gig. **Fix:** one line in `rigs.js buildFigure` — `inner.rotation.y = Math.PI` — normalises every rig's front to −Z, matching the rest of the system. Then `RY_FLIP` is deleted from `band.js` (band/crowd use C's `ry` verbatim). Because the impostor atlas bakes this same `buildFigure`/`spawnRig` output and the layer's yaw-pick already uses the −Z convention, near↔mid stays seamless (both flip together). It's **purely visual** — a Y-rotation leaves every bone's world-Y untouched, so head-height / feet-plant, the no-giants gate, and all seeded identity/determinism are byte-unchanged (**no golden move**). **Verified:** keeper now faces the shop floor (was the back wall); **223/223** gig-mode + **188/188** flags-off streamed walkers face their edge-travel direction (avg dot **1.000**; was −1.0); band faces the crowd + crowd faces the stage with `RY_FLIP` gone; `?noassets` placeholder band (which the old flip left facing the backdrop) is now correct too. - **→ Lane C (pose contract, please land in LANE_C_PUB.md):** *"Rig visual front is canonical **local −Z** (`rigs.js buildFigure` normalises the GLB's native +Z). All pose `ry` (counter.stand, bandPoses, watchPoints, browse points) are the −Z-front heading the rig should face — no lane compensates; the R12 `RY_FLIP` is gone."* Your existing `ry` values were already correct under this — nothing to change. - **→ Lane F / Fable (flags-off flag):** the fix is in the shared rig stack, so the **default (flags-off) street crowd now faces travel** instead of moon-walking. Determinism + the golden plan hash are untouched (visual only), but if any regression snapshots *pixels* it will move — it's a bug fix, re-pin if needed (improvement, not a leak). ### Debt #2 — instrument GLBs wired (placeholder-persists, fail-soft, fleet-gated) `band.js` keeps its primitive instruments as the immediate/​`?noassets` fallback, then swaps E's manifest GLBs in async when they resolve (same pattern as `interiors/glb.js`). Role → fitting: `guitar`→ `electric_guitar`, `bass`→`bass_guitar`, `vocal`→`mic_stand` (planted), `drums`→`drum_kit` (planted at the riser), plus one `guitar_amp` as backline dressing. Manifest is **self-fetched** (promise-cached) so band.js stays decoupled from C's glb.js, and the whole upgrade is **gated on `fleet.ready`** → under `?noassets` (fleet null) it never fetches (proven: fresh-tab network shows **0** `procity_fit_*` / `manifest` requests). Disposal-guarded by a generation counter; GLB clones share the cached geo/mats (detach-only, never dispose — only the `ownGeo`-tagged primitives are freed). Orientation lives in one `GLB_FIT` table (guitars need a +Z roll to sling; the kit is scaled 0.66 to fit C's tight 0.73 m vocal↔drums gap). ### Debt #3 — the drummer (4-piece, Fable's ruling) `bandPoses.forEach` already picks up C's 4th `drums` pose (`seated:true`, riser `y`). The drummer sinks `SEAT_DROP` (0.52 m) so the kit hides the straight legs and it reads seated **from the front — the crowd POV / money shot**; from a hard side angle the sink shows (a standing rig faked as seated). A true seated pose is a clip job — see the wishlist. Build is **defensive**: if John had vetoed to a trio, C simply omits the pose and the band is 3 with zero D change. ### The outdoor queue (charter item — new `web/js/citizens/queue.js`) `VenueQueue`: a seeded 2–6 line on the same gate-protected rig path, idle-animated, that drains as punters are admitted and empties by close. Consumes B's `venue.queueZone` when present, else a straight line off the door — the fallback trails along the **frontage tangent** so it hugs the building instead of spilling onto the road. Verified: spawn (5 rigs, human-sized, hug the frontage, face the venue), `admitOne()` drains the head + shuffles everyone up to target, and a seeded auto-drain safety. **F seam** (wire per venue off the street-side gig state): ```js const q = new VenueQueue({ citySeed: plan.citySeed, fleet }); q.spawn(streetGroup, { door, queueZone, gigId }); // at 'doors' (+ 'on' overflow); door = {x,z,ry}, ry = facing the entrance q.admitOne(); // on each admitted punter (cover-charge / entry hook) q.update(dt); // each street frame — idle + lerp + slow auto-drain q.disposeAll(); // at 'done' / venue out of view ``` **→ Lane B:** publish `venue.queueZone = { x, z, ry, len }` — `(x,z)` = head by the door, `ry` = the heading a punter faces (toward the entrance), `len` = line length; the line trails single-file opposite the facing, so pick `ry` to run it along the verandah. ### Multi-venue surge (`sim.js`) `setGig(venueShopId, on)` widened from a single `_gigVenue` to a `_gigVenues` **Set** so several concurrent gigs each pull their own block; `_nearestOpenShop` now prefers the nearest gig venue in `GIG_RANGE` across all of them. F's R12 single-venue call is a subset — still works. Non-gig patronage path is byte-identical. ### Measured budgets (seed 20261990, "Boolarra Heads", The Exchange Hotel #116) | thing | number | budget | |---|---|---| | gig-night interior, rig fleet + GLB instruments | **56 draws** · 183k tris | ≤350 draws ✓ | | — of which instruments (5 GLBs @ ~14k, E's ≤15k target) | ~70k tris | (no interior tri cap) | | band / crowd | **4** on the deck (3 front + seated drummer) · **8** at 8 watch points | crowd ≤ watchPoints ✓ | | outdoor queue | seeded **2–6** rigs (~1 draw each) at the frontage | stated | | `?noassets`+`?gigs` | band+queue placeholders, **0** GLB/manifest fetch | silent-and-fine ✓ | | flags-off street walkers face travel | 188/188 (dot 1.0) | determinism byte-unchanged ✓ | *RSL (#404) fields the crowd-cap stress case: `CROWD_CAP` is 12, real cap = `watchPoints.length` per venue (≤ always holds). Re-measure the RSL interior once its archetype dressing lands.* --- ## ROUND 9 — interior presence: occupancy truth + browser rigs (→ Lane F handoff below) **Follow a ped into a shop and find them browsing.** D owns occupancy truth; C owns the browse points; F wires the handoff. Validated end-to-end in-shell (`?stock=real`). `qa.sh --strict` GREEN; v1 (`?roster=v1`) path untouched. ### D owns occupancy truth ✅ (the C→D→F seam) Patronage now records who's inside which shop, keyed by `shopId` (from the door point). New API: ```js citizens.occupancyOf(shopId) → { count, occupants: [{ seed, enteredAt, pedIndex }] } ``` `count` = peds patronage currently has inside (pre-cap); `pedIndex` lets a browser BE the exact ped who ducked in off the street (identity continuity). Occupancy is cleaned on emerge, on chunk-drop, and on stream toggle. **Deterministic**: same seed + same update sequence → identical occupied shops AND occupants (verified: 17 shops, seed:pedIndex byte-equal across two runs). - **Consistency (D2)**: an `inside` ped is hidden on the street (not in the rendered set — verified 0 inside peds rendered) and re-emerges at the door. No double-presence. ### Browser rigs ✅ (KeeperManager, the leak-proven pattern) `keepers.js` gained `browse:true` (faces the shelf via `ry`, **no player-greet**) + `pedIndex` (pick the exact fleet ped) + `seedKey` (distinct browsers per shop). Each browser is a **merged ped = ~1 draw**. Validated over **12 enter/exit cycles**: browser count == `min(occupancy, 3)` **12/12**, worst interior **61 draws ≤350** (C's headroom holds), **leak-free (0 GPU delta after warmup)**, disposed on every exit (`keepers.disposeAll()` in interior_mode). ### → Lane F: the handoff in `web/js/world/interior_mode.js` (F owns it) — verified working Two-line wiring at interior build (F already has `keepers` + `current.browsePoints` from C): ```js // on enter(shop), after buildInterior: const occ = citizens.occupancyOf(shop.id); const pts = (current.browsePoints || []).slice(0, Math.min(occ.count, 3)); // cap 3 pts.forEach((pt, i) => keepers.spawn(current.group, { x: pt.x, z: pt.z, ry: pt.ry, shopId: shop.id, browse: true, pedIndex: occ.occupants[i] ? occ.occupants[i].pedIndex : null, seedKey: `${shop.id}#${i}`, })); // exit() already calls keepers.disposeAll() → browsers freed with the room. Leak-proven. ``` And **`web/index.html`**: `citizens.setShops(...)` door points must carry `shopId: s.id` (add it to the R8 recipe — one field). Everything else (patronage, weather) is unchanged. **Closing-time (pending A's ruling):** my dwell model already handles it sensibly — `_openAt(hours)` stops *new* entries once a shop closes, and existing occupants clear within their short seeded dwell (5–20 s, tiny vs the hour-long closing window), so no one is stuck inside a shut shop. I'll adopt A's explicit ruling when it lands if it differs. Evidence: `docs/shots/laneD/r9_browsers_in_shop.jpg` (two browsers in a toy shop among the priced stock). --- ## ROUND 8 — shop patronage v0 + weather reaction (→ Lane F wiring below) **The crowd comes alive: streamed peds duck into open shops they pass and re-emerge; at night only the open-late video shop draws visitors; rain thins + shelters the crowd.** Default-on for the streamed roster (post-flip), behind `?patronage=0` off-switch. Verified in-shell (real plan shops) + test page. `qa.sh --strict` GREEN. v1 (`?roster=v1`) path untouched (no patron fields, golden identity holds). ### Patronage (D1) ✅ State machine per streamed ped: walking → (every ~10m, if a nearby OPEN shop is within 18m, a seeded roll) → **going** (steer to the door) → **inside** (hidden, seeded 5–20s dwell) → **emerge** (resumes its footpath walk). Hours-aware via the shop's `hours`. - **Day-long**: peds visibly enter/leave shops of all types (noon: up to ~38 inside near a shopfront, 7 types visited). - **Night**: at 22:48 only the video shop (`hours[1]≥22`) is open → patronage is **video-only** (`onlyVideo:true`), and its block stays lively (the R6 night floor). The patronage roll ramps up as the streets empty so the few peds out concentrate at the one open shop. - **Deterministic** (chunk-keyed identities unchanged: 150 re-derive from seed), **leak-free** (0 GPU delta over a 2-walk patronage churn — enter/emerge reuses the existing actor lifecycle), and **budget-neutral / positive** (inside peds are hidden → draws −1 vs patronage-off at the same spot). ### Weather reaction (D2) ✅ — reads Lane B's `PROCITY.weather` contract (does NOT import weather.js) `sim.setWeather({state,intensity})` each frame. **Rain** (intensity 0.8): density **−56%** (in the 40–60% target), walk speed +~14%, and patronage chance up → more peds shelter in shops (measured inside 6→25). **Overcast**: ~−10%. **Clear**: v1 behaviour. Seed 20261990 rolls `clear` (v1-identical); use `?weather=rain` to force it. ### → Lane F: wiring in `web/index.html` (F owns the shell) 1. **Shop door points** — build once from the plan, feed the sim: ```js const CH = 64, shopsByChunk = new Map(); for (const s of plan.shops) { const l = plan.lots.find(x => x.id === s.lot); if (!l) continue; const ry = l.ry || 0, fx = -Math.sin(ry), fz = -Math.cos(ry); // facade normal → the street const x = l.x + fx * (l.d / 2 + 0.6), z = l.z + fz * (l.d / 2 + 0.6); // door at the shopfront const k = citizens.chunkKeyAt(x, z); (shopsByChunk.get(k) || shopsByChunk.set(k, []).get(k)).push({ x, z, hours: s.hours }); } if (!rosterV1) citizens.setShops(shopsByChunk); // patronage needs door points; inert without if (params.get('patronage') === '0') citizens.setPatronage(false); // off-switch ``` (PATRON_RANGE 18m tolerates door-point imprecision; if a door reads wrong, nudge the `+0.6`.) 2. **Weather** — in the street branch each frame: `citizens.setWeather(window.PROCITY.weather);` (B's contract is always a valid `{state,intensity}`; F sets `{clear,0}` when `?weather` is off.) 3. `setNightLivelyChunks` (openLate block) — already wired in F1; unchanged. **Baseline note (flip protocol):** patronage default-on may move the flags-off draw baseline a hair — in the direction of *fewer* draws (inside peds hidden), and the default spawn view has ~0 near-rigs so it's near-nil. If F1's regression trips, re-pin per the flip protocol (it's an improvement, not a leak). --- ## ROUND 7 — MERGE VALIDATED · **GO for the roster flip** (→ Lane F: flip on this) · escape hatch ready **Verdict: GO. Lane E's R7 ped-merge closed the one gap the R6 memo named. Full-density streamed roster (perChunk 16) is now under budget in a real town. Flip it default-on.** `qa.sh --strict` GREEN. All numbers in-shell (real town "Boolarra Heads", seed 20261990). ### Merge validation (E1 — `pipeline/merge_ped.py`, `447188a`) ✅ - **Every one of the 19 peds is now 1 mesh / 1 material** (was 8 meshes / 2 materials) → **~1 draw per near-rig**; fleet **116→24 draws at the 24-cap** (E's own count: 92→19). Bonus: GPU memory dropped too (geometries 205→160, textures 141→90 in-shell). - Rigs **bind + animate** (walk/idle) — mixer 0.2–0.3 ms; **no T-pose**. Skinning/skeleton preserved (65 joints), clips retarget unchanged. - **Silhouettes + atlas materials intact** — hi-vis worker (vest/helmet/boots), suit, business, comical kid all read crisp, no atlas seams/melting; **identity variety preserved** (`pickRig` unchanged). - **Impostor bake clean** (19 subjects, 16×5 atlas); **determinism holds** (507 chunk-keyed identities re-derive from seed). ### The flip numbers (full density, `perChunk 16` — no reduced-density concession) | metric | pre-merge (R6) | **post-merge (R7)** | budget | |---|---|---|---| | worst continuous-walk street view **draws** | 356 ✗ | **241** ✅ | ≤300 | | worst view **tris** | ~66k | **65,899** ✅ | ≤200k | | worst-view near-rigs | (throttled to 8) | **17** (full) | cap 24 | | draws / near-rig | ~7 | **~1** | — | | 30-chunk soak (3 walks = 726 builds/disposes) | leak-free | **leak-free, 0 GPU delta** | baseline | | heap over soak | 50→77→54 | 78→63 (GC, stable) | stable | ### Full-density shipping defaults (set this round) `enableStream` default is now **`perChunk 16`, `radius 2`** (was 8). Near-cap stays 24 (now 24 draws, not 168). `NIGHT_LIVELY_FLOOR 0.5` for the open-late block (R6). No other knobs change. ### → Lane F: the flip wiring in `web/index.html` (F owns the shell — decision #1, flip on my word) Invert the R5/R6 flag: **stream is the default; `?roster=v1` is the escape hatch.** ```js const rosterV1 = params.get('roster') === 'v1'; // escape hatch → the old fixed roster const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets, fleet, chunkStream: rosterV1 ? null : { radius: 2 } }); // default-on ``` Hours-aware (optional, recommended): after construction, `citizens.setNightLivelyChunks(keys)` where `keys` = the openLate shop's lot→chunk + 1-ring (`sim.chunkKeyAt(x,z)`; openLate = `hours[1] >= 22`). Everything else (`setTimeOfDay`/`setExposure`/`update`/`setPaused`) is unchanged. The hook-driven window (`onChunkBuilt`/`onChunkDisposed` → `sim.onChunkBuilt/onChunkDisposed`) is still the preferred precise option; poll works with zero wiring. **Verified in my harness (test page + runtime-enabled shell):** no-flag boot → `streamMode:true`, perChunk 16, determinism ✓; `?roster=v1` → `streamMode:false`, fixed roster, determinism ✓. The in-shell no-flag-URL boot completes once F lands the two-line flip above — the roster itself is proven to run correctly in the shell at the shipping default (the soak + flip numbers above are in-shell). --- ## ROUND 6 — streamed-roster "default-on readiness" memo (→ Fable + Lane F, for the R7 flip call) **Verdict: functionally ready (deterministic, leak-free, hours-aware, composes) — but full-density default-on is gated on one optimisation (ped sub-mesh merge). Ships budget-safe TODAY at reduced density.** Flag stays default-off this round. `qa.sh --strict` GREEN (sim.js doesn't touch its gates). All numbers below are in-shell (real town "Boolarra Heads", seed 20261990), stream runtime-enabled (F wires `?roster=stream` in F2 — call-site in the R5 section below). ### Soak (gate-2 + far-field, `?roster=stream`) ✅ - **Leak-free**: 3 full street-graph walks = **242 chunk builds / 242 disposes → 0 GPU geometry / 0 texture delta** (renderer.info constant 205/141); **16 shop enter/exit cycles → 0 delta**. JS heap oscillates 50→77→**54** MB (GC recovers — not monotonic, no leak). - **Determinism**: 188 live chunk-keyed identities re-derive byte-for-byte from the seed, in-shell. - **Flag-off byte-identical**: shell citizen-0 signature `0:13:7:1.786:1.493:3:-1` (R4 golden), still exact after this round's changes. 0 console errors across the soak. ### Hours-aware density ✅ (implemented this round) Each chunk thins per-frame by the same `densityAt(tod)` curve v1 uses. New: `setNightLivelyChunks(keys)` gives the open-late block a night floor (`NIGHT_LIVELY_FLOOR=0.5`). Measured: the open-late **video** shop (hours 11–23) block holds ~half its crowd at night while ordinary streets go near-empty (**ordinary 65→6, ~90% drop; open-late block 31→16**). **Lane F call-site**: compute the openLate shop's lot→chunk (+ 1-ring) and `citizens.setNightLivelyChunks(keys)` once after enabling stream (`sim.chunkKeyAt(x,z)` gives the key). Empty set = uniform thinning (safe default). ### Composition with `?dig=1` ✅ The streamed roster is **street-tier only**: the shell frame loop calls `citizens.update` in the street branch only, so the roster is fully inert while a shop/dig is open — no shared state, no fight. Proven: enter/exit record shops (the dig context) with stream on is leak-free and the roster resumes intact. (The riffle itself is Lane C+F's flag; couldn't trigger its raycast open via synthetic input without pointer-lock, but the composition claim is mode-separation, which holds by construction.) Same reasoning covers the all-on combo — roster shares no state with `winmap` (B glass) or `dig` (C). ### Perf / budget — the ONE gap ⚠️ (default-on blocker at full density) - Stream vs v1 (pop 140) at the busiest node: frame **+0.29 ms** (0.89 vs 0.60 ms), draws ~parity; most of the ~500 active are far = cheap position-advance only. Mixer stays 0.1–0.3 ms. - **Draw budget**: worst continuous-walk street view must stay ≤300 (CITY_SPEC). Root cause: each decimated ped is **8 sub-meshes but only 2 materials → ~7 draws per near-rig**. So a dense street of near-rigs blows the budget: | stream `perChunk` | worst-view draws | worst near | density (within 70m, median) | |---|---|---|---| | 16 | **356** ✗ | 15 | ~26 | | 12 | 314 ✗ | 10 | ~18 | | **8 (new default)** | **291** ✓ | 10 | ~13 | So it ships **budget-safe at `perChunk=8` today** (~13 median within 70m — constant + camera-following vs v1's uniform ~13 that *thins as the town scales*; and it concentrates **~10 near-tier rigs vs v1's 0–1** — the visible upgrade). Full density (`perChunk 16`, ~2× the crowd) needs the budget headroom. ### Recommendation for the R7 default-on flip 1. **Land the ped sub-mesh merge** (merge each ped's 8 sub-meshes by material → 2 → ~2 draws/rig). Then `perChunk 16` worst-view ≈ 260 draws — full density under budget. It's a `rigs.js` fleet-load optimisation and it is **F1-safe in practice**: v1's default/spawn view has ~0 near-rigs, so the flags-off draw snapshot is unaffected — the merge only cuts draws where near-rigs cluster (the stream path). Also helps v1 street draws + interior keepers. ~1 session; risk = skinned-mesh merge (verify animation). **This is the single thing standing between stream and default-on.** 2. Until then: default-on is safe at `perChunk=8` (reduced density) — flip is a judgement call on whether ~10 near-rigs + constant far-field beats v1 today, or wait for the merge for the full crowd. *Known non-blockers: near↔mid is a hard LOD switch (documented, imperceptible at 25m); open-shop spawn-bias beyond the night floor is v2-later (needs per-chunk shop data — the sim is graph-only).* --- ## ROUND 5 — chunk-streamed roster (v2, behind `?roster=stream`) ✅ → Lane F wiring below **As-built implementation of the R3 design note (further down).** Default-off; v1 path byte-identical. `tools/qa.sh --strict` GREEN (sim.js doesn't touch the citygen/manifest gates). **What it does.** v1 spreads one fixed roster over the whole town, so big-town streets are uniformly sparse. Stream generates + ticks citizens **per 64m chunk**, keyed so identity is a pure function of `(seed, chunkKey, i)` — independent of town size / visit order — and windows them to the live chunks around the camera. Density becomes constant per unit street and follows the player. Everything downstream (LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer) is unchanged and stays **global across live chunks**. **Measured (test page, 9×9 fixture grid, ~±208m):** - **Constant far-field density**: citizens within 70m ≈ 58 at spawn, 129 at 150m out, 83 elsewhere — vs v1 (200 pop) which is uniformly ~12–15 everywhere (spread too thin). Stream is ~5–10× the street density at equal total cost, and it doesn't thin as you walk arbitrarily far. - **Deterministic**: 507 chunk-keyed identities re-derive byte-for-byte from the seed (determinism button, stream mode). Same seed + same walk → same residents. - **Leak-free**: after warming ped GPU uploads, **2 full grid walks (~380 chunk build/dispose cycles) → 0 geometry / 0 texture delta**; rig pool capped at 30, live chunks bounded at 25 (R=2 → 5×5). - **Budget**: worst view uses the same near-cap (24) + 1 impostor draw as v1; frame 2.31 ms (~432 fps headroom) with ~500 active (most far = cheap position-advance only). Citizen draw/tri contribution is identical to v1's bound. - **Flag-off = v1**: shell with no flag → `streamMode:false`, citizen 0 signature `0:13:7:1.786:1.493:3:-1` — **exact match** to the R4 golden capture. No perturbation. ### → Lane F: wiring `?roster=stream` in `web/index.html` (F owns the shell) Minimum (poll-driven, zero Lane B changes — works today): ```js const streamOpt = params.get('roster') === 'stream' ? { radius: 2, perChunk: 16 } : null; const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets, fleet, chunkStream: streamOpt }); ``` Preferred (hook-driven — the sub-roster window then matches Lane B's built-chunk window exactly; the sim auto-stops polling on the first hook call). Wire onto the chunks ctx **only when the flag is on**: ```js if (streamOpt) { chunks.ctx.onChunkBuilt = (key) => citizens.onChunkBuilt(key); // Lane B calls this (chunks.js:41) chunks.ctx.onChunkDisposed = (key) => citizens.onChunkDisposed(key); // chunks.js:47 } ``` (`chunks` already exposes `ctx`; if not, expose it or pass the two callbacks into `createWorld`.) Nothing else changes — `setTimeOfDay`/`setExposure`/`update`/`setPaused` are identical. Flag-off must not construct with `chunkStream`, so the v1 path (golden identities) is untouched. The 64m chunk key (`${cx},${cz}`, `Math.floor(v/64)`) matches Lane B's `planutil.js` exactly, so B's keys line up with ours. *Hours-aware density is in (task 3): each chunk is thinned per-frame by the same `densityAt(tod)` curve v1 uses, so streamed streets empty at night too. Open-shop spawn-bias weighting is left for v2-later (needs shop data in the chunk; the sim is graph-only today) — noted, not built.* --- ## ROUND 4 — in-shell verify + decimation validation (→ Fable, → Lane F) **All three R4 tasks done. `tools/qa.sh --strict` GREEN. Sign-off for Lane F below.** ### D1 — in-shell rig/keeper verification (`web/index.html`) ✅ Verified in the real game (not the test page), via `window.PROCITY`: - **Placeholder→rig upgrade** fires for both peds (`citizens.mode` → `'rig'`, 19 fleet, pool active) and keepers (keeper spawns at the counter `keeperStand` pose as a **rig**). - **Leak-free**: baseline `renderer.info.memory` = {geometries 213, textures 198}; after **12 enter/exit shop cycles** it returned to **exactly** 213/198 (Δ geom 0, Δ tex 0). Interiors peak ~303 geoms inside, drop back every exit. Confirms the R-review dispose fix (free the keeper's Skeleton only, never the fleet-shared geo/mats) holds in-shell alongside Lane C's interior dispose. - **Determinism in-shell**: seed 20261990 → byte-identical identity signature across a full reload. - **`?noassets=1`**: `fleet===null`, mode stays `placeholder`, peds + keeper are placeholder actors, **zero ped GLB fetches** — town fully playable asset-free. ### D2 — decimated ped validation (critical path — E1 landed `518678d`) ✅ SIGN-OFF FOR F Validated Lane E's decimated fleet (`web/models/peds/`, ref `lane-e/round4-peds`): - **Per-ped tris: 1564–2805, avg 2450 — all ≤3k** (matches E's `_peds_decim.json`). - **Rigs bind + animate**: skinning preserved, the shared `walk.glb`/`idle.glb` retarget unchanged (skeleton/65 joints intact), near actors animate, mixer 0.1–0.3 ms. - **No skinning explosions**: 24-teleport pool-eviction churn → **0 broken meshes**; silhouettes read at street distance; identity variety intact (verified visually — kid/luchador/hi-vis/elder/ business all distinct, no melted hands/faces). - **Impostor atlas bakes clean** from the decimated rigs; determinism + leak checks re-pass. - **Gate-3 number for Lane F**: at the 24-rig near cap the fleet now contributes **≈59.6k tris** (was ~1.2M). Test-page whole-view total **88.9k tris** (was ~1.5M). In-shell sample near rig 2729 tris, keeper 2073 tris (was 41k). **F is clear to re-measure gate 3** — the fleet leaves ~140k for the town, comfortably ≤200k. (The R1 "peds too heavy" finding is now resolved by E's decimation.) ### D3 — exposure sync at dusk/night — FOUND + FIXED A REAL BUG ✅ Verifying the shell exposed a genuine rendering bug (only correct on my direct-canvas test page): **the impostor material was `toneMapped:false` and applied ACES in-shader**, but the shell renders through an **EffectComposer (RenderPass → bloom → OutputPass)**. three only tone-maps materials when rendering to the **canvas** (`three.module.js:6921–6931`, `currentRenderTarget === null`); into a composer target, materials output **linear** and OutputPass applies ACES once. So my self-tone-mapping impostors were **double-tone-mapped** in the actual game (too dark), matching the rigs only on the test page. **Fix (impostor.js):** the impostor is now a normal `toneMapped:true` material — it outputs **linear** and uses three's own `` + `` call-sites (the `*_pars_*` are auto-injected — including them ourselves double-defines `RRTAndODTFit` and fails to compile), so it tone-maps **identically to the near rigs on both paths**: canvas → ACES per-material; composer → linear → OutputPass ACES. Verified: near rig + mid impostor match brightness at noon AND the scene reads correctly at night 22:00 in the shell (`docs/shots/laneD/r4_shell_*.jpg`), and the test-page canvas path still matches. - **Note for Lane F**: `citizens.setExposure()` is now a **no-op** (exposure is global via `renderer.toneMappingExposure`, which both three and OutputPass read). The call at `web/index.html:245` is harmless — keep or drop it, your call. *(Round-1/3 notes below remain valid; the tri-budget finding #1 is superseded by D2's decimation.)* ## What shipped | file | role | |---|---| | `web/js/citizens/rigs.js` | ported rig stack: fleet loader, `_canon`/`canonRig`/`_rotOnly`, `buildFigure` (head-bone height-normalise + feet plant), `spawnRig` (single clip), `makeActor` (walk↔idle crossfade), `pickRig` | | `web/js/citizens/placeholder.js` | seeded low-poly box humanoid (POLY lock) — walks/idles, planted at y=0, hot-swaps to a rig | | `web/js/citizens/impostor.js` | 4-yaw sprite-atlas baker + instanced billboard layer (mid tier, 1 draw call) | | `web/js/citizens/sim.js` | deterministic roster, footpath lanes, near/mid/far LOD, rig pool, staggered mixer budget, time-of-day density | | `web/js/citizens/keepers.js` | one keeper per shop at the counter slot, idle + greet head/body-turn | | `web/citizens_test.html` | standalone harness: fixture 3×3 street graph, sliders, tier debug, determinism check | | `web/models/peds/*` | 19 rigged GLBs + `walk.glb` + `idle.glb`, **byte-identical** copies from 90sDJsim (checksummed) | ## The fleet (copied, never edited) 19 rigged peds (17 normal + 2 comical) + 2 clip-only GLBs, ~37 MB total, copied from `johnking@100.91.239.7:~/Documents/90sDJsim/web/world/models/peds/` (also present locally at `~/Documents/90sDJsim/…`). SHA-verified byte-identical (house law: canonical source stays upstream). - Skeletons canonicalise cleanly: `mixamorig1…`→`mixamorig…`, so **one shared walk clip drives all 19** and one shared idle clip too. Verified in-scene — a single `walk.glb` animates every ped. - `walk.glb`/`idle.glb` position tracks + `Hips.quaternion` are stripped (`_rotOnly`) — without this the different-scale root track inflates peds to giants. Confirmed: no giants/ants across all 19. ## Measured budgets (M3 Ultra, `citizens_test.html`) Preview note: the in-app browser reports `visibilityState:'hidden'`, which throttles `requestAnimationFrame`, so the on-screen fps counter reads low (47–84). True cost was measured by timing 140 manual `update()+render()` frames. **200 citizens, midday (the acceptance fixture):** | metric | measured | CITY_SPEC / Lane-D budget | verdict | |---|---|---|---| | near (rigged) actives | 24 (hard cap) | ≤ 24 | ✅ | | mid (impostor) | ~122 | — | — | | far (culled) | ~54 | — | — | | **mixer update / frame** | **0.4 ms max, ~0.2 ms avg** | < 2 ms | ✅ comfortably | | sim logic / frame | 0.24 ms avg | 4 ms build budget | ✅ | | full frame CPU (update+render) | ~2.6 ms → ~380 fps headroom | 60 fps (16.6 ms) | ✅ huge margin | | mid tier draw calls | **1** (69 instances, 138 tris) | 1 per atlas | ✅ | | impostor atlas texture | 2048×640 (16×5 cells, 4 yaws × 19 peds) | ≤ 2048, < 512 MB total | ✅ (~5 MB) | | determinism | live roster ≡ seed recompute, stable while walking | same seed → same crowd | ✅ | **Time-of-day density** (200 base): 00:00 → 12 active, 06:00 → 70, 12:00 → 200, 15:00 → 150, 21:00 → 36. Curve in `sim.js DAY_CURVE`. ## ⚠️ Findings for Lane E / Lane F (need a decision) 1. **The inherited peds are ~49.7k tris each — too heavy for the CITY_SPEC 200k-tri "typical view".** 24 near rigs ≈ **1.2M tris**; a realistic 16-rig street view already measured **1.02M tris**. Framerate holds on M-series (GPU eats it), but this leaves *nothing* for Lane B buildings. **Recommendation:** decimate the ped fleet to ~5–8k tris in the asset pipeline (Lane E) — at 6k/ped the near cap costs ~144k tris, back within budget. The rig stack is indifferent to poly count; this is purely an asset job. Until then, `NEAR_MAX` (sim.js) is the throttle. 2. **Draw calls scale with near rigs (~6 calls/ped — each GLB has several sub-meshes/materials).** 16 rigs ≈ 144 calls; 24 rigs ≈ ~190. Under the 300-call street budget *today*, but tight once Lane B adds shells. **Recommendation:** merge each ped's sub-meshes by material on load (`BufferGeometryUtils.mergeGeometries`) → ~1–2 calls/ped. Cheap win, deferred (not blocking). 3. **Peds are metallic-PBR (`metalness≈0.5`) and render black without a `scene.environment`.** The test scene builds a neutral PMREM; the impostor bake is fed the same env. **Lane B's shell must set `scene.environment`** (a sky PMREM) or every citizen — near and mid — goes dark. This is wired: `CitizenSim` reads `scene.environment` and passes it to the impostor baker. 4. **Impostor material is `toneMapped:false` and does ACES + sRGB itself** (matching `ACESFilmicToneMapping`, exposure `/0.6`). **The shell DOES animate `renderer.toneMappingExposure`** (Lane B `lighting.js` sets it per day segment), so mid billboards drift brighter/darker than the near rigs across the day unless matched. **Round-3 fix:** `CitizenSim.setExposure(e)` passthrough added — see the Lane F hook below. (For a non-ACES curve the in-shader ACES in `impostor.js IMP_FRAG` would also need to match; today both are ACESFilmic so exposure is the only variable.) *Hardened by a 4-dimension adversarial code review (see D-progress.md): 6 confirmed defects fixed — non-deterministic fleet order, shared-geometry disposal, impostor under-exposure, unrestored viewport, missing modelMatrix, frozen exposure.* ## Integration hooks for Lane F - `new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet })` — `graph = { nodes:[{id,x,z}], edges:[{id,a,b,width,kind}] }`. Swap the fixture graph for Lane A's `CityPlan.streets`. Footpath lanes are derived from edge `width` + a 0.9 m margin; pedestrians keep to the right of travel. - `sim.setPopulation(n)` (slider / density) · `sim.setTimeOfDay(t01)` (drive from the shell's day segment) · `sim.setPaused(bool)` (wire to `visibilitychange`) · `sim.update(dt)` each frame. - **[Round-3] `sim.setExposure(e)` — one line, needed for dusk/night.** The shell already calls `citizens.setTimeOfDay(...)` in the street loop (`index.html:225`); add right after it: ```js citizens.setExposure(renderer.toneMappingExposure); // [Lane F] keep mid billboards matched to day/night exposure ``` Cheap (sets one uniform), safe to call every frame, and survives the placeholder→rig atlas re-bake (stored internally). **Verified** in `citizens_test.html`: uniform tracks 0.55↔2.3 and persists across the fleet upgrade. Without it, mid impostors read too bright at night / too dark at noon vs the near rigs. *(This is a Lane-F seam edit in `index.html`; flagged here per the cross-lane note rule.)* - **[Round-3] Keeper fleet-upgrade (enables F §3.4).** Keepers today are placeholder-only because F builds `new KeeperManager({ camera, citySeed })` with no fleet. To upgrade them to shared GLB rigs, pass the loaded fleet: `new KeeperManager({ camera, citySeed, fleet })`. `keepers.js` already picks a rig when `fleet.ready` and falls back to a placeholder otherwise — no other change. **Dispose is leak-free**: verified 15 enter/exit cycles with a rig keeper → `renderer.info.memory` geo/tex return exactly to baseline (`_disposeInner` frees the clone's skeleton bone-texture only; shared fleet geo/mats are preserved for siblings). Safe to spawn/`remove()` per interior enter/exit. - **Chunk streaming (v1.5):** roster is currently whole-graph — fine for v1, but big towns look sparse (see the design note **"Chunk-streamed roster"** below for the why + the full plan). - **Keepers:** `keepers.spawn(roomGroup, { x, z, ry, shopId, type })` — feed `x,z,ry` from Lane C's interior counter `places`; call on interior build, `keepers.remove(handle)` on dispose. `keepers.update(dt, playerPos)` each interior frame. - `?noassets=1` verified: full placeholder town, mixers=0, zero crashes. ## Chunk-streamed roster — design note (✅ IMPLEMENTED in R5, behind `?roster=stream`) *Round-3 task 3 (design), built in Round 5 — see the "ROUND 5" section at the top for the as-built result + Lane F wiring. This section is the original spec; the implementation followed it: hook-driven (`onChunkBuilt`/`onChunkDisposed`) with a poll fallback, chunk-local identity, global near-cap, owner despawn, constructor opt-in (`chunkStream`). Kept for the rationale.* **The problem (measured).** `setPopulation(N)` builds one global roster and spreads it uniformly over *all* edges (`identityOf` picks `edge = rng()*edgeCount` across the whole graph). On the 12-edge test fixture that fills the view. On a real town — "Boolarra Heads" has hundreds of edges — those same N citizens scatter town-wide, so almost all sit beyond the 70 m cull from any one camera (I measured a 27-node generated town: pop 200 → **4 near / 6 mid / 190 far**). Raising N to fill the near streets wastes roster + advance cost on citizens nobody can see, and still can't guarantee local density. **The fix.** Generate + tick citizens **per chunk**, keyed so identity is independent of town size and visit order, and stream them in lockstep with Lane B's chunk window (the chunks already building/ disposing around the player). Density becomes *constant per unit street*, and total roster is bounded by the live-chunk window, not the town. **Design:** 1. **Chunk-local identity.** Replace the global running `id` with a per-chunk key: `identityOf(citySeed, chunkKey, i)` for `i in [0, perChunkCount)`, and choose the citizen's home edge from **that chunk's** edges (`chunkIndex(plan).chunks[chunkKey].edges`), not the global list. Then "same seed + same chunk → same people" holds regardless of what else exists or the order chunks were visited — the determinism property that whole-graph keying quietly loses at scale. `perChunkCount` is seeded per chunk and scaled by district (main-street chunks busier than residential) × `densityAt(tod)`. 2. **Windowing — poll-driven (works today, no Lane B change).** Each frame derive the active chunk set from the camera: `chunkKey(⌊camX/chunkSize⌋, ⌊camZ/chunkSize⌋)` + neighbours within a radius R (R≥1 so a walker never steps into an unloaded chunk before its owner unloads). Diff vs the live set → build sub-rosters for newly-active chunks, dispose for newly-inactive. This mirrors B's own window and needs nothing from B. *Alternative — hook-driven:* `chunks.js` exposes an optional `ctx.onChunkBuilt` (see `chunks.js:40` — "inert unless a consumer sets it"); if B also adds `onChunkDisposed`, drive spawn/despawn off those instead of polling. Prefer this once B commits the pair; poll until then. 3. **Storage.** Swap the single `this.roster` array for `this.chunkRosters = Map`. The per-frame `update()` iterates the union of live-chunk citizens. **Everything downstream is unchanged** — LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer already operate per-citizen and are chunk-agnostic; the near-cap + mixer budget stay **global** across live chunks (nearest-first selection already does the right thing on the merged set). 4. **Ownership + hand-off.** A citizen is owned by its spawn chunk. It walks freely (the graph is continuous); because R≥1 keeps neighbours loaded, crossing a chunk boundary is seamless. It despawns only when **its owner chunk** unloads — even if it's momentarily standing in a still-loaded neighbour (acceptable: that's behind the player, past the cull). No re-keying on cross, so no identity churn. 5. **Budget.** Live window ≈ (2R+1)² chunks × `perChunkCount`. Tune `perChunkCount` (~15–30) so the near streets reach the 24-cap while total roster stays ~150–270 — similar cost to today's flat 200, but concentrated where the camera is instead of smeared across the map. **Migration.** Opt-in and back-compatible: `new CitizenSim({ …, chunkStream: { chunkIndex, chunkSize, radius } })` switches on chunk-keyed identity + windowing; omit it and the current whole-graph roster is unchanged. So v1 ships as-is and v1.5 flips a constructor option — no rewrite of the hot path. **Explicitly NOT in scope here:** cross-chunk social groups, per-district behaviour trees, persistent citizens (a citizen you saw yesterday) — all v2 (`docs/V2_IDEAS.md`). ## Clip wishlist (for the mixamo-fetch run — CHECK THE 34 EXISTING CLIPS FIRST) Only `walk` + `idle` are wired today (they ship with the ped fleet). CITY_SPEC says `~/Documents/mixamo-fetch/out/` already holds **34 clips (sit, lean, look-around, phone, …)** — these bind directly through the canonical `mixamorig` skeleton exactly like walk/idle, so wiring them is a data task, not new rig work. **Verify each exists before requesting; never re-download.** Wanted for richer loiter/keeper behaviour, in priority order: 1. `sit` — bench-sit at benches/verandah steps (sim loiter has the hook; needs the clip). **R13: the gig drummer also wants this (or a bespoke `drum`/`play-drums` loop)** — the 4th band member is currently a standing rig sunk `SEAT_DROP` behind the kit, which reads seated only from the front. A real seated clip retires the sink hack (band.js `SEAT_DROP` + the `m.seated` branch in `update`). 2. `lean` — loiter against a shopfront (window-shopping stops). 3. `look-around` / `idle-look` — window-shopping variety + a better keeper greet than the body-turn. 4. `talk` / `gesture` — pairs chatting on the footpath (spawn in seeded 2-groups). 5. `carry` / laden walk — shoppers with bags after a purchase (content-phase tie-in). 6. `browse` / `reach` — keeper stocking shelves; customer reaching a shelf inside interiors. Bespoke (won't be on Mixamo, hand-author later): crate-riffle at market stalls, till-operation. ## Known limitations (honest) - Near↔mid swap is a hard LOD switch at ~25 m (hysteresis 24/27 m). At that range a 128px impostor and the full rig subtend near-identical screen size, so it reads as seamless — but it is a switch, not a cross-fade (rig materials are shared across SkeletonUtils clones, so per-instance opacity fade isn't free). If a pop is ever visible after buildings land, the fix is a short dither/fade band. - In `?noassets` mode, mid impostors use 8 generic placeholder variants while near placeholders are per-citizen coloured — so a citizen's colour can shift slightly crossing 25 m. Fallback-only; with the real fleet, near and mid are the same baked ped. - Loiter *timing* is dt-driven (cosmetic), so exact positions at time T aren't reproducible across runs; **identity** (who, which ped, height, speed, spawn beat) is fully seeded and asserted.