PROCITY/D-progress.md
m3ultra 78f49f7113 Lane D R41 §41.3: the town stops walking — 99.3% to 78.1%, at zero draws
THE ROUND IN ONE MEASUREMENT (12 samples x 146 active, ?clips=0 vs default — a new flag that
turns off the library and nothing else): walking 99.3% -> 78.1% · bench-sit 0 -> 9.5% · lean
0 -> 7.1% · stopped in own idle 0.4% -> 5.1% · DISTINCT CLIPS ACROSS THE CROWD 4 -> 20.
The town was 99.3% people walking because standing still had nowhere to happen.

Wiring: new postures.js + clipbank.js. idles.glb (10/10) drives a per-citizen deterministic
idle on every near-tier actor plus the seeded shopkeeper. locomotion gives 33.7% of walkers a
shopping bag. sitlean (8/8, lazy) puts 4 sits on Lane B's ACTUAL benches and 4 leans on
shopfront walls. browse (5/8, lazy) is a real BROWSE state at C's browse points, seeded per
(shopId, slot). venue (5/6, lazy) widens the gig crowd, plus a publican pouring and a record
keeper in headphones. social (0/8) is never fetched — two-person conversation needs a paired
state machine, filed to R42.

Cost: boot = 4 requests, 1.24 MB / 16 clips resident; the rest lazy on first need; heap delta
+3.34 MB; mixer median 0.1 ms both arms. ?clips=0 / ?classic=1 / ?noassets=1 fetch ZERO clips
— not even clipbank.js (dynamic import). No shell edit needed.

DRAWS: +0 on every bookmark (street_noon 193, crossroads 108, night_crowd 128, market_square
94, night_neon 111, interior 110 — identical both arms). Ruling 4 respected exactly.

DETERMINISM: 150 citizens, two fresh contexts, byte-equal posture signature. Controls: seed+1
differs; EVERY clip GLB delayed 2 s -> identical signature (posture is a pure function of
(citySeed, id), never of residency). 6 new streams collide with none of the 12 pre-R41 keys.

TWO FINDINGS THAT CHANGED THE DESIGN: the idle pool was INVISIBLE — wired only to the R17/R29
node loiter, so only 0.8% of citizens were ever stopped; and the lean never fired at all (0 in
a 9 s run). Both moved to the patronage stride check. Bench stations are GATED not trusted:
14/14 derived stations coincide with real instanced geometry within 2 cm, and the control
(same stations offset 2 m) matches 0/14. Filed to B: one benchStops(plan) export retires the
mirror, and furniture.js puts the bench's front ALONG the street rather than facing the road,
contradicting its own comment.

Leak: +0 geometries, +1 texture over 6 enter/exit cycles. Goldens 157,647/157,647, 0x5f76e76.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:11:30 +10:00

