PROCITY/D-progress.md
m3ultra 888335d633 Lane D round 12: gig band + crowd + patronage surge (?gigs=1)
v3.0-alpha — Friday night at the pub. New web/js/citizens/band.js (GigCrew):
- Band trio on C's stage.bandPoses (deckY), distinct fleet peds (seeded per gig), each with a
  primitive instrument on the chest (guitar/mic/bass; E's GLBs drop in via opts.instrumentFor).
- Crowd (<=8) at C's watchPoints on the gate-protected spawnRig path: dance:false = weight-shift
  idle, dance:true = bounce/sway/step with seeded phase (no metronome).
- Deterministic from citySeed; placeholder crew under ?noassets.

sim.js: setGig(venueShopId, on) gig-night surge — patronage pulls peds from further (GIG_RANGE)
and ducks in harder (GIG_SURGE) at the venue; occupants drain at close per A's closing-time ruling.
Guarded by _gigVenue==null so gig-off is byte-identical (prime flag law; goldens unmoved).

Verified standalone (A withGigs + C buildInterior + D): band 3, crowd 8, dancing 3, all 11 figures
human-sized (1.60-1.83m under a 4.0m ceiling; R10 no-giants holds), band faces audience + crowd
faces stage, deterministic, leak-free (8 cycles, 0 delta). Crew ~40 draws. Surge: venue occupancy
1 -> peak 5, identity stable. Shot: docs/shots/laneD/r12_gig_night.jpg.

Flag for C/F: band.js flips C's watchPoints/bandPoses ry by pi (RY_FLIP) — the fleet mesh's visual
front is local +Z, not the -Z the comments assume. Remove RY_FLIP if C re-fronts the gig poses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:49:07 +10:00

27 KiB
Raw Permalink Blame History

PROCITY-D — progress (Lane D · Citizens)

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 band→audience and crowd→stage. 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):

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 feet→crown 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 feet→crown span. So it's a hips-relative measure, not animation.)
  • Fix (one logic line): normalise by the feet→crown 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.jpglaneD/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 → occupancyOfbrowsePoints.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% (4060% 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 — ~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 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 (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 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 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 orderfleet.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:
    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.