# 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 41 (§41.3) — THE CITIZENS COME ALIVE: 46 shipped · 29 wired · 20 on the street Summary in `D-progress.md` §Round 41; this is the measured detail. Lane E's `web/models/clips/*.glb` + `web/assets/motion_manifest.json` are the input, verified before wiring (`python3 pipeline/clips_verify.py` → GREEN 0/0, 46 clips / 6 groups / 3 498 124 B on today's tree). ### The one number that says what the round did A census of the live crowd at the retail heart, 12 samples × 146 active citizens, same seed, same pose, two arms: | state | `?clips=0` (R40) | default (R41) | |---|---:|---:| | walking | **99.3%** | 78.1% | | bench-sit (on a real bench) | — | **9.5%** | | shopfront lean | — | **7.1%** | | stopped at a window, own seeded idle | 0.4% | **5.1%** | | R17 free sit · R29 glance | 0.2% · 0.2% | 0.1% · 0.1% | | distinct clips in play across the crowd | **4** (`walk`/`idle`/`sit`/`look`, 99.3% on `walk.glb`) | **20** | `?clips=0` is a new flag in `rigs.js` that turns the motion library off and NOTHING else. It exists because `?classic=1` is a bad control for this — it also changes the ped pool (17 vs 22), the sit/look/dance clips, the fog, the game and half the shell, so a heap or draw delta measured against it says nothing about the library. ### The files, and the one law that holds them together | file | what it is | |---|---| | `web/js/citizens/postures.js` | **NEW.** Pure, THREE-free, load-state-free: the clip pools and the seeded pickers. Node imports it directly (the gate does). | | `web/js/citizens/clipbank.js` | **NEW.** The group loader. **Dynamically imported** by rigs.js behind the gate, so `?classic=1` does not fetch the module either. | | `rigs.js` | `loadPedFleet` gains `clips = dance` (the R36 `djs = dance` trick again: the shell already passes `dance: !CLASSIC`, so **no shell edit was needed**). `makeActor` gains per-instance clip swapping; `spawnRig` gains `setClip`. | | `sim.js` · `keepers.js` · `band.js` | the states. | **THE LAW: posture is a pure function of (citySeed, id), decided once, never re-rolled.** Lazy loading can change *when* a posture appears but never *which one it is*. That is what makes six groups affordable — and it is gated: arm 3 of `tools/qa/r41_citizens.py` delays **every** clip GLB by 2 s with a Playwright route and asserts the posture signature is byte-identical to the unstalled run. The same discipline runs one level down. `fleet.clipsRequested` is published **synchronously** by `loadPedFleet` before the dynamic import even starts, and every roll that can move a citizen (bench, lean, pause) is gated on that — never on `fleet.bank`, which only answers "is it resident yet". Otherwise the first second of a boot would draw a different number of randoms than the rest of it and "same seed → same crowd" would depend on the network. Every such roll is also drawn **unconditionally** (the R17/R29 pattern: roll always, act only if allowed), and the POSE falls back to the R2/R16/R29 base clip while the variant is in flight — so a slow fetch changes nothing at all. ### Clip → state map (29 of the 46 wired, 17 deliberately not) | category | group | clips used | drives | |---|---|---|---| | idle | `idles.glb` | all 10 | **per-citizen deterministic idle** (`posturesFor().idle`) — the resting action of every near-tier actor, so it plays on every window pause, and the seeded shopkeeper idle | | locomotion | `locomotion.glb` | `walk_shopping_bag` (1 of 6) | the walk pool is `['@walk','@walk','walk_shopping_bag']` ⇒ **1 in 3 citizens carries a shopping bag**; `@walk` is the R2 `walk.glb` | | sitlean | `sitlean.glb` | all 8 | 4 sits on **Lane B's actual benches**, 4 leans on **shopfront walls** | | browse | `browse.glb` | 5 of 8 | interior browsers at Lane C's browse points, seeded per (shopId, slot) | | venue | `venue.glb` | 5 of 6 | gig crowd widening + `venue_bartending` for pub/rsl/band_room keepers + `venue_headphones` for record-shop keepers | | social | `social.glb` | **0 of 8** | **not loaded, not fetched.** A two-person clip needs a two-person state machine and a partner-pairing pass. 896 KB for a state D does not have. → R42 | Not used and why: `browse_hold_turn_l/r` (172°/147° loop seam — one-shot turns with no turn state), the four `turn_*` + `walk_to_stand` (the sim turns instantly at a node; a turn-state machine is R42's job and it is the only thing that makes those five clips mean anything), `venue_clap_seated` (no seated venue slot yet). **Loop safety is measured, not assumed.** E ships `loopSeamDeg` per clip; `clipbank.js` puts anything with `loopable:false` **and** a seam > 25° on `THREE.LoopPingPong` instead of repeat. Exactly one pool clip lands there — `browse_pick_up` (92.15°) — and ping-ponging it is why it reads as crate-digging (down, up, down) rather than a teleport back to the start. The other 28 either loop or have a measured seam ≤ 5° (E's `loopable` flag is conservative; the seam is the ground truth). ### THE BENCH — the state that needed geometry, and how the mirror is kept honest R17's bench-sit sat a ped down at whatever graph node it stopped at, upright, on air ("no bench-position binding" — its own comment). R41 binds it. Lane B places benches at `s = 14, 40, 66, …` (step 26 m) on alternating sides of every edge (`furniture.js:203-209`). `sim.js benchStationsFor(edge)` mirrors that rule exactly, and a ped can only take a bench on ITS OWN footpath side: the sim's lane perpendicular is `forward × (uz, −ux)` = `−forward ×` the furniture perpendicular, so the match condition is `side === −forward`. The bench's local +Z is its FRONT (B's template puts the backrest at z = −0.2, the seat at y = 0.45) and rig fronts are local −Z after the R13 facing-normalise, so a sitter is `benchYaw + π`, nudged 6 cm onto the seat. The plank is 1.6 m — **it seats two**, ±0.38 m on a seeded draw. That was a measured need, not a flourish: the first soak put `-1,-6#3` and `-1,-6#5` on the same station to the centimetre. **A mirror that drifts is a ped sitting on air again, so it is gated, not trusted.** Arm 6 of `tools/qa/r41_citizens.py` walks every station the sim derives inside the streamed window and requires real instanced geometry within **2 cm** of it, with the yaw matching mod 2π. Today: **14/14 position, 14/14 yaw**, against a scene holding 2 435 instances. **CONTROL:** the same stations offset 2 m match **0/14** — so "coincides" is a measurement, not a hit on any nearby prop. > **→ LANE B, one line, and D deletes the mirror:** please export > `benchStops(plan)` from `web/js/world/furniture.js` next to the existing `busShelterStops(plan)` > (same shape, same reason it exists). D switches to the import and drops `benchStationsFor` > entirely. Until then the gate is the seatbelt. > > **→ LANE B, observed while mirroring, not fixed (your file):** `pushYaw(lists.bench, …, yaw + …)` > with `yaw = atan2(ux, uz)` maps the bench's local **+Z to ALONG the edge**, so the bench's 1.6 m > plank runs ACROSS the footpath and it faces up the street, not the road — the comment on line 203 > says "facing the road". The streetlight arm above it (`armYaw`, line 197, "arm reaches over the > road") has the same convention and so has the same question. D's sitters bind to the bench > TRANSFORM, so they sit correctly either way; this is only cosmetic and only yours to rule on. ### THE SHOPFRONT LEAN — and the measurement that moved it First wired to the R17/R29 window-shop loiter, which fires at a graph NODE — i.e. at an intersection, where there is rarely a shopfront to lean on. **Measured: 0 leans in a 9 s run.** Moved to the R8 patronage stride check (every 10 m walked), which is already the moment the sim asks "is there a shop beside me" — which is exactly when a leaner is beside a wall. Strictly downstream of the duck-in decision, on its own `leanstop` stream. The wall anchor uses `shop._raw` — the door point **before** R40's footpath clamp. The clamp exists to drag doors onto the walkable strip (its whole job), but a leaner wants the facade the door was derived from, not the kerb the ped walks on. `_raw` is the shell's `lot + front-normal·(d/2+0.6)`, i.e. 0.6 m in front of the facade; push `LEAN_WALL_BACK` 0.30 m further out along the edge normal and the ped's back is ~0.15 m off the wall, offset `LEAN_SIDE` 1.05 m along the frontage so they are not in the doorway, facing the road. ### THE WINDOW PAUSE — the measurement that changed the design The headline of the round is the per-citizen idle. Wired only to R17/R29's node loiter, a census of the live crowd found **0.8% of citizens stopped at any instant** — edges are long, node arrivals are rare — so nine of the ten new idles were assigned, deterministic, and **never seen**. That is a real failure of the brief dressed up as a green tick. Fix, on the same stride check as the lean: a ped who walks past a shop and neither goes in nor leans on it sometimes just **stops and looks at the window**, in their own seeded idle. No reposition, no new clip, no new fetch — the actor's resting action already IS this citizen's idle (`setIdleClip` at acquire). Stopped-citizen share went **0.8% → 5.1%**, and all ten idles now appear in a 12-sample census (`idle_cocky_lean` 1.5/sample down to `idle_breathing` 0.4/sample). ### Rates, and what they buy (all seeded, all tunable in one place at the top of `sim.js`) | constant | value | effect at the retail heart | |---|---:|---| | `BENCH_STOP_FRAC` · `BENCH_DWELL` | 0.40 · 9–20 s | 9.5% of the crowd seated | | `LEAN_FRAC` · `LEAN_DWELL` | 0.12 · 6–14 s | 7.1% leaning | | `PAUSE_FRAC` · `PAUSE_DWELL` | 0.17 · 4–9 s | 5.1% stopped in their own idle | ### The pooled-actor problem, and why `makeActor` grew four methods Near actors are POOLED by ped type and recycled between citizens (R2 decision #1), so a per-citizen idle cannot be baked in at construction — a recycled actor would carry the previous citizen's posture. `makeActor` now memoises one `AnimationAction` per SOURCE clip and re-points four live slots (`walkA`/`idleA`/`sitA`/`lookA`) on acquire, ≤3 per frame (`NEW_RIG_PER_FRAME`). three.js shares the `PropertyMixer` bindings per (root, track) across every action on the same root, so the marginal cost of an extra action is its interpolants, not another copy of the skeleton. `setSitting(s, clip)` and `setLooking(l, fade, clip)` gained an optional clip and keep their exact R16/R29 semantics with it omitted; `setIdleClip(null)` / `setWalkClip(null)` restore the base actions, which is precisely what a citizen whose clip has not landed (or a boot with no bank) gets — the R40 actor, byte for byte. `spawnRig` (single-clip figures: keepers, browsers, gig) gained `setClip`, the **self-heal seam**: a figure is posed with whatever exists at spawn and upgrades in place the frame its lazily-fetched group lands. Its re-plant generalises R29's lesson — `_rotOnly` drops the Hips POSITION track, so a clip's authored vertical motion comes out as the FEET moving (R29 measured +0.205 m on look.glb). spawnRig's original seated re-plant samples once at t=0, which is right for a fixed pose and wrong for a clip whose lowest bone travels, so `setClip` samples the posed skeleton at 6 points across the clip and plants the MINIMUM: soles can never sink through the floor, and the residual float is the clip's own authored range rather than an arbitrary phase's error. ### Memory and fetch — the ledger, measured in fresh headless contexts | | fetches | bytes resident | groups / clips | |---|---:|---:|---| | at boot | **4** (manifest 16 KB · `clipbank.js` · `idles.glb` · `locomotion.glb`) | **1 242 272 B** | 2 / 16 | | + first street sit/lean intent | +1 `sitlean.glb` | 1 600 384 B | 3 / 24 | | + first interior with browsers | +1 `browse.glb` | 2 076 440 B | 4 / 32 | | + first gig night (from the street, `setGig`) | +1 `venue.glb` | 2 602 476 B | 5 / 38 | | ever | **never** `social.glb` | — | — | | `?clips=0` · `?classic=1` · `?noassets=1` | **0** | 0 | 0 / 0 | **One honest line on the `?classic=1` fetch delta.** It is zero for ASSETS — no clip GLB, no manifest, and not even `clipbank.js` (dynamic import behind the gate). It is **+1 JS module**: `postures.js` (7 098 B, pure data + five pure functions) is a static import of `sim.js`/`keepers.js`/ `band.js` and so is fetched on every boot, exactly as R40's `door_snap.js` (4 017 B) already is. Zero asset bytes, zero GLB, zero behaviour under the gate — but stated rather than left to be found. Six fetches at boot was the thing the round warned about; the answer is **two GLBs at boot, three on first demand and one never**, and each lazy group is promise-cached so N racing callers cost one fetch (proven: the bank does not grow across 6 interior enter/exit cycles). **Heap delta (library ON − OFF), `performance.memory` after a forced GC in a fresh context: +3.34 MB and +6.87 MB across two runs** — the spread is GC timing, the floor is the resident clip bytes plus parsed `AnimationClip` overhead. `mixerMs` is unchanged: median 0.1 ms in both arms, max 0.3 ms vs 0.2 ms. ### Zero draw — before/after, same seed, same bookmarks | view | `?clips=0` | default | Δ | budget | |---|---:|---:|---:|---| | street_noon | 193 | 193 | **+0** | ≤300 | | crossroads_busy | 108 | 108 | **+0** | ≤300 | | night_crowd | 128 | 128 | **+0** | ≤300 | | market_square | 94 | 94 | **+0** | ≤300 | | night_neon | 111 | 111 | **+0** | ≤300 | | interior (record) | 110 | 110 | **+0** | ≤350, margin **240** | Worst street view over the five bookmarks: **193 both arms**. Ruling 4 gave this round no street headroom and the round did not spend any: the clips are skeleton-only (E: tris = 0, meshes = 0, materials = 0, images = 0 on all six groups), so the ONLY mechanism by which they could move a draw count is repositioning a ped onto a bench or a wall — and that is bounded by the unchanged near-cap of 24 rigs, each of which is already one draw. **Caveat stated rather than hidden:** 193 is the worst of these five bookmarks on this seed, not the town's global worst; B's R40 number for the street law is 291/300 at a night pose these bookmarks do not reach. The delta is the claim, and the delta is 0. ### Leak `tools/qa/r41_citizens.py` arm 6b: warm the caches with one enter/exit, then **6 more cycles into the same shop** — geometries 118 → 118 (**+0**), textures 91 → 92 (**+1**), clip bank flat at 4 groups / 32 clips. The R2 shared-resource disposal contract still holds with more clips resident: `AnimationClip`s hold no GPU resources (these groups are 0 meshes / 0 materials / 0 images), and `_disposeInner` still disposes only the clone's own `Skeleton`. Warm-first is deliberate — counting cycle 1 measures cold start, not retention, and doing so is what made this arm read `+10` once. ### Gates added (both new files, no collision) - `node tools/qa/r41_postures.mjs` — zero deps, ~40 ms. Manifest resolution (every pool id resolves with the right category and group, **CONTROL:** a bogus id fails the same resolver) · determinism over 1 000 citizens in two module instances (**CONTROL:** a different seed differs) · stream isolation (the 6 R41 keys collide with none of the 12 pre-R41 keys; **CONTROL:** all 18 streams produce different draws on the same id) · pool spread (all 10 idles, 90–110 each per 1 000) · loop safety (**CONTROL:** the ping-pong class is non-empty). - `tools/.venv/bin/python tools/qa/r41_citizens.py` — 7 arms in fresh headless contexts against a no-store server: boot ledger · lazy loading · determinism incl. the **2 s forced-stall** control · draw table both arms · `?noassets=1` + `?classic=1` · bench binding + control · leak · gig widening. - `tools/.venv/bin/python tools/qa/r41_shots.py` — the two acceptance shots, camera chosen by measurement (bench station with the most shop doors around it, occlusion-raycast, then held while the crowd fills in) and every figure measured for stature. **→ F: three lines for `qa.sh`.** All three exit 0/1 and are deterministic. ### The stature law, and why the first version of it was wrong The shot harness first checked an absolute seated band `[0.9, 1.5]` m (R16's, written for the drummer on `sit.glb`) and called a 1.32 m leaner a failure. It is not one: the library's four wall leans take 15–25% off a standing crown **by construction** (you are leaning back), and its four sits run from bolt-upright-in-a-chair to slumped. A fixed band is the wrong instrument. What R10 forbids is a GIANT and what R16 forbids is a FOLD (keeping `Hips.quaternion` lays the body flat, head at hip height). So the gate now reports stature with its ratio to that citizen's own nominal height and fails on **stature > 2.0 m**, **stature < 55% of nominal**, or a nominal height outside the seeded [1.4, 2.0]. Measured today across both shots: leans 93%, sits 75–86%, browsers/keepers 93–101%, walkers 96–99% of nominal. No giant, no fold. ### Shots - `docs/shots/laneD/r41_street_postures.jpg` (+ `.txt` sidecar with the human-sized line) — seed 20261990, camera (11.6, 58.5) yaw −1.890. **8 near-tier rigs, 3 distinct clips**: a hi-vis worker seated on a real Lane B bench, a comical ped walking centre-frame, six more walkers (a third of them on `walk_shopping_bag`). 126 draws / 300. - `docs/shots/laneD/r41_browse_interior.jpg` (+ `.txt`) — The Op Shop, 3 browse points: a browser at the clothes rack on `browse_hold_idle` and the keeper at the counter on `idle_happy_1`. 69/350. Framing note for whoever re-shoots: "inside the frustum" is not "in the picture". Three runs reported 3–6 rigs in shot while the JPEG showed footpath and a gum-tree billboard. `r41_shots.py` now ray-tests every candidate figure from the lens and counts only the ones that can actually be seen, and raycasts the camera pose itself (an unchecked "best score" pose put the lens inside a building twice — high score, grey wall). ### Goldens `node web/js/citygen/selfcheck.js` → **157 647/157 647, fingerprint `0x5f76e76`**, unmoved. No citygen file was touched. ### Filed to R42 1. **The turn state.** `turn_left_90` / `turn_right_90` / `turn_walk_180` / `turn_in_place` / `walk_to_stand` are shipped and unused because the sim turns instantly at a node. A short turn state at `_advance`'s node branch (hold the ped for the clip's duration, blend the heading) makes five clips real and would kill the remaining tell that these are waypoint walkers. 2. **`social.glb`.** 8 clips, 896 KB, needs a partner-pairing pass: two peds who arrive at the same loiter within N metres take `social_shake_1`/`_2` (E flagged the pair) or the conversation loop facing each other. The manifest's `pair` field is the whole contract. 3. **`benchStops(plan)`** from Lane B (above) retires D's mirror. 4. **`browse_hold_walk` / `browse_hold_turn_*`** want a browser who walks the shop floor rather than standing at a fixed point — Lane C's browse points would need a small path, not a pose. --- ## ROUND 40 (§40.5) — THE DOOR POINTS AND THE FOOTPATH: the kerb clamp + the gate Summary in `D-progress.md` §Round 40; this is the measured detail a later lane will want. ### Today's numbers (all re-measured 2026-08-04, `tools/qa/door_footpath_check.mjs`) R39 shipped **0 door points on a footpath corpus-wide** (0/493 · 0/72 · 0/139 · 0/30 — the `(−sin,−cos)` sign at `index.html:428`-era, B's lineage table). B's R39 `+sin/+cos` fix, measured on today's tree, before any D change: | town | strict footpath | walkable front | residue classes (walkable) | |---|---|---|---| | synthetic@20261990 | 447/493 · 90.7% | 473 · 95.9% | shop:carriageway 3 · stall:carriageway 8 · stall:in-a-lot 6 · anchor:off-verge 1 · shop:in-a-lot 2 | | katoomba_real | 71/72 · 98.6% | 71 · 98.6% | shop:off-verge 1 (0.85 m) | | fitzroy_real | 138/139 · 99.3% | 138 · 99.3% | shop:off-verge 1 (0.85 m) | | bowral_real | 28/30 · 93.3% | 28 · 93.3% | shop:off-verge 2 (0.85 m) | After D's clamp (`door_snap.js` in `sim.setShops`): **katoomba 72/72, fitzroy 139/139, bowral 30/30 — 100% on both measures; synthetic unchanged at 473/493 walkable (95.9%)**. Corpus transitions: fixes 4, breaks 0, moves 7 points total, max move 5.41 m (synthetic) / 0.85 m (real). ### The two residue species, and why they got different answers 1. **The 0.85 m kerb miss (fixed here).** `plan_osm.js:466-468` sets a real-town lot back at `max(KERB, roadWidth(pe)/2 + FOOTPATH)` from the centreline. On corridors where that exceeds `edge.width/2`, the facade stands past the corridor edge, so the shell's door point (`d/2 + 0.6`) lands just OUTSIDE `vergeBand(e)` — off the walkable strip by under a metre. The ped steered to the gutter shadow, not the footpath. `snapDoorToFootpath` clamps the point into `[kerb+0.45, corridorEdge−0.45]` of its nearest footpath-bearing edge. Registry's published split, the sim's own edges — no lots, no plan, no rng, so "sim stays graph-only" holds and same seed → same doors. 2. **The plaza fronts (measured, deliberately NOT dragged).** The synthetic's big strict-footpath residue is 36 market stalls + arcade shops, 50–73 m from every footpath-bearing edge, fronting the market square / covered arcade. An UNCAPPED snap "passes" them (measured: 488/493 strict) by moving doors up to **59.85 m** onto a main street they do not front — peds would duck into a stall by vanishing at a point three shops away. That is the R8 defect wearing a green tick. The 6 m cap exists to forbid exactly this; the gate scores plaza ground as a valid FRONT instead. ### For whoever owns the derivation next (F's shell / B was last in) The remaining 20 synthetic misses are artefacts of the **fixed nudge** at `web/index.html:433-437`: `door = lot + (sin ry, cos ry) · (d/2 + 0.6)` assumes 0.6 m in front of the facade midline is clear public ground. On 4.5 m stalls packed in the square, that lands inside the neighbouring stall (6 cases) or past the square's kerb line (8); on two synthetic corner shops it clips the cross street's carriageway. If a round ever needs those: derive the point from `buildShopfront`'s own `doorRect` (buildings.js:410-416, :583-597 — the actual door, not the lot midline) instead of the nudge. I did not touch it — not my file, and the gate passes without it. ### The gate (`tools/qa/door_footpath_check.mjs`) — what keeps this honest - Imports the SHIPPED `door_snap.js` (the sim's code path, not a copy) and replicates the shell's raw derivation; measures both, so a regression in either shows as its own number. - **CONTROL:** the back point — the exact R8→R39 defect — must score under the floor on every town (reads 0.4–13.3%). A heading has no falsifiable arm inside the file that computes it (B's R39 keeper insight), so this gate carries one. - **FIX-ONLY:** the clamp may break 0 previously-passing points, ever. - Deterministic (two runs byte-identical), ~2 s, zero deps, exit 0/1. **→ F: one line in qa.sh, please — `node tools/qa/door_footpath_check.mjs` with the other node gates.** - Goldens: selfcheck 157,647/157,647, fingerprint `0x5f76e76`, run today after the change. ### Shot `docs/shots/laneD/r40_front_duckin.jpg` — near-tier rig frozen 3.2 m from the VIDEO BARN door, mid-stride ON the footpath under the verandah (staged via the sim's own `_beginVisit` + `setPaused` at the threshold; the steering, the stored door point and the entry radius are all shipped code). --- ## ROUND 36 (wave 2.5) — CLASSIC KEEPS ITS PEOPLE: the DJ bodies gated out of `?classic` **The ruling (Fable, on F's §36.4 held warn):** `b339402` (July 20) appended the 5 trellis'd DJ bodies (`woman_dj_01/02/03`, `dj_techno_01`, `dj_phrtt_01`) to `PED_NAMES.normal` UNCONDITIONALLY — so `?classic=1` fetched them and its seeded crowd pool widened 17→22, six epochs after the covenant froze. Ruled option (b): the five gate OUT of classic's pool, mirroring the sit/look/dance `!CLASSIC` clip gates. Classic is the v2 town INCLUDING ITS PEOPLE; the DJs walk every other boot. **Mechanism (D's call — one file, `rigs.js`, per the wave-2.5 pathspec law):** - `PED_NAMES.normal` is restored to the covenanted **base 17** (exact pre-July-20 list, order preserved). The five move to a new `PED_NAMES.djs` extension list. - `loadPedFleet` gains **`djs = dance`** — a new opt whose DEFAULT tracks the existing `dance` flag. The shell already passes `dance: !CLASSIC` (index.html:194), so the classic-ness signal arrives with ZERO shell edits: classic ⇒ `dance:false` ⇒ `djs:false` ⇒ no DJ fetch, 17-pool; default ⇒ `djs:true` ⇒ all 22. Separable for callers wanting clips without bodies (or vice versa). - The roster is built `djs ? [...normal, ...djs] : normal` — the five APPEND, so no base slot is renumbered in either mode: the default-boot 22-list is byte-identical to the pre-ruling list, and classic's 17-list is byte-identical to pre-b339402. `pickRig` maps `r01 * 997 % pool.length`, so pool LENGTH is crowd identity — 17 restores the exact pre-July-20 mapping, not merely the width. - Known side effect (accepted, noted): `citizens_test.html` calls `loadPedFleet` with no opts ⇒ `dance:false` ⇒ its dev-harness roster shows 17+2, not 22+2. It is not a covenanted surface; a future D round can pass `{djs:true}` there if the harness wants the five back. **Measured (fresh contexts each, port-isolated no-store servers :8971 current / :8972 pre-b339402 shadow tree, seed 20261990):** | check | `?classic=1` (gated) | default boot (gated) | |---|---|---| | DJ-GLB fetches | **0 of 5** (21 ped-GLB fetches total = 17+2 bodies + walk+idle) | **5 of 5** | | pool | **17 normal** / 2 comical / 19 all | **22 normal** / 24 all | | crowd census | 129 active, 129 named, **19 distinct — all base-17 ∪ comical, zero DJ** | 153 named street peds incl. **37 DJ-bodied** (techno 13 / w01 8 / phrtt 7 / w03 6 / w02 3) | | gig crowd (F's organic proof) | n/a (gig layer absent) | **preserved** — pinned seed's venue crew (12) includes `dj_phrtt_01` + `woman_dj_02` in the crowd | | console errors | **0** | **0** | **The pre-July-20 identity check (the R29 lesson: measure, don't assume):** booted `?classic=1` on a shadow tree whose `rigs.js` is `b339402^` (everything else current), same seed, same server law. Roster arrays identical; the full 129-citizen census — every `(id, pedRoll, pedIndex, pedName)` tuple — is **byte-identical** to the gated tree's classic boot. Classic's crowd is not just 17-wide; it is the SAME crowd, ped for ped, as before July 20. (This held by construction — the five were appended, so gating them off restores both list content and length — but it is now held by measurement too.) ## 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: **"if the measured win is <20k tris in practice, KILL the item with the measurement and it leaves the backlog forever."** It does not survive. **E: do not build the `_lo` variants — that work is now unnecessary.*** ### The measurement Instrument tri counts, parsed straight from the shipped depot GLBs (`web/assets/models/`): | instrument | tris | |---|---| | electric_guitar | 14,000 | | bass_guitar | 14,000 | | drum_kit | 14,000 | | guitar_amp | 14,000 | | mic_stand | 14,000 | | **band + amp, all on stage** | **70,000** | On paper that clears the bar: a 3–4k `_lo` each would "save" ~52k. **But the rule says *in practice*, and the brief named the reason — "interiors are close-quarters."** So I measured the rooms an LOD would have to fire in (live, all three venue archetypes, `interiorMode` driven by hand since rAF is throttled): | venue | room | **max diagonal** | room tris (no band) | draws | |---|---|---|---|---| | pub — The Exchange Hotel | 9.7 × 9.7 m | 13.7 m | 12,522 | 38 | | band_room — The Vinyl Shed | 8.9 × 9.6 m | 13.0 m | 11,699 | 35 | | rsl — Wangaratta RSL (biggest) | 12 × 9.4 m | **15.2 m** | 15,115 | 38 | ### The verdict: practical win = **0 tris**. KILLED. **A player can never be more than 15.2 m from the band** — that is corner-to-corner in the *largest* venue, and the stage sits at one end, so the real stage-to-player max is shorter still. An LOD is a *distance* trade: it pays when the subject is small on screen. Inside a ≤15.2 m room the band is never small — it is the subject of the gig, and of F's tour money shot. Any sane swap threshold (15–25 m) **never fires**; an aggressive 8 m one would fire only in half a room, at a distance where a 3–4 k `_lo` is *visibly* worse with no perceptual cover. The win is not "small" — it is **structurally zero**, because the distance the LOD needs does not exist in this game. **Per the wind-sway rule: killed, off the backlog forever.** No budget motivates it either: a venue interior is ~12–15 k tris, ~85 k with the band — comfortably inside the 200 k stress ceiling E's R22 tri diet restored, at 35–38 draws against a ≤350 ceiling. ### Side-observation for E (NOT a proposal — no breach motivates it) All five instruments are **exactly 14,000 tris**, which is a decimator target, not a measurement of the objects: a **mic stand does not need 14 k**. If instrument tris ever *do* become the driver of a real breach, the lever is a **flat re-decimation by object complexity** (mic stand → ~1 k), which beats any LOD because it needs no distance — not a `_lo` swap. Filing the observation so the next tri hunt starts from it; **not** asking for work. --- ## ROUND 23 — v5.0-alpha: the GIG_RANGE chunk-neighbour fix (ledger #6, the carried v4.x item) *PROCITY-D, 2026-07-16. My own filed finding, finally fixed — post-release, as Fable scheduled. Hot path, so the R18-style before/after is the deliverable. `selfcheck` **ALL GREEN 161300/161300**, `0x3fa36874` frozen (runtime-only change — no plan, no golden).* ### The finding grew while it waited: 8.7 % → 42.6 % I filed this at 8.7 % in R20, measured at katoomba's isolated pub. **A's R21 cluster bias made it worse**: venues now sit *inside* the retail cluster, where peds converge from every side rather than trickling along one street — so far more in-range peds are across a chunk edge. Re-measured on the relocated venue: **42.6 %** of in-gig-range ped-samples (5094 of 11971) were chunk-blind. Fixing it mattered more in R23 than when I filed it. ### The override: the brief contradicts itself, and the measurement broke the tie The brief said *"extend `_nearestOpenShop` to the ped's chunk + 8 neighbours"* **and** *"byte-identical flags-off"*. **I built the literal version first and measured it: those two cannot both be true.** On the synthetic town (exactly what `?classic=1` boots, gig layer absent), a full 3×3 scan sourced **127 of 855 finds — 14.9 % — from neighbour chunks**: finds v2 never made. The frozen v2 crowd would start shopping ~15 % more. That is a covenant breach, in a clause of the same brief that asked for it. **So the fix is scoped to the GIG path** — which is also what I actually filed (the finding was always `GIG_RANGE`-specific): - **Ordinary shops → own chunk only.** v2 semantics, frozen. A shopfront is noticed from its own block. - **Gig venues → the full 34 m radius, honest across chunk edges.** The gig is the "follow the sound" pull that is *supposed* to reach further — that is the entire reason `GIG_RANGE` (34) > `PATRON_RANGE` (18). - **No gig on → `_gigVenues` is empty → the neighbour loop is skipped entirely.** `?classic` and every flags-off boot take the *identical code path and identical cost* to v2. **Byte-identical by construction**, not by measurement luck. Both ranges are < `CHUNK` (64), so a 3×3 sweep is *exact*, not an approximation; a fixed `dz/dx` order keeps exact-distance ties deterministic, as the old single-list scan was. ### Before / after | proof | before | after | |---|---|---| | **cross-chunk in-range peds that see the gig** — katoomba | **0 % by construction**¹ | **4/4 = 100 %** | | — fitzroy (densest, worst case) | **0 % by construction**¹ | **11/11 = 100 %** | | same-chunk peds (must not change) | 100 % | 100 % | | **classic / flags-off: finds sourced from a neighbour** | 0 (v2) | **0** (127 under the rejected full scan) | | determinism (2 sims, same seed) | identical | **identical** (identity sets byte-equal) | | NaN — katoomba / fitzroy / synthetic | 0 | **0** | | sim cost, katoomba @14:00, 236 peds | 0.056 ms/frame | **0.057 ms/frame (+2 %)** | | lookup cost/call — no gig on | 0.58 µs | **0.58 µs (loop skipped)** | | lookup cost/call — gig on | 0.58 µs | **1.3–2.7 µs** | ¹ *not a small-sample claim: v2 read `shopsByChunk.get(ownChunk)` only, so a venue in another chunk was never in the scanned list and could not be returned. 0 % is structural.* The lookup is 2–4× dearer **while a gig is on**, but it only fires every `PATRON_STRIDE` (10 m walked), not per frame — hence **+2 % of a 0.056 ms frame cost**. Against a 16.7 ms budget that is 0.006 %. Fitzroy at 376 peds stayed clean. Minor known inefficiency: with a gig *scheduled but closed* (e.g. 14:00) the neighbour loop still runs for nothing (0.58 → 1.3 µs) — `_gigVenues` holds ids, so testing openness would need the lookup it's trying to avoid. Not worth it at +2 %; noted rather than hidden. ### Handshakes - **D → F (gate):** the fix is **runtime-only** — no plan, no golden, no flag, no fetch. `?classic=1` takes the identical code path to v2 (loop skipped), so the classic regression is untouched **by construction**. Selfcheck green at 161300. No D-side blocker for `v5.0-alpha`. - **Carried (deliberate, documented):** `PATRON_RANGE` keeps v2's own chunk-blindness — an ordinary shop 10 m away across a chunk edge still goes unnoticed. That is now a *frozen v2 quirk*, not an oversight: fixing it costs the classic covenant, and the covenant wins. Revisit only if the covenant is ever relaxed. - **Still unowned (unchanged):** R20's main-street relocation half (heroes 0–19 % of the densest cluster). --- ## ROUND 22 — v4.0 THE EPOCH CLOSE: citizens at pack scale (ledger #5) *PROCITY-D, 2026-07-16. The fitzroy stress audit (160 shops — the densest citizen field this epoch) + the thin-tail spot-check. Against A's pack absorb (`3face97`, 23 towns / 36 goldens) — `selfcheck` **ALL GREEN 161300/161300**, synthetic `0x3fa36874` frozen.* ### Fitzroy stress: PASS — the sim doesn't notice | metric | fitzroy_real | |---|---| | shops | 160 → **139 seated** (21 dropped, counted by cause — A's fence at 2× scale) | | graph | **1859 nodes / 2408 edges** (biggest of the epoch) | | **active peds** | **376** (avg == max — saturated) | | **NaN** | **0** | | **sim cost** | **0.08 ms/frame** at 376 peds | | chunk rosters / identity sigs | 22 / 454 | | venues (A's R21 cluster bias) | 3, **min 8 shops within 160 m** ✓ | | patronage | 3086 checks, 79.6 % null, **103 visits** | | plan gen | 194 ms | **Nothing needed touching at 2× scale.** Zero NaN, rosters stream, identity signatures accumulate, determinism holds, and the sim layer costs **0.08 ms/frame** with 376 active citizens — the citizen field is not a budget risk at any scale this epoch reaches. A's cluster bias survives the mecca (min 8 ≥ the ≥3 bar). ### Thin tail: "does 12 shops still read alive?" — **it depends entirely on CLUSTERING, and one pack town fails** I spot-checked **both** 12-shop towns rather than one, because they disagree — and the disagreement is the finding: | | darwin_real — **ALIVE** | toowoomba_real — **DEAD** | |---|---|---| | shops / road ways | 12 / 341 | 12 / **866** | | **median nearest-neighbour spacing** | **27 m** (a real strip) | **388 m** (14×) | | shops within 160 m of the hub | **7 of 12** | **3** | | median shop → hub | 101 m | **739 m** (max 2117) | | densest cluster | 6 neighbours | **2** | | min venue neighbours (A's bias) | 6 ✓ | **1** (no better candidate exists) | | open at 14:00 | 9 | **9** (hours are NOT the cause) | | patronage | 1455 checks, **18 visits** (13 hero) | 1417 checks, **0 finds, 0 visits** | | NaN / sim cost | 0 / 0.05 ms | 0 / 0.05 ms | **Both towns pass `MIN_TOWN_SHOPS`. One has a high street; the other doesn't.** Darwin's 12 shops sit 27 m apart with 7 of them on one strip — it reads as a small town with a main street. Toowoomba's 12 sit ~388 m apart across a **4.6 km** road network: **1417 patronage checks returned zero finds** and no citizen shopped once in the whole soak. It isn't a shopping town — it's a road network with occasional shops. (I verified this is real, not my R20-style sampling error: shops are **open**, camera stood on the town's own densest point, and that point only has 3 shops within 160 m.) **→ FILED for E / Fable (curation, not code): the floor measures the wrong thing.** `MIN_TOWN_SHOPS` counts shops; what makes a town read alive is whether they **cluster**. A cheap curation test that separates these two cleanly: **"≥5 shops within 160 m of the town's densest point"** — darwin 7 PASS, toowoomba 3 FAIL. Toowoomba is in the v4.0 pack today and has no main street to walk. Cull it, or accept it as a known thin-tail (it costs nothing — 0 NaN, 0.05 ms/frame — it just has nothing to do). **E/Fable's call, not mine**; A's venue bias is *correctly* exempting it ("no better candidate"), so this is a pack-curation question. ### Handshakes - **D → F (gate):** citizens at pack scale **GREEN**. 23-town pack: fitzroy (160 shops / 376 peds) clean at 0 NaN and 0.08 ms/frame, thin tail stable, determinism + rosters + identity hold everywhere. **No D-side blocker for `v4.0`.** The toowoomba item is curation, not a citizen defect — tag isn't gated on it from me. - **Carried to v4.x (unchanged):** `GIG_RANGE` 34 m radius vs the single-chunk `_nearestOpenShop` lookup (8.7 % of in-range peds chunk-blind; fix = chunk + 8 neighbours). - **Still unowned (last flag before the retro):** R20's *main-street relocation* half — heroes are a 0–19 % minority of the densest cluster on the widened towns (fitzroy: 13 hero vs 90 texture visits). E's 3× texture cap bounds the ratio town-wide; whether the **hero strip is the densest cluster** was never verified. If it matters for v4.x, it's a one-call re-run of this harness. --- ## ROUND 21 — v4.0-beta CLOSE: relocation verify (ledger #4) *PROCITY-D, 2026-07-16. Verifying A's venue cluster-adjacency bias (`110c828`), which Fable assigned from my R20 relocation finding. `selfcheck` **ALL GREEN 154141/154141** against A's re-pinned goldens.* ### Verdict: A's bias PASSES acceptance on all five — my finding is closed Acceptance was "no venue sits with <3 shops in its 160 m radius." Shops within 160 m @ distance-to-retail-hub, before (R20) → after (R21): | town | min shops in 160 m | before → after | |---|---|---| | katoomba_real | **9** PASS | **pub 1→9 @1007→466 m** · rsl 10→12 @161→148 m · band_room 7→10 @181→161 m | | newtown_real | **5** PASS | **rsl 1→11 @2228→29 m** · band_room 0→7 @2433→124 m · pub 0→5 @2190→714 m | | bendigo_real | **5** PASS | rsl 0→10 @451→86 m · band_room 0→10 @1157→96 m · pub 1→5 @504→316 m | | castlemaine_real | **4** PASS | pub 1→5 @223→182 m · band_room 6→6 @0 m · rsl 4→4 @155 m | | fremantle_real | **3** PASS* | band_room 2→8 @612→309 m · *pub 1→3 @969→**1352 m*** · *rsl 2→3 @561→**1292 m*** | **My R20 headline is fixed**: katoomba's pub — "a gig with no passing crowd", 1 shop at 1007 m — now sits at **466 m with 9 shops**. Newtown is the dramatic one: all three venues were **2.2–2.4 km out with 0–1 shops**; the rsl now sits **29 m from the hub with 11**. Castlemaine's floor did NOT need an exemption (min 4). ### The crowd followed — katoomba's pub, same density/seed/21:00 | | R20 (pre-bias) | **R21 (post-bias)** | |---|---|---| | passing crowd (maxActive) | 34 | **66** (2×) | | patron checks | 226 | **442** (2×) | | inside frames | 1042 | **1424** | | tonightRoster | 2 | **3** | | NaN | 0 | **0** | The "gig with no passing crowd" observation is retired. (null % stays ~96 % at 21:00 — retail is shut at night **by design**; that's my own R20 correction, not a leak.) ### Two readings that need care (so nobody mis-reads my own numbers) - ***fremantle is the weakest pass** (\*): its pub/rsl clear the ≥3 bar but moved **further** from the primary hub (969→1352 m, 561→1292 m) — A's bias optimises **local cluster adjacency**, and fremantle's venues found a *secondary* cluster. It satisfies the letter of acceptance and the crowd is healthy in practice (pub: roster **6**, inside **2741**, maxActive **72**), so I'm **not** calling it a miss — just naming it, since "3 shops at 1.3 km" is a thinner high street than katoomba's "9 shops at 466 m". A's call if they want it tighter. - **venue-win % DROPPING is not a regression.** newtown's rsl went 100 %→**21 %**, fremantle's pub 100 %→62 %. That metric asks "when a check finds a shop, is it the venue?" — it was only ever 100 % because the venue was *alone*. Post-bias the venue has real neighbours competing for finds, while total crowd went **up** (newtown rsl: roster 3, inside 1873, maxActive 55). Isolation flatters that number; ignore it post-bias. ### Handshakes - **D → F (gate):** relocation verify **GREEN**. All five towns pass A's acceptance, the pub finding is closed, 0 NaN at every venue, rosters populate. No D-side gate concern for `v4.0-beta`. - **FYI F:** `selfcheck` scans **every** `web/assets/towns/*.json` on disk, including E's *untracked* pack WIP. I saw it transiently RED mid-round (`newcastle_real`: no open-late shop) purely from E's in-flight files, then green again minutes later at 154141 checks. If you gate while E is mid-fetch you may catch a false red — E's pack is non-gating, so check whether a failure is a pack town before believing it. - **Still filed for v4.x (agreed, not a mid-beta change):** `GIG_RANGE` is a 34 m radius but `_nearestOpenShop` searches only the ped's own 64 m chunk → 8.7 % of in-range peds are chunk-blind. Fix = chunk + 8 neighbours. - **Unowned from R20:** the *main-street* relocation half (heroes 0–19 % of the densest cluster; castlemaine's main street has zero secondhand shops). E's R20 SUBTLE guardrails (texture capped at 3× heroes) address the ratio town-wide; whether the **hero strip is again the densest cluster** is unverified. Not in my R21 ledger — flagging it so it doesn't get lost at the epoch close. --- ## ROUND 20 — v4.0-beta THE FIVE TOWNS: citizens at density (ledger #7) *PROCITY-D, 2026-07-16. Part 1 of 2: the **pre-density baseline** — the gig crowd's behaviour on all five real towns' venues (F's district gate owns placement; I own behaviour). This half is unblocked; the density re-audit waits on E's widened caches → A's absorb. Deliberately measured BEFORE the widening so the after-table is a real A/B.* ### The gig crowd is behaviourally CORRECT on all five real towns Per town: stream sim on the real graph, `setGig` on its first venue, 21:00, camera at the venue, ~1400 frames. Every town generates 3 venues (pub/band_room/rsl — charter's 2–4) on night hours. | town | shops | door chunks | patron checks | **null %** | hit venue | **venue win %** | roster | NaN | |---|---|---|---|---|---|---|---|---| | katoomba_real | 19 | ~5 | 703 | 97.3 | 14 | 74 | 2 | **0** | | newtown_real | 18 | 15 | 267 | 95.5 | 12 | **100** | 4 | **0** | | fremantle_real | 20 | 16 | 429 | 94.9 | 22 | **100** | 7 | **0** | | bendigo_real | 9 | 9 | 343 | 97.7 | 8 | **100** | 3 | **0** | | castlemaine_real | 6 | 5 | 364 | 95.9 | 6 | 40 | 4 | **0** | - **0 NaN on all five.** The gig crowd is numerically safe on every real graph. - **`tonightRoster` populates on all five** (2–7) — R14 identity continuity holds on real bones. - **Venue-win % is the surge working**: whenever the patronage check finds *any* shop, the gig venue beats ordinary shopfronts (GIG_RANGE/gigBest preference) — 100% on three towns, 74% on katoomba. **castlemaine's 40% is not a leak**: at the 6-shop floor, 3 of its 6 shops ARE venues, so the "other shop" hits are mostly *other venues*. A quirk of the sparse floor, not the mechanism. ### CORRECTION to my own first read of that null % — I misattributed it (measurement wins) My first pass called the 94.9–97.7 % null rate "the crowd is sparsity-throttled" and made it the headline before/after metric. **Both halves of that were wrong, and I'm correcting the record:** 1. **It was measured at gig hours.** At 21:00 only **4 of 69** katoomba shops are open — retail is shut *by design*. A high null rate at 21:00 is **correct night behaviour**, not sparsity. The gig IS the nightlife. 2. **It was sampled at each town's first venue** (`shops.find(s => s.venue)`). For katoomba that is the **pub, which sits 1,000 m from the retail cluster with 1 neighbour** — I had parked the camera on the one isolated venue in town. Measured there, the daytime null was 99.4 % with **0 visits**; measured on the actual main street, the same town same seed gives **77.2 % and 64 visits**. The number was an artefact of where I stood. The gig-crowd table above still **stands** — it measures the *mechanism* (0 NaN, rosters populate, venue-win %), which is location-independent. Only the sparsity interpretation was bad. ### The real density A/B — the main street, retail hours (14:00), same seed, like-for-like Camera on the **densest retail cluster** (the actual main street), pre-widening cache (from git) vs E's widened cache, 2,000 frames each: | main street @14:00 | PRE (20 shops) | **POST (80 shops)** | |---|---|---| | hub density (shops within 160 m) | 8 | **25** | | null % | 90.2 | **77.2** | | **total visits** | 17 | **64** (3.8×) | | **hero visits** (opshop/book/record/video/pawn/toy) | 17 | **19** | | texture visits (milkbar/dept/stall) | 0 | 45 | | hero share of visits | 100 % | **30 %** | | hero share of shops | 84 % | 26 % | | NaN | 0 | 0 | **The widening lands John's directive, measured from the crowd side.** The secondhand heroes did **not** lose footfall — they went **17 → 19 visits**. The 45 texture visits are added *on top*, not taken from them. The street is **3.8× busier** while the heroes stay **over-indexed** (26 % of shops, 30 % of visits). That is precisely "the player should notice the town feels more alive, not that the game changed." ### Filed, not fixed - **Venue placement at density (A/F):** katoomba's 3 venues — rsl **13** shops within 160 m, band_room **10**, **pub 1** (1,000 m from the cluster). A gig at the pub has almost no passing retail crowd. It still draws a roster (peds stream everywhere), so it's a *quality* observation, not a break. Placement is A's/F's lane. - **`GIG_RANGE` vs the chunk lookup (mine):** GIG_RANGE is a 34 m *radius*, but `_nearestOpenShop` searches only the ped's own 64 m chunk, so an in-range ped across a chunk edge is blind to the gig — **8.7 %** of in-range ped-samples (852/9,740). Real but small; a hot-path change mid-beta isn't worth it. Fix (chunk + 8 neighbours) is a v4.x candidate. ### FINAL — post A's absorb (`5d29863`), all five towns at density A's absorb landed (capacity widened, goldens pinned, `selfcheck` **51866/51866**). Re-ran everything against the absorbed graph. **The katoomba numbers barely moved** (null 77.2→78.1, visits 64→62, hero share 30→29 %) — the pre-absorb read was already sound. | town | cache→seated | hub | null % | visits | hero | texture | hero share | NaN | maxActive | rosters | sigs | |---|---|---|---|---|---|---|---|---|---|---|---| | katoomba_real | 80→72 (8) | 26 | 78.1 | 62 | 18 | 44 | 29 % | **0** | 217 | 15 | 262 | | fremantle_real | 80→79 (1) | 29 | 71.1 | 116 | 23 | 93 | 20 % | **0** | 401 | 24 | 483 | | newtown_real | 72→67 (5) | 12 | 85.9 | 44 | 6 | 38 | 14 % | **0** | 268 | 19 | 324 | | bendigo_real | 36→35 (1) | 14 | 87.2 | 45 | 1 | 44 | 2 % | **0** | 358 | 21 | 432 | | castlemaine_real | 24→23 (1) | 6 | 94.9 | 9 | 0 | 9 | 0 % | **0** | 295 | 20 | 359 | **Sim verdict: zero changes needed at 4× density.** 0 NaN on all five, chunk rosters stream (15–24), identity signatures accumulate (262–483), patronage/loiter/bench-sit all fire, up to **401 active peds** with no degradation. Determinism unchanged (identity is graph+seed keyed; shops don't touch it). **D→F: green.** ### THE FINDING → for E / C / Fable: the widening MOVED the main street off the secondhand strip The hero share falling (29→0 %) is **not** a behaviour bug — patronage is type-agnostic, so visits track the *local* shop mix exactly. The mix is the story. Main street = the densest shop cluster (what "walk the main street" means to a player): | town | main-st shops | **heroes on main st** | hero % of main st | town-wide hero % | **hub moved** | |---|---|---|---|---|---| | katoomba | 9 → **27** | **9 (100 %) → 5** | 100 % → **19 %** | 25 % | **308 m** | | fremantle | — → 30 | 5 | **17 %** | 25 % | — | | newtown | — → 13 | 2 | **15 %** | 27 % | — | | bendigo | — → 15 | 1 | **7 %** | 25 % | — | | castlemaine | 3 → **7** | **1 → 0** | 33 % → **0 %** | 20 % | **434 m** | **Pre-widening the densest cluster WAS the secondhand strip** (katoomba: 9 of 9 main-street shops were heroes). Post-widening the town's centre of gravity **relocated 308–434 m onto the general-retail cluster**, where the heroes are a **0–19 % minority**. Castlemaine's main street now has **zero** secondhand shops (0 of 4 heroes within 160 m of its hub; median hero is 463 m away). The heroes weren't drowned *in place* — they were **demoted**: the liveliest street in town is no longer theirs. - **The good half stands:** heroes kept their absolute footfall (katoomba 17 → 18 visits) and the street is **3.8× busier**. The widening works; it just overshot the dial. - **This is exactly the case John's directive pre-authorised:** *"if a real town's general retail would drown the secondhand strip visually, E may seed-subsample the texture classes (count it, like every drop)."* My read (measurement, not a ruling — E/C/Fable own it): **subsample texture until the secondhand strip is again the town's densest cluster.** The sparse towns need it most (bendigo 7 %, castlemaine 0 %); katoomba at 19 % is borderline. A hero-adjacency bias on texture seating would be the alternative lever (A's). ### Also filed — venue placement at density (A / F) Venues are seeded from the shop pool, and at real density they land **off** the retail hub: | town | venue distance to hub (shops within 160 m) | |---|---| | katoomba | pub **1007 m (1)** · rsl 161 m (10) · band_room 181 m (7) | | fremantle | pub 969 m (1) · rsl 561 m (2) · band_room 612 m (2) | | **newtown** | **all three 2190–2433 m (0–1)** | | bendigo | pub 504 m (1) · rsl 451 m (0) · band_room 1157 m (0) | | castlemaine | band_room **0 m (6)** · rsl 155 m (4) · pub 223 m (1) | A gig 2.4 km from the high street has no passing retail trade. A's `5d29863` "pub facade bias" fixed frontage, not town position — katoomba's pub is still 1007 m out with 1 neighbour. Crowd still forms (peds stream with the camera), so it's a **quality** call, not a break — but "venue placement still lands" is in A's ledger and F's district gate, so it's theirs. Castlemaine shows the good case (band_room *on* the hub). --- ## ROUND 19 — v4.0-alpha CLOSE: the fragmentation re-measure (ledger #3) *PROCITY-D, 2026-07-16. A resolved my R18 finding (commit `6fc4b40`: junction-protected DP + bridge-joins + shopless-island culling, katoomba golden pinned). This is the post-fix re-measure — same harness, same seed. **Verdict: the ruling landed clean — the fragmentation is fully resolved and the sim stays untouched.*** ### Before → after on the real Katoomba graph | metric | R18 (my finding) | **R19 (post-fix)** | |---|---|---| | connected components | 105 | **1** | | street-metres in the main net | 58.2 % | **100 %** | | shops stranded off-main | 2 | **0** — 1 bridged to main, 1 dropped past `SEAT_MAX` **and counted** (`norm.dropped`, plan_osm.js:319) | | **% near-crowd on tiny fragments** | ~10 % | **0 %** (no fragments left) | | nodes / edges | 872 / 799 | 830 / 966 | Shops: 20 in cache → **19 seated + 1 counted-drop, 0 silently stranded** (ruling satisfied). `selfcheck` **51670/51670 green**, katoomba real-roads golden pinned in the output-drift guard. ### The DP-collinear junction-drop mechanism is fixed at the source Re-ran R18's original failing geometry — a **dead-straight cross street through a collinear mid-junction** (the exact case B and I both hit). R18: DP flattened it, dropped the junction → **max-degree 2, disconnected.** R19: the junction is protected from DP → **degree-4 crossroads forms, 1 component.** Fixed. ### Sim confirm on the fixed (bridged) graph — still zero changes Fresh stream sim, real Katoomba, drove the shop blocks: **0 NaN over 1,303,200 samples**, footpaths perfect (0 off-footpath, maxDist 12.9 m), 312 active peds, loiter (251) + **bench-sit (77)** fire, and **patronage rose 24→41 visits** (peds now reach shops across the connected graph). `fragmentPedFraction: 0`. The sim needed nothing — exactly as R18 predicted (a single component is strictly easier than 105). ### Handshake - **D → F:** fragmentation verdict **post-fix is GREEN** — real Katoomba is one connected component, 0 peds stranded, 0 NaN, all citizen behaviours hold, determinism intact (A's golden pins it). No D-side gate concern. Tag away. - **FYI (B/F budget):** edges rose 799→966 (junction protection + bridges) though total street-metres fell (islands culled) — flagged for the tris budget alongside the BIG_CITY fix; not a sim concern. - No D-side spawn-bias mitigation needed this epoch — A's pass left nothing to mitigate. (Beta backlog item retired.) --- ## ROUND 18 — v4.0-alpha REAL ROADS: citizens on real bones (ledger #5) *PROCITY-D, 2026-07-16. The audit: does the citizen sim behave on real (irregular) street topology? E landed `katoomba_real`'s real road geometry (484 OSM ways → 872 nodes / 799 edges) mid-audit, so this is measured on the **real Katoomba graph**, not just a proxy. In-shell: fresh `CitizenSim` built on `plan.streets`, driven headless-of-render (rAF throttled). No source changes — the finding is the verdict + the failure list.* ### Verdict: the graph contract IS the contract — the sim needs ZERO changes The sim's graph-walk is topology-agnostic by construction (seeded random walk over nodes/edges, dead-end U-turn), so real geometry should just work. It does. Proven on a **crafted irregular town** (deg-4 crossroads, 3 T-junctions, 5 dead-ends, a 30° acute fork, a cycle) AND the **real Katoomba graph** (7.2° acute junction, a 1303 m edge, 105 components): | assertion | crafted irregular | **real Katoomba** | |---|---|---| | NaN/Inf ped positions | 0 / 240k | **0 / 1,095,500** | | off-footpath samples (ped beyond its own edge band) | 0 / 240k | **0** (maxDist 12.9 m = main offset) | | edges traversed | 11 / 11 | full main network | | peds pinned at dead-ends | 0 | 0 | | loiter fires | 154 ev | **174 ev** | | **bench-sit fires (R17)** | 47 ev | **60 ev** | | patronage fires (stream mode) | 165 visits | **24 visits, 4.3k inside-frames** | | chunk streaming over non-grid | 7 live rosters / 93 active | **13 chunks / 307 active** | | determinism (2 sims, same seed) | 0/120k pos mismatch; identity set visit-order-independent | **byte-identical rerun; 1167 sigs** | Identity signature format is intact (`chunkKey#i:pedIndex:pvar:height:speed:edge:forward`), no `sit` term — my R17 `benchsit` stream doesn't perturb it. **Nothing in the sim needed touching for real roads.** ### The measured leak → FILED FOR A: the real-roads graph is fragmented `katoomba_real`'s street graph comes out in **105 disconnected components**. The main component holds **58% of street-metres (52.8 km) and 18 of 20 shops** — the core town is coherent — but **42% of street metres and 2 shops strand** on 102 islands (9 single nodes, 76 fragments of 2–4 nodes, 17 of 5–20). Live cost to citizens: **~10% of near-main crowd presence** lands on tiny fragments (aimless wandering, can't reach the main street or its shops); the 2 stranded shops get no main-crowd traffic. - **Primary cause — expected, not a bug:** on real Katoomba the median fragment is **71.5 m** from the main net → **mostly peripheral / bbox-clipped roads and genuinely-separate service ways**, which is what an OSM extract in a bounded box always contains. The main town is coherent; this is the tail. - **Secondary mechanism (corroborated with Lane B):** A's `buildRealRoads` runs Douglas–Peucker **per-way before snapping**, so a junction point that is collinear on the through-way gets **dropped**, with no mid-edge split where one way's endpoint lands on another way's segment — a near-straight through-street with a mid-way junction can lose it → the crossing way strands. I hit this **three times** crafting the test town (fixed by modelling junctions as shared way-endpoints, as OSM does); **Lane B independently hit the same DP-collinear drop** on A's `selfcheck` fixture (LANE_B_NOTES §18). On *real* Katoomba this bites only the **9 of 85** tiny fragments that sit <10 m from the main network (20 within 30 m) — the ones a snap-tolerance / endpoint-split pass could legitimately join. A **fidelity knob** (charter risk #4). - **Reconciles with Lane B:** B verified real Katoomba's **126 intersections form correctly** (= my 27 deg-4 + 99 deg-3 junction nodes) and concluded connectivity is fine — true *for the main component*. My add is the **global** measurement B didn't take: those 126 junctions live in a 480-node main component, but the full graph is still 105 components with 42 % of street-metres on islands. Both true, compatible. - **The sim degrades gracefully** — fragment peds just walk their island; **zero NaN, no crash**. This is A's graph-construction call (connect-tolerance / mid-edge split), not a sim change. A D-side mitigation (bias spawns toward the main component) is a **beta** item and needs A's connectivity decision first. ### Handshakes - **D → A:** the fragmentation measurement above (105 components, 42% street-metres + 2 shops stranded, root cause + the 9 close-fragments that could be joined). Your knob, your call. - **D → F (gate):** the sim is clean on the real graph — no D-side gate concern. Bench-sit + loiter + patronage + determinism all hold on `katoomba_real`. When you re-run the town matrix with the roads dimension, the citizen path is green from my side. - **Re-run note:** the whole audit is parameterized on `plan.streets`; it re-runs on any town's real graph in one call (used it on both the crafted town and real Katoomba this round). --- ## 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.