631 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PROCITY-D — progress (Lane D · Citizens)
## Round 41 (§41.3) — the citizens come alive: Lane E's 46 clips wired, 20 of them now in play. (2026-08-07)
**The measurement that is the round.** A census of the live crowd at the retail heart, 12 samples ×
146 active citizens, same seed, same pose, two arms — where `?clips=0` is a new flag that turns the
motion library off and **nothing else** (`?classic=1` also changes the ped pool, the fog, the game
and half the shell, so it cannot be the control for this):
| state of the street crowd | `?clips=0` = R40 | default = R41 |
|---|---:|---:|
| walking | **99.3%** | 78.1% |
| sitting on an actual Lane B bench | — | **9.5%** |
| leaning on a shopfront | — | **7.1%** |
| stopped at a window in their own seeded idle | 0.4% | **5.1%** |
| R17 free sit · R29 glance | 0.2% · 0.2% | 0.1% · 0.1% |
| **distinct animation clips in play across the crowd** | **4** (`walk`/`idle`/`sit`/`look` — and 99.3% of the crowd is on `walk.glb` alone) | **20** |
Lane E's input was verified before anything was wired: `python3 pipeline/clips_verify.py` → GREEN,
0 errors / 0 warnings, 46 clips / 6 groups / 3 498 124 B, every group zero-draw.
### What each clip category drives
| group | clips used | drives |
|---|---|---|
| `idles.glb` (boot) | **10 of 10** | the per-citizen deterministic idle — the resting action of every near-tier actor, plus the seeded shopkeeper idle |
| `locomotion.glb` (boot) | 1 of 6 | `walk_shopping_bag` — the walk pool is `['@walk','@walk','walk_shopping_bag']`, so **1 in 3 citizens carries a bag** (measured 33.7% over 1 000 ids) |
| `sitlean.glb` (lazy: first sit/lean intent) | **8 of 8** | 4 sits bound to Lane B's benches, 4 leans bound to shopfront walls |
| `browse.glb` (lazy: first interior with browsers) | 5 of 8 | a real BROWSE state at Lane C's browse points, seeded per (shopId, slot) |
| `venue.glb` (lazy: gig night, from the street) | 5 of 6 | gig-crowd widening + the publican pouring + the record-shop keeper in headphones |
| `social.glb` | **0 of 8 — never fetched** | needs a two-person state machine + partner pairing. 896 KB for a state D does not have. → R42 |
Unused and why: the four `turn_*` + `walk_to_stand` (the sim turns instantly at a node — a turn state
is R42 and is the only thing that makes those five mean anything), `browse_hold_turn_l/r` (172°/147°
loop seams, one-shot turns), `venue_clap_seated` (no seated venue slot).
### Boot fetch count and memory — measured in fresh headless contexts
| | clip 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 | +1 `venue.glb` | 2 602 476 B | 5 / 38 |
| `?clips=0` · `?classic=1` · `?noassets=1` | **0** | 0 | 0 / 0 |
Six fetches at boot was what the round warned about. The answer is **two GLBs at boot, three on first
demand and one never**, each promise-cached (the bank does not grow across 6 interior enter/exit cycles).
**Heap delta (library ON OFF), forced GC, fresh contexts: +3.34 MB and +6.87 MB across two runs**
the spread is GC timing; the floor is resident clip bytes plus parsed `AnimationClip` overhead.
`mixerMs` unchanged: median 0.1 ms both arms.
### Determinism — same seed, same postures, byte-equal
- 150 active citizens, **two fresh browser contexts → byte-equal** posture signature.
- **CONTROL:** seed 20261991 gives a different signature — "byte-equal" is not "constant".
- **CONTROL that matters for lazy loading:** with **every clip GLB delayed 2 s** by a Playwright
route, the signature is **identical**. Posture is a pure function of `(citySeed, id)`
(`postures.js posturesFor`), decided once at citizen creation and never re-rolled, so loading can
change *when* a posture appears, never *which one*.
- One level down: `fleet.clipsRequested` is published **synchronously** by `loadPedFleet` before the
dynamic import starts, and every roll that can move a citizen is gated on that rather than on
"has it landed", so the number of randoms drawn per boot cannot depend on the network. Six
freshly-keyed streams (`posture`, `benchstop`, `leanstop`, `browse-pose`, `keeper-pose`,
`gig-pose`) collide with none of the 12 pre-R41 keys — gated, with the control that all 18 produce
different draws on the same id.
- 1 000 citizens × two module instances in node: identical (`sha256 08ecfe041b384460`).
### Draw table — 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** |
Ruling 4 gave the street no headroom and the round spent none. The clips are skeleton-only (0 tris,
0 meshes, 0 materials), so the only mechanism by which they could move a draw count is putting a ped
somewhere else — bounded by the unchanged near-cap of 24 rigs, each already one draw. **Stated, not
hidden:** 193 is the worst of these five bookmarks on this seed, not the town's global worst (B's
R40 street number is 291/300 at a night pose these bookmarks do not reach). The **delta** is the
claim, and it is 0.
### `?noassets=1` and `?classic=1` — the fallback survives, proven
| | clip fetches | bank | town | console |
|---|---:|---|---|---|
| `?noassets=1` | **0** (no GLB, no manifest, not even `clipbank.js`) | none | 20 chunks, 165 citizens walking | 0 errors |
| `?classic=1` | **0** | none | 18 chunks, 139 citizens walking | 0 errors |
| `?clips=0` | **0** | none | full town | 0 errors |
The library rides `clips = dance`, the R36 `djs = dance` trick again: the shell already passes
`dance: !CLASSIC`, so **no `index.html` edit was needed** and the zero-fetch-delta covenant holds —
including on the JS surface, because `clipbank.js` is reached by a dynamic `import()` and is not
fetched when the gate is off.
**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.
### Leak
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, and
`_disposeInner` still disposes only the clone's own `Skeleton`.
### The two measurements that changed the design
1. **The idle pool was invisible.** Wired only to the R17/R29 window-shop loiter — which fires at a
graph NODE, and edges are long — a census found **0.8% of citizens stopped at any instant**. Nine
of ten new idles were assigned, deterministic, and never seen: the brief's headline failing while
the gate went green. Fix: a **window pause** on the R8 patronage stride check (the moment the sim
already asks "is there a shop beside me"). Stopped share **0.8% → 5.1%**, and all ten idles now
appear in a 12-sample census.
2. **The lean was never firing.** Also wired to the node loiter at first — i.e. at intersections,
where there is rarely a shopfront to lean on. **Measured: 0 leans in a 9 s run.** Moved to the
same stride check. Now 7.1%.
### The bench is a real bench
R17's bench-sit sat a ped down at whatever node it stopped at, upright, on air. R41 binds it to Lane
B's actual furniture: `benchStationsFor(edge)` mirrors `furniture.js:203-209` exactly, a ped can only
take a bench on its own footpath side, and the 1.6 m plank **seats two** (a measured need — the first
soak put two citizens on one station to the centimetre). Because that is a mirror of another lane's
file, it is **gated, not trusted**: every derived station inside the streamed window must coincide
with real instanced geometry within **2 cm**, yaw included — today **14/14 and 14/14**, against a
scene of 2 435 instances. **CONTROL:** the same stations offset 2 m match **0/14**. Filed to Lane B:
one `benchStops(plan)` export next to the existing `busShelterStops(plan)` retires the mirror.
### Gig — widened, not redesigned
The v3 dance pick (`gigdance` over `fleet.danceClips`) is untouched. A second, independently keyed
`gig-pose` roll decides whether a crowd slot takes a venue clip instead — northern soul for a dancer,
clap/cheer/headphones for a stander. Measured on a live gig: **8 crowd slots, 6 swapped, 2 keeping
their R13 pick verbatim, 0 pending**. A slot that does not swap, and every boot where `venue.glb`
is not resident, is byte-identical to R13.
### Gates added
| gate | what it proves |
|---|---|
| `node tools/qa/r41_postures.mjs` | manifest resolution · determinism · stream isolation · pool spread · loop safety. Zero deps, ~40 ms, five controls. |
| `tools/.venv/bin/python tools/qa/r41_citizens.py` | 7 arms in fresh contexts: boot ledger · lazy loading · determinism incl. the 2 s forced stall · draw table both arms · `?noassets`/`?classic` · bench binding + control · leak · gig widening. |
| `tools/.venv/bin/python tools/qa/r41_shots.py` | the two acceptance shots, camera chosen by measurement and occlusion-raycast, every figure measured for stature. |
**→ F: three lines for `qa.sh`.** All exit 0/1, all deterministic.
### Shots
`docs/shots/laneD/r41_street_postures.jpg` — seed 20261990, midday, camera (11.6, 58.5) yaw 1.890.
**8 near-tier rigs in frame, 3 distinct clips** (`@walk`, `walk_shopping_bag`, `sit_hands_thighs`): a
hi-vis worker **sitting on a real Lane B bench** with his feet on the footpath, a comical ped walking
centre-frame, and six more walkers up and down the strip. 126 draws of a 300 budget.
`docs/shots/laneD/r41_browse_interior.jpg` — The Op Shop (opshop), 3 browse points. A **browser at
the clothes rack on `browse_hold_idle`** — the ped who ducked in off the street, holding something
instead of standing to attention — and the keeper behind the counter on `idle_happy_1`, his own
seeded idle. 69 draws of a 350 budget.
Both carry a `.txt` sidecar with the human-sized line: stature measured off the posed skeleton for
every figure, as a ratio of that citizen's own nominal height. Today: sits 7677%, browsers/keepers
99101%, walkers 95100% of nominal. **No giant (>2.0 m), no fold (<55% of nominal).**
### Goldens
`node web/js/citygen/selfcheck.js`**157 647/157 647, fingerprint `0x5f76e76`**, unmoved. No
citygen file was touched.
### Files touched (only these)
`web/js/citizens/postures.js` (new) · `web/js/citizens/clipbank.js` (new) · `web/js/citizens/rigs.js`
· `web/js/citizens/sim.js` · `web/js/citizens/keepers.js` · `web/js/citizens/band.js` ·
`tools/qa/r41_postures.mjs` (new) · `tools/qa/r41_citizens.py` (new) · `tools/qa/r41_shots.py` (new)
· `docs/shots/laneD/r41_*.{jpg,txt}` (new) · `D-progress.md` · `docs/LANES/LANE_D_NOTES.md`.
**No git command was run.** Nothing under `web/models/`, `web/assets/`, `web/js/world/`,
`web/js/interiors/`, `web/index.html` or `tools/qa.sh` was touched.
---
## Round 40 (§40.5) — the door points and the footpath: measured, kerb-clamped, gated. (2026-08-04)
**Verification first, per the brief.** New gate `tools/qa/door_footpath_check.mjs` re-derives every
door point EXACTLY as the shell does (`lot + front-normal·(d/2+0.6)`, web/index.html:433-437) and
classifies it against the plan's own geometry (registry's `vergeBand` corridor law + lots + blocks).
All numbers below are from today's runs on today's tree.
| town | R39 (shipped) | today, B's fix only — footpath | today — walkable front | after D's clamp — footpath | **after D's clamp — walkable front** |
|---|---|---|---|---|---|
| synthetic@20261990 | 0/493 | 447/493 · 90.7% | 473 · 95.9% | 447 · 90.7% | **473/493 · 95.9%** ✅ |
| katoomba_real | 0/72 | 71/72 · 98.6% | 71 · 98.6% | 72 · 100% | **72/72 · 100%** ✅ |
| fitzroy_real | 0/139 | 138/139 · 99.3% | 138 · 99.3% | 139 · 100% | **139/139 · 100%** ✅ |
| bowral_real | 0/30 | 28/30 · 93.3% | 28 · 93.3% | 30 · 100% | **30/30 · 100%** ✅ |
**Verdict: B's two-character R39 fix did the heavy lifting (0% → ~9199%) but did NOT clear the
≥95% gate alone** — bowral read 93.3% and the synthetic 90.7% strict. Two residue species, and they
wanted different answers:
1. **Kerb misses (fixed by D).** On real towns `plan_osm` sets lots back at
`max(KERB, roadWidth/2 + FOOTPATH)`, so on narrow corridors the facade sits past the corridor
edge and the shell's `+0.6` nudge lands the door point **0.85 m outside the verge band**
1/72 katoomba, 1/139 fitzroy, 2/30 bowral. Fix: **`web/js/citizens/door_snap.js`** (new, pure,
no THREE, no rng) — clamp a door point into the footpath band of its nearest footpath-bearing
street, applied at intake in `sim.setShops`. Registry's published `vergeBand` over the sim's own
edges; graph-only stays graph-only. Capped at 6 m so a plaza front is never dragged to a street
it doesn't front; margin 0.45 m off the band edges. Transition-checked corpus-wide: **fixes 4,
breaks 0** (a gate arm asserts breaks = 0 forever). Live-shell verified: booted the game,
diffed `sim.shopsByChunk` against the raw derivation in the running page — 493 points, 2 moved,
max 5.41 m, exactly matching the node run.
2. **Plaza fronts (a definition, not a defect).** The synthetic's 36-shop strict-footpath residue
is **market stalls fronting the market square** (`use:"stall"`, block 25 `kind:"market"`) and
arcade shops — their front IS the square, they can never be in a street's verge band, and
dragging their door 5073 m to a street they don't front (measured: that's what an uncapped
snap does) would re-break the front-door truth. The gate therefore scores **walkable front
ground** — footpath, or the open ground of a `market`/`arcade` block (outside every lot, off
every carriageway) — and reports the strict R39-style footpath number alongside for continuity.
Peds never reach stalls anyway (5073 m from every walkable edge vs PATRON_RANGE 18) — their
door points are inert data today.
**Remaining synthetic residue (20/493, under the floor, documented not fixed):** stall-vs-stall
front overlaps (6), stalls at the square's edge clipping a carriageway (8), 3 shop + 1 anchor
corner cases, 2 shops fronting into a neighbour lot. All are **derivation** artefacts of the fixed
`d/2+0.6` nudge in `web/index.html:433-437` (F's shell, B's R39 hands) — if a later round wants
them, the recommended fix is deriving from `buildShopfront`'s own `doorRect` instead of the fixed
nudge; measured detail in LANE_D_NOTES §40. Out of D's graph-only reach and under the gate's floor.
**Gate arms (all green, 12/12):** rate ≥95% on all four towns · CONTROL — the back point (the exact
R8R39 defect) must score under the floor on every town or the classifier is vacuous (it reads
0.413.3%) · FIX-ONLY — the clamp breaks 0 previously-passing points. Deterministic: two runs
byte-identical JSON. **Goldens untouched: selfcheck 157,647/157,647, fingerprint `0x5f76e76`** (the
clamp is runtime pose in D's sim; citygen never touched).
**The picture:** `docs/shots/laneD/r40_front_duckin.jpg` — a rigged ped mid-stride ON the footpath,
frozen 3.2 m from the VIDEO BARN front door it is ducking into (staged via the sim's own
`_beginVisit` + a pause at the threshold; steering, door point and entry are the shipped code).
**→ Fable/Lane F (one line, as briefed):** please wire `node tools/qa/door_footpath_check.mjs` into
`tools/qa.sh` (rc 0/1, ~2 s, zero deps — same shape as the other node gates). Not editing qa.sh
myself; F owns the runner.
Files touched: `web/js/citizens/door_snap.js` (new) · `web/js/citizens/sim.js` (import + setShops
clamp) · `tools/qa/door_footpath_check.mjs` (new gate) · `docs/shots/laneD/r40_front_duckin.jpg`
(new) · this file · LANE_D_NOTES §40.
## Round 12 (v3.0-alpha) — the band and the crowd, behind `?gigs=1`. (2026-07-15)
Friday night at The Thornbury Hotel: a band on C's stage and a mixed crowd at C's watch points.
New `web/js/citizens/band.js` (`GigCrew`) + a gig surge in `sim.js`. Verified against A's real
`plan.gigs` + C's real venue interior (F wires the `?gigs=1` shell seam last, so I drove A+C+D
directly in a standalone scene). Deterministic from `citySeed`; asset-free-safe (placeholder crew).
**Band trio (task 1)** — 3 rigs on `stage.bandPoses` at `deckY`, **distinct fleet peds** (seeded
shuffle per gig — a band with two identical members reads wrong), each with a primitive instrument
that rides the chest: guitar (strum-sway), mic stand (vocal sway), bass (strum-sway). E's GLBs drop
in via `opts.instrumentFor(role)` when they land. NB C's `bandPoses` roles are **guitar/vocal/bass**
(a front-line trio) — there is no drummer pose (C's drum riser is set-dressing); flagging in case a
drummer was expected.
**Crowd (task 2)** — up to 8 rigs at `watchPoints` on the same gate-protected `spawnRig` path.
`dance:false` → subtle weight-shift idle; `dance:true` → bounce + sway + step, **seeded phase so the
crowd never metronomes**. Verified 3/8 dancing (C's ~⅓).
**Gig surge (task 3)**`sim.setGig(venueShopId, on)`: while on, patronage pulls peds from further
(`GIG_RANGE` 34 m) and ducks in harder (`GIG_SURGE` 0.55) at the venue; occupants **drain at close
per A's closing-time ruling** (no special handling — `_openAt` + the dwell timer do it). Measured a
mock venue climb **1 → peak 5** occupants; identity signature unchanged; **gig-off byte-identical**
(guarded by `_gigVenue==null`, so the frozen goldens don't move — prime flag law).
**Verified (standalone, fresh browser):** band **3** · crowd **8** · dancing **3** · **all 11 figures
human-sized** (heights 1.601.83 m, crown < 4.0 m ceiling the R10 no-giants gate holds for the gig
rigs) · band faces audience + crowd faces stage · **deterministic** (identical twice) · **leak-free**
(8 spawn/dispose cycles, geo/tex delta 0). **Budget:** the crew adds **~40 draws** (11 rigs + 3
instruments); the pub interior itself is the bulk F measures the full gig-night 350 in the shell.
**Figures human-sized relative to doors/fittings: yes** measured (1.601.83 m under a 4.0 m ceiling)
and shown in `docs/shots/laneD/r12_gig_night.jpg` (distinct band with instruments, mixed crowd watching).
** Cross-lane flag for C/F facing convention.** C's `watchPoints`/`bandPoses` `ry` point the fleet
mesh **180° from where `spawnRig` plants it** (verified in-scene: `ry=π` faced the band at the backdrop;
the watch-point `ry` faced the crowd at the door). The fleet mesh's visual front is its local **+Z**,
not the `Z` the sim/keeper comments assume. `band.js` compensates with a `RY_FLIP = π` on spawn so
bandaudience and crowdstage. **If C changes the gig-pose `ry` convention (or the fleet is re-fronted),
remove `RY_FLIP`.** Worth a look at whether keepers/browsers are subtly back-to-front too.
**For Lane F (wiring the seam):**
```js
import { GigCrew } from './js/citizens/band.js';
const crew = new GigCrew({ citySeed: plan.citySeed, fleet });
// on gig doors/on, inside the venue interior:
crew.spawn(room.group, { stage: room.stage, watchPoints: room.watchPoints, gig: tonight });
// each interior frame: crew.update(dt); on gig-off / room exit: crew.disposeAll();
citizens.setGig(gig.venueShopId, on); // street surge while doors/on; setGig(null,false) to end
```
## Round 10 (blocker fix) — interior giants → human-sized. qa --strict GREEN. (2026-07-15)
Fixed the R9 ship-blocker: interior rig figures were ~2× too tall (keeper crown at 3.83 m in a
3.4 m room, clipping the ceiling). Measured live, not from notes.
- **Root cause (rigs.js `buildFigure`)**: it normalised scale by `height / headY`, where `headY` is
the head bone's world Y in bind pose. But the Mixamo skeleton's ORIGIN is the hips (~mid-body), so
`headY` is head-**above-hips** (~half the standing height) not the feetcrown height. Result:
every rig ~1.832.0× too tall. (NB Fable's "bind-pose 82 vs posed 180" hypothesis was close on the
numbers but the head does NOT move when posed bind crown 3.209 m vs idle-posed 3.205 m. The 82 is
head-above-hips; the 175 is the true feetcrown span. So it's a hips-relative measure, not animation.)
- **Fix (one logic line)**: normalise by the feetcrown **span** = `headY - minY` (both already
computed). Now `buildFigure(rig, h)` lands the crown at exactly `h` above planted feet. Verified all
19 fleet rigs crown == 1.75 m (feet 0); live keeper in "Second Time Redfern" 3.83 m **1.82 m**.
This one change fixes keepers, browser rigs, near-tier street rigs AND the impostor bake (all ride
`buildFigure`) street peds looked fine only because they're mostly impostors, which are sized by
requested height and so masked the bug.
- **Regression guard (required)** new `tools/qa/interior_scale_check.py` (Playwright): enters a
sample of open shops, injects browser rigs at every browse point, and asserts EVERY interior fig's
crown [1.4, 2.0] m AND < room `dims.H` AND feet planted. Wired into `tools/qa.sh --strict` (gate 6,
`run_gate` a giant fails qa). Proven **bidirectional**: PASS on the fix (24 figs / 6 shops), FAIL
on a reintroduced giant (crowns 3.44.05 m, all 6 shops red). `qa.sh --strict` = **6 passed, 0 failed**.
- **Determinism + leak re-verified**: same seed same keeper height + ped (shop 18 1.823 m, idx 10,
identical twice; buildFigure crown stable & == requested); 15 spawn/dispose cycles geo/tex delta 0.
- **Re-shot** `laneD/r9_browsers_in_shop.jpg` `laneD/r10_browsers_fixed.jpg` (kept r9 as the
documented before-state).
**Figures human-sized relative to doors/fittings: yes** verified live (measured crowns 1.61.86 m
across 6 shop types under 3.44.0 m ceilings) and visible in `r10_browsers_fixed.jpg` (a hi-vis worker
and a hoodie browser both well under the ceiling, in proportion to the shelves and door).
Files touched (mine only): `web/js/citizens/rigs.js` (fix), `tools/qa/interior_scale_check.py` (new
gate), `tools/qa.sh` (+gate block, authorized by ROUND10 §Lane D), `docs/shots/laneD/r10_*.jpg`, docs.
## Round 9 (v2.0 ship) — interior presence: occupancy truth + browser rigs. qa GREEN.
Built the D side of the one cross-lane seam (C browse points D occupancy+browsers F handoff).
Follow a ped into a shop and find them browsing. Full numbers + F wiring in `LANE_D_NOTES.md`.
- **Occupancy truth (D1)**: patronage records who's inside which shop by shopId. New
`citizens.occupancyOf(shopId) → {count, occupants:[{seed,enteredAt,pedIndex}]}`. Cleaned on
emerge/chunk-drop/stream-toggle. **Deterministic** (17 shops + occupants byte-equal across two runs).
- **Browser rigs**: `keepers.js` gained `browse` (faces shelf, no greet) + `pedIndex` (the browser IS
the ped who ducked in) + `seedKey`. Validated 12 enter/exit cycles: count == min(occupancy,3) **12/12**,
worst **61 draws ≤350**, **leak-free** (0 GPU delta), disposed every exit. Merged peds = ~1 draw each.
- **Consistency (D2)**: inside peds hidden on the street (0 rendered), re-emerge at the door.
- **F handoff** (verified by running F's exact wiring): enter `occupancyOf` `browsePoints.slice(0,
min(count,3))` `keepers.spawn({...browse:true, pedIndex})`. `web/index.html` `setShops` door points
need `shopId: s.id` added. Closing-time handled by the dwell model (pending A's explicit ruling).
- Evidence: `docs/shots/laneD/r9_browsers_in_shop.jpg`. v1 path untouched.
Changed `web/js/citizens/sim.js` (occupancy) + `keepers.js` (browser mode) + `citizens_test.html`
(shopId in synthetic shops).
---
## Round 8 (the crowd comes alive) — shop patronage v0 + weather reaction. qa GREEN.
Also did the R7 post-flip shell verification (F's flip landed) + pushed the tags to origin (main
1a20501900b274; v1.0/v1.1/v2.0-alpha/v2.0-beta all on origin).
- **Post-flip verify** (real shell): no-flag boot streamMode true, determinism (188 ids), leak-free
(0 delta), setNightLivelyChunks wired; `?roster=v1` fixed roster, golden identity byte-identical.
- **Shop patronage v0 (D1)** : streamed peds duck into open shops they pass (goinginsideemerge, hidden
while inside), hours-aware noon variety (up to ~38 inside, 7 types), **night video-only** (the
open-late shop draws the crowd). Deterministic (150 chunk-keyed ids), leak-free (0 GPU delta over the
churn), **budget-neutral** (draws 1, inside peds hidden). Behind `?patronage=0`, default-on for stream.
- **Weather reaction (D2)** : reads B's `PROCITY.weather` (no import). Rain density **56%** (4060%
target), walk speed +~14%, more sheltering (inside 625); overcast 10%; clear = v1.
- v1 path untouched (no patron fields, golden identity). Full numbers + F's `setShops`/`setWeather`/
`?patronage=0` wiring in `LANE_D_NOTES.md`.
Only `web/js/citizens/sim.js` + `web/citizens_test.html` (synthetic-shop demo + flags) changed.
---
## Round 7 (the flip) — MERGE VALIDATED, GO given to F, defaults + escape hatch ready. qa GREEN.
Validated Lane E's ped-merge (the gate my R6 memo named) and handed F the **GO** for the roster flip.
Full go/no-go numbers + F's two-line wiring at the top of `LANE_D_NOTES.md`.
- **Merge validated (D1)**: all 19 peds now **1 mesh / 1 material** (was 8/2) ~1 draw/near-rig
(fleet 11624 at the cap); bind+animate/no-T-pose, silhouettes+atlas materials crisp (hi-vis/suit/
kid), impostor bake clean, identity variety + determinism preserved. GPU memory dropped too.
- **The flip numbers (full density, perChunk 16)**: worst street view **241 draws ≤300** (was 356
unmerged), **65,899 tris ≤200k**, 17 near-rigs; 30-chunk soak (726 builds/disposes) **leak-free, 0
GPU delta**, heap stable. **GO.**
- **Full-density defaults (D2)**: `enableStream` default flipped 8→**16** (the merge affords it);
near-cap stays 24 (now 24 draws not 168).
- **Post-flip + escape hatch (D3)**: test page + shell now default to stream; **`?roster=v1` restores
the fixed roster** (verified: streamMode false, determinism ✓). No-flag boot streamMode true,
determinism ✓. F's `web/index.html` flip wiring (invert to default-on + `?roster=v1` escape)
documented; the in-shell no-flag-URL boot completes when F lands F1 (roster proven running in-shell
at the shipping default via the in-shell soak above).
Only `web/js/citizens/sim.js` (perChunk default) + `web/citizens_test.html` (default-on + escape
hatch) changed. Committed atomically.
---
## Round 6 (harden streamed roster toward default-on) — DONE. qa GREEN. Flag still default-off.
Hardened `?roster=stream` and wrote the **default-on readiness memo** (top of `LANE_D_NOTES.md`) so
Fable + F can decide the R7 flip on evidence. In-shell (real town), stream runtime-enabled:
- **Soak green**: 242 chunk builds/disposes over 3 street-graph walks **0 GPU geom/tex delta**; 16
shop enter/exit 0 delta; heap oscillates 507754 MB (GC recovers, no leak); 188 chunk-keyed
identities re-derive from seed; 0 console errors; flag-off byte-identical (citizen-0 = R4 golden).
- **Hours-aware density (new)**: added `setNightLivelyChunks` + a night floor so the open-late video
block stays lively at night (3116) while ordinary streets go near-empty (656). F call-site documented.
- **Composition with `?dig=1`**: roster is street-tier, fully inert while interior/dig is open (frame
loop street-branch-only); enter/exit record shops leak-free with stream on. No shared state with any
v2 flag all-on combo safe.
- **Perf/budget the one gap**: each decimated ped = 8 sub-meshes / 2 materials = ~7 draws/near-rig, so
full-density stream (`perChunk 16`) hits **356 draws** (over the 300 street budget). Set the default to
`perChunk 8` (**291 draws, 300, budget-safe today**) and documented the clean unlock merge ped
sub-meshes by material (82 ~2 draws/rig perChunk 16 fits at ~260) as the single thing gating
full-density default-on in R7 (F1-safe: v1 spawn view has ~0 near-rigs).
Only `web/js/citizens/sim.js` (night floor + default) + `web/citizens_test.html` (default perChunk)
changed. Committed atomically; flag default-off; v1 path untouched.
---
## Round 5 (v2 foundations) — DONE. Chunk-streamed roster behind `?roster=stream`. qa GREEN.
Implemented the R3 design note (was design-only): the chunk-streamed roster, default-off, v1-identical
when off. Full numbers + Lane F wiring in `docs/LANES/LANE_D_NOTES.md` (top).
- **Chunk-local identity** keyed `(seed, chunkKey, i)` same seed + chunk same residents regardless
of town size / visit order; **windowed spawn/despawn** hook-driven (Lane B's `onChunkBuilt`/
`onChunkDisposed`, sim auto-detects) with a camera-poll fallback; **global near-cap** across live
chunks; **owner-chunk despawn** (no cross-boundary re-keying); v1 path untouched (`chunkStream` opt-in).
- **Proven** on a big 9×9 fixture grid: (a) constant far-field density ~58129 citizens within 70m
at spawn AND 150m out, vs v1's uniform ~1215 (fixed roster spread thin); (b) deterministic 507
chunk-keyed identities re-derive from seed; (c) leak-free 2 full grid walks (~380 chunk cycles)
**0 geom/tex delta**, pool capped 30, chunks bounded 25; (d) budget same near-cap + 1 impostor
draw as v1, 2.31 ms/frame; (e) **flag-off byte-identical** shell citizen-0 signature exactly
matches the R4 golden. Hours-aware density included (per-chunk tod thinning).
- Only `web/js/citizens/sim.js` + `web/citizens_test.html` (bigger fixture + stream toggle/HUD/
determinism) changed. Flag + F call-site documented in LANE_D_NOTES; F wires `web/index.html`.
Evidence: `docs/shots/laneD/r5_stream_tiers.jpg`.
---
## Round 4 (v1-tag round) — DONE. qa.sh --strict GREEN.
All three Lane-D R4 tasks complete; details + numbers in `docs/LANES/LANE_D_NOTES.md` (top).
- **D1 in-shell verify** In the real game (`web/index.html`): peds + keepers upgrade
placeholderrig; **12 enter/exit shops leak-free** (`renderer.info.memory` back to exactly
baseline, Δ0); determinism holds across reload; `?noassets=1` fully placeholder, zero ped fetches.
- **D2 decimated peds** (critical path; E1 landed) Validated E's fleet: **all 19 peds 3k tris
(15642805, avg 2450)**, rigs bind + animate (skinning/skeleton preserved, walk/idle retarget
unchanged), **0 broken meshes** under eviction churn, silhouettes + identity variety intact,
impostor atlas bakes clean, determinism + leak re-pass. **Gate-3 sign-off for Lane F: near fleet
now 59.6k tris at the 24-cap (was ~1.2M); whole test view 88.9k (was ~1.5M).**
- **D3 exposure sync** Verifying it **found + fixed a real bug**: the impostor material
self-tone-mapped (`toneMapped:false` + in-shader ACES), which is correct on my direct-canvas test
page but **double-tone-mapped under the shell's EffectComposer/OutputPass** (three only tone-maps
materials on the canvas path). Reworked it to a standard `toneMapped:true` material that outputs
linear and uses three's own tone-map/colorspace chunks tone-maps **identically to the near rigs
on both paths**. Verified near+mid match at noon and correct night render in-shell
(`docs/shots/laneD/r4_shell_*.jpg`). `citizens.setExposure()` is now a harmless no-op (exposure is
global); Lane F can keep or drop the call at `web/index.html:245`.
Only `web/js/citizens/impostor.js` changed this round (+ my NOTES/progress/R4 shots). Committed
atomically; `web/index.html`, `dbg.js`, and other lanes' in-flight files left untouched.
---
# (Round 1) — initial Lane D build
**Status: complete and verified in-browser.** 2026-07-14, by PROCITY-D (Opus 4.8).
Standalone test page runs, all Lane-D acceptance gates pass. One cross-cutting finding for Lane E
(ped poly-count) is flagged below and in `docs/LANES/LANE_D_NOTES.md`.
### Re-verify after session resume (2026-07-14, PROCITY-D)
Prior session hit the token limit; resumed on a fresh account. Confirmed nothing rotted all 5
citizen modules intact on disk with every adversarial-review fix present. Re-ran the gates live in
the browser on this machine:
- **perf @ pop 200**: max mixer **0.2 ms**, sim CPU **0.148 ms/frame**, near capped at **24**
- **determinism**: `✓ 200 identities match seed 20261990` (live vs two fresh recomputes)
- **no T-pose / one shared walk clip / keeper at counter**
- **`?noassets`**: placeholder town, 0.00 ms mixer, 2.4k tris, crash-free
- **Integration contract PROVEN**: built Lane A's real `generatePlan(1234)` "North Cressy Reach"
(27 nodes / 22 edges) and ran `new CitizenSim({ …, graph: plan.streets })` **with zero adapter**
0 dangling edges, 0 non-finite citizen coords, no errors. `plan.streets` is exactly the `{nodes,
edges}` shape the sim consumes. Lane F §3.3 is a one-line construction (see below).
### Round 3 — support round (2026-07-14, PROCITY-D) — all 3 tasks done
Per `docs/LANES/ROUND3_INSTRUCTIONS.md` §Lane D. Committed Lane D landed as `9eb1acc` first; then:
1. **Keeper GLB-rig upgrade + leak-free dispose (task 1).** Gated on F's §3.4 (keepers are still
placeholder-only in the shell F builds `KeeperManager` with no `fleet`). Verified **my side** of
the contract in `citizens_test.html`: a fleet-backed keeper spawns as a **rig**, and 15 enter/exit
spawngreet→`remove()` cycles leave `renderer.info.memory` **exactly at baseline** (geo 140140,
tex 175175) leak-free. Enablement for F is one arg (`fleet`); documented in LANE_D_NOTES.
2. **`ImpostorLayer.setExposure()` wired to day/night (task 2).** Added a clean `CitizenSim.setExposure(e)`
passthrough (sim.js) that also **survives the placeholder→rig atlas re-bake** (stored + re-applied).
Confirmed the shell *does* animate `renderer.toneMappingExposure` per segment (`lighting.js`), so
this is a real fix, not defensive. Verified: uniform tracks 0.552.3 and persists across the upgrade.
Left F the exact one-line call site (`index.html:225`, after `setTimeOfDay`) in LANE_D_NOTES it's
an F-owned seam edit, so I documented rather than edited it.
3. **Chunk-streamed roster design note (task 3).** Written in LANE_D_NOTES ("Chunk-streamed roster
v1.5 design note"): chunk-local identity keying, poll- vs hook-driven windowing (with the `chunks.js`
reality), global near-cap across live chunks, ownership/hand-off, budget, and an opt-in migration that
leaves v1 untouched. **Design only, no implementation** as instructed.
Post-edit re-verify: no console errors, determinism (200 match seed), near capped 24, mixer 0.3 ms.
Round-3 code touches **only sim.js** (my file); the rest is docs. No sibling files edited.
---
## What I built (owns `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*`)
| file | lines-ish | what |
|---|---|---|
| `web/js/citizens/rigs.js` | ~185 | the ported crown-jewel rig stack fleet loader, `_canon`/`canonRig`/`_rotOnly`, `buildFigure` (head-bone height-normalise + feet plant), `spawnRig`, `makeActor` (walkidle crossfade for the near tier), `pickRig` |
| `web/js/citizens/placeholder.js` | ~150 | seeded low-poly box humanoid (the POLY lock) walks/idles, so the town is populated from frame one and in `?noassets` mode |
| `web/js/citizens/impostor.js` | ~210 | mid-tier LOD: bakes each ped to a 4-yaw sprite atlas on an offscreen RT, one InstancedMesh of camera-facing billboards **1 draw call** for the whole mid crowd |
| `web/js/citizens/sim.js` | ~330 | the simulation deterministic roster, footpath lanes off the street graph, near/mid/far tiers with hysteresis + hard near-cap, a reusable rig pool, staggered mixer budget, time-of-day density |
| `web/js/citizens/keepers.js` | ~95 | one keeper per shop at the counter, idle clip + greet turn toward the player |
| `web/citizens_test.html` | ~310 | standalone harness (fixture 3×3 street graph, no Lane A/B imports) |
| `web/models/peds/*` | 21 GLBs, 37 MB | 19 rigged peds + walk + idle, **byte-identical** copies from 90sDJsim (SHA-verified) |
| `docs/LANES/LANE_D_NOTES.md` | | measured budgets + clip wishlist |
| `docs/shots/laneD/*.jpg` | 5 shots | near / mid / main-street / tier-debug / noassets |
Everything is plain ES modules, three from `web/vendor/`, all randomness via `core/prng.js`
(`Math.random()` used nowhere in identity/placement). Loaders come from `core/loaders.js`.
## Acceptance — every gate met
- **200-citizen fixture holds 60fps** full frame CPU ~2.6 ms (≈380 fps of headroom on the M3
Ultra; GPU trivial). Preview's throttled rAF shows 4784 fps, but timed frames prove the margin.
- **Mixer update < 2 ms/frame** measured **0.4 ms max** at 200 citizens (staggered: nearest 8
every frame, 4 more per frame).
- **No T-pose ever** fixed a real bug: freshly-promoted rigs were showing bind-pose for a frame;
actors now evaluate their mixer at acquire time. Verified across the fleet in `01_near_tier.jpg`.
- **No giants/ants** head-bone height normalisation verified across all 19 peds (1.551.95 m).
- **One shared walk clip animates every ped** the `mixorig` canonicalisation binds a single
`walk.glb` to all 19; confirmed in-scene.
- **Same seed → same crowd** determinism button asserts the live roster equals a fresh seed
recompute; holds while citizens walk (identity live position).
- **Screenshots** into `docs/shots/laneD/` (near, mid, busy main street, + tier-debug & noassets).
- **`docs/LANES/LANE_D_NOTES.md`** measured budgets + the mixamo-fetch clip wishlist.
Plus the LOD design points from the lane brief: near<25m rig+mixer walking footpath lanes & turning
at nodes on a seeded choice with loiter stops; mid 2570m instanced impostors baked from the fleet
(auto-in-sync); far culled; hysteresis so nobody pops; time-of-day density; `?noassets` placeholder
town; keepers at counters. All exercised in the test page.
## Decisions worth your eyes
1. **Rig pool, not per-spawn clone.** Near actors are pooled per ped-type and reused across citizens
(SkeletonUtils.clone + mixer are the expensive bits) bounded at 30 instances, cap 24 active,
3 new clones/frame to avoid hitches. Height is applied as an outer-group scale so pooled rigs are
height-agnostic and reusable.
2. **Impostor colour management.** The mid atlas is baked *linear* (no tone-map) and the billboard
shader applies ACES + sRGB itself, so mid impostors match the ACES-tonemapped near rigs exactly
fixed an early bug where they rendered ~2× too dark.
3. **Figure origin at the feet.** Both rigs and placeholders present a `fig` Group whose origin is the
ground point, so the sim moves/rotates walkers without re-planting them each frame.
## ⚠️ One thing I need a ruling on (Lane E territory)
**The inherited 90sDJsim peds are ~49.7k tris each.** 24 near rigs 1.2M tris far past the
CITY_SPEC "≤200k tris typical view". It runs fine on Apple Silicon today, but it leaves no tri budget
for Lane B's buildings. The rig stack is indifferent to poly count this is an **asset-pipeline
decimation job (Lane E)**: at ~6k tris/ped the near cap fits budget. Until then `NEAR_MAX` in `sim.js`
is the throttle. Same story, smaller, for draw calls (each ped is ~6 sub-meshes merge on load).
Details + numbers in the NOTES. Not blocking Lane D; flagging so it's on the integration radar.
## Adversarial review pass (6 real bugs found + fixed)
After the build passed in-browser, I ran a 4-dimension multi-agent code review with an adversarial
verify stage (14 agents). It confirmed **6 genuine defects** (4 others refuted); all 6 are fixed and
re-verified:
1. **[HIGH] Non-deterministic fleet order** `fleet.normal/all` were filled in GLB load-completion
order, so `pickRig`'s index (→ every identity) could differ across reloads/machines, silently
breaking "same seed same crowd". Fixed: fill fixed slots by `PED_NAMES` index, compact after.
2. **[HIGH] Shared-resource disposal** `_disposeInner` disposed geometry/materials that
SkeletonUtils.clone *shares* across all clones + the base rig; pool eviction would tear down GPU
buffers still in use by sibling citizens. Fixed: dispose only the clone's own `Skeleton` (its
bone texture) never the shared geo/mats. Stress-tested (24 teleports forcing eviction churn)
0 broken meshes.
3. **[HIGH] Impostor under-exposure** the in-shader ACES omitted three's `/0.6` exposure
normalisation, making mid billboards ~1.67× darker than the identical near rig at the swap
boundary. Fixed: `color *= uExposure / 0.6`.
4. **[HIGH] Renderer viewport not restored** the atlas bake set per-cell viewport/scissor but only
restored scissor-*test*; the async re-bake left the viewport on a 128px cell the main scene
could render into a corner. Fixed: save/restore viewport + scissor rect.
5. **[MED] Billboard ignored `modelMatrix`** impostors would desync from rigs if the citizens group
carries a transform (Lane B chunk offsets). Fixed: fold `modelMatrix` into the billboard base.
6. **[LOW] Frozen tone-map exposure** added `ImpostorLayer.setExposure()` for day/night.
Also caught during re-verification: three injects its own `RRTAndODTFit`/ACES functions into any
tone-mapped `ShaderMaterial`, colliding with mine. Fixed with `toneMapped:false` (I tone-map
in-shader) + `imp`-prefixed helper names. Console is clean; determinism still passes; 60fps holds.
## For Lane F integration (hooks are ready)
- **Street graph verified, no adapter.** `plan.streets` (Lane A) is byte-shape-compatible with the
`graph` the sim wants. In the street branch of the shell:
```js
const sim = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets, fleet });
// per frame (street mode): sim.update(dt); // LOD is by sim.camera.position pass the render camera
```
Note `update(dt)` takes **only dt** (it reads `this.camera.position` for LOD, which is what you want
LOD by what the viewer sees). Lane F §3.3's `sim.update(dt, player.position)` sig is loose; the
1-arg form is correct as long as the constructed `camera` is the render camera.
- **Density on big towns**: a flat `setPopulation(N)` spreads citizens uniformly across *all* edges, so
on a large plan most sit far-culled from any one viewpoint (correct + cheap, but streets read empty
if N is small). For v1 pick N to suit town size; the durable fix is chunk-streamed rosters (below).
- Roster identity `identityOf(citySeed, edgeCount, id)` is chunk-ready key by `chunkKey+i` and
stream rosters with Lane B's chunk build/dispose.
- Keepers pull `x,z,ry` from Lane C's counter `places`; `sim.setTimeOfDay` from the shell day segment;
`sim.setPaused` from `visibilitychange`; **Lane B must set `scene.environment`** (PBR peds go dark
without it already wired to be read from the scene).
## Try it
```
cd web && python3 -m http.server 8130
# open http://localhost:8130/citizens_test.html (full fleet)
# open http://localhost:8130/citizens_test.html?noassets=1 (placeholder town)
# open http://localhost:8130/citizens_test.html?seed=1234 (any seed)
```
Sliders: population 0200, time-of-day. Buttons: tier colours (green=near, yellow=mid, pink=far),
determinism check. Drag=look, wheel=zoom, WASD=move focus.