# PROCITY-D — progress (Lane D · Citizens) ## 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 1a20501→900b274; 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 (going→inside→emerge, 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%** (40–60% target), walk speed +~14%, more sheltering (inside 6→25); 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 116→24 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 50→77→54 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 (31→16) while ordinary streets go near-empty (65→6). 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 (8→2 → ~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 — ~58–129 citizens within 70m at spawn AND 150m out, vs v1's uniform ~12–15 (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 placeholder→rig; **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 (1564–2805, 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 spawn→greet→`remove()` cycles leave `renderer.info.memory` **exactly at baseline** (geo 140→140, tex 175→175) → 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.55↔2.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` (walk↔idle 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 47–84 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.55–1.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 25–70m 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 0–200, time-of-day. Buttons: tier colours (green=near, yellow=mid, pink=far), determinism check. Drag=look, wheel=zoom, WASD=move focus.