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