PROCITY/docs/LANES/LANE_D_NOTES.md
m3ultra 10025d4082 Lane D R29 (v6 Spike 1): look.glb bound and wired — peds glance, feet planted (wave 0)
The spike reaches the game. E's look.glb (mesh-free, 65 joints, 6.5s 'Look') is bound
through the fleet gate; street peds now glance around during window-shop stops. `lean`
is absent and never existed (E's R28 measurement). Goldens untouched — clips sit outside
every plan hash; the synthetic anchor stays R27's amended 0x5f76e76.

WIRED: loadPedFleet(base, {sit, look}) -> fleet.lookClip, the exact twin of the R16 sit
gate; the shell passes look:!CLASSIC so ?classic fetches NOTHING (a raw fetch would have
breached the zero-fetch-delta covenant — E flagged it, the gate is the answer).
makeActor gains lookA, played only on setLooking(true) — crossfaded (0.3s), not snapped
like sit, since idle->idle-variant reads naturally and there's no replant for a fade to
fight. sim.js gains a dedicated `glance` rng stream, rolled ALWAYS (deterministic) but
only flipped when a clip exists => classic inert at the source. Mutually exclusive with
bench-sit (sit rolls first at 35%, glance takes 40% of the rest). RigPool threads
lookClip; release snaps the glance off so no rig is pooled mid-look.

THE FINDING — my own prediction was wrong, and the measurement caught it. I wrote that a
STANDING clip needs no foot replant ("no hip descent to recover — that trap was sit's
alone"). Measured over the full loop: idle keeps the lowest bone within 4cm of the ground
(-0.04..0.00); look.glb ranges -0.009..+0.205 — the ped HOVERS up to 20.5cm, drifting
through the loop. Same trap, different coat: _rotOnly drops the Hips POSITION track for
rig-height independence, so the clip's authored weight-shift dip can't move the hips and
comes out as the FEET RISING. Sit's one-time replant can't fix it — the float varies per
frame.

FIX: plantFeet() — the per-frame twin of sit's replant. Reset to bind plant -> measure
posed feet -> drop inner by float/scale. ABSOLUTE (reset->measure->correct), never
incremental, so it cannot drift. Reproduces the authored motion exactly: the hips dip
relative to the ground instead of the feet leaving it. Scale-aware like sit's. Foot bones
cached once; cost paid only by glancing peds, on frames their mixer ran (both mixer sites
— the drummer's post-mix lean taught that coupling rule).

PROOFS: foot float 0.205m -> 0.000m (pinned); head yaw sweep span 1.735 (-0.85..+0.89) so
the glance still animates; ?classic — no look fetch, no Look action on any rig, glance
never flips (0); identity signature unchanged, no glance term; sit AND glance never
simultaneous (0); 0 NaN; goldens 0x5f76e76 untouched. A glancing ped is NOT tagged
seated/posed — standing pose at standing stature, so F's no-giants gate needs no arm.

D->F: the pose gate HAS ITS SUBJECT — un-SKIP it. Fresh ?localdepot=1 boot shows a ped
glancing on the footpath, head sweeping, feet planted.
Spike 2 readiness: plantFeet() is the general machinery the brief asked for ("data, not
new code") — riffle and wall-lean should land as clip + roll; the lean's wall-contact
placement is the only genuinely new part.
FLAGGED (A's file, A rests): selfcheck.js:301 still says "the synthetic golden above stays
0x3fa36874" while line 267 pins the amended 0x5f76e76. A stale comment, not a rule — but
the same species as the epoch's five holds, so: flagged, not touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:44:07 +10:00

94 KiB
Raw Blame History

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 ~405520 (loadRig/spawnRig/_canon/ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.


ROUND 29 — v6 SPIKE 1: look.glb bound and wired — peds glance (wave 0)

PROCITY-D, 2026-07-17. The spike finally reaches the game: E's look.glb (mesh-free, 65 joints, 6.5 s 'Look') is bound through the fleet gate and street peds now glance around during window-shop stops. lean is not here and never was — E measured the stash (R28). Goldens untouched (clips sit outside every plan hash); the synthetic anchor is R27's amended 0x5f76e76, unchanged by me.

What shipped

  • rigs.jsloadPedFleet(base, { sit, look })fleet.lookClip, the exact twin of the R16 sit gate. The shell passes look: !CLASSIC, so ?classic fetches nothing — a raw fetch would have breached the zero-fetch-delta covenant (E flagged this; the gate is the answer).
  • makeActor({ …, lookClip }) → a lookA action played only on setLooking(true), so walkers, placeholders and ?classic never advance it. setLooking crossfades (0.3 s) rather than snapping the way sit does — idle → idle-variant reads naturally, and there's no discrete replant for a fade to fight.
  • sim.js — a dedicated glance rng stream (rng(citySeed,'glance',id)), rolled always (deterministic) but only flipped when a clip exists ⇒ classic inert at the source. Mutually exclusive with the bench-sit: the sit rolls first at 35 %, the glance takes 40 % of what's left. RigPool threads lookClip; release snaps the glance off so no rig is pooled mid-look.

The finding — I predicted this needed no re-plant, and the measurement said otherwise

I wrote in my own first draft that a standing clip needs no foot re-plant ("no hip descent to recover — that trap was sit's alone"). Wrong, and the numbers caught it:

lowest bone over the full loop idle clip look.glb, no plant
min → max 0.04 → 0.00 (span 4 cm) 0.009 → +0.205 (span 21 cm)

The ped hovers up to 20.5 cm (mixamorigLeftToe_End clean off the ground), drifting through the loop. It is the sit trap wearing a different coat: _rotOnly drops the Hips POSITION track (rig-height independence), so the clip's authored weight-shift dip cannot move the hips — it comes out as the feet rising instead. Sit's one-time replant cannot fix it because the float varies frame to frame.

Fix: plantFeet() — the per-frame twin of sit's replant. Reset to the bind plant → measure the posed feet → drop inner by float / scale. Absolute (reset→measure→correct), never incremental, so it cannot drift. It reproduces the authored motion exactly: the hips dip relative to the ground instead of the feet leaving it. Scale-aware like sit's. Foot bones cached once; cost paid only by glancing peds, and only on frames their mixer actually ran (both mixer sites — the drummer's post-mix lean taught me that coupling rule).

Proofs

before after
foot float over the loop 0.205 m 0.000 m (pinned)
head yaw sweep (the glance itself) span 1.735 (0.85 → +0.89)
?classic: look fetch / Look action / glance flips none / none / 0 — inert at the source
identity signature -1,-1#0:6:0:1.777:1.171:1:-1 unchanged — no glance term
sit ∧ glance simultaneously 0 (mutually exclusive)
NaN 0 0
goldens 0x5f76e76 0x5f76e76 untouched

A glancing ped is not tagged procitySeated/procityPosed — it is a standing pose at standing stature, so F's no-giants gate needs no exemption arm for it.

Handshakes

  • D → F: the pose gate has its subject — un-SKIP it. A fresh ?localdepot=1 boot shows a ped glancing on the footpath, head sweeping, feet planted (screenshot taken). Classic/?noassets untouched.
  • Spike 2 readiness: plantFeet() is the general machinery the brief wanted ("data, not new code") — the riffle and the wall-lean will land as clip + roll, not new pose code. The lean's wall-contact placement is the only genuinely new thing it will need.
  • Stale line spotted (A's file, A rests): selfcheck.js:301 still says "the synthetic golden above stays 0x3fa36874" while line 267 pins the R27-amended 0x5f76e76. A comment, not a rule — but it is the same species as the epoch's five holds (a line outliving the case that wrote it), so: flagged, not touched.

ROUND 28 — v5.1 THE SWEEP: instrument LOD — KILLED with the measurement (ledger #4)

PROCITY-D, 2026-07-17. The oldest tri item on the books (v3.1-era). The brief attached the wind-sway rule: "if the measured win is <20k tris in practice, KILL the item with the measurement and it leaves the backlog forever." It does not survive. E: do not build the _lo variants — that work is now unnecessary.

The measurement

Instrument tri counts, parsed straight from the shipped depot GLBs (web/assets/models/):

instrument tris
electric_guitar 14,000
bass_guitar 14,000
drum_kit 14,000
guitar_amp 14,000
mic_stand 14,000
band + amp, all on stage 70,000

On paper that clears the bar: a 34k _lo each would "save" ~52k. But the rule says in practice, and the brief named the reason — "interiors are close-quarters." So I measured the rooms an LOD would have to fire in (live, all three venue archetypes, interiorMode driven by hand since rAF is throttled):

venue room max diagonal room tris (no band) draws
pub — The Exchange Hotel 9.7 × 9.7 m 13.7 m 12,522 38
band_room — The Vinyl Shed 8.9 × 9.6 m 13.0 m 11,699 35
rsl — Wangaratta RSL (biggest) 12 × 9.4 m 15.2 m 15,115 38

The verdict: practical win = 0 tris. KILLED.

A player can never be more than 15.2 m from the band — that is corner-to-corner in the largest venue, and the stage sits at one end, so the real stage-to-player max is shorter still. An LOD is a distance trade: it pays when the subject is small on screen. Inside a ≤15.2 m room the band is never small — it is the subject of the gig, and of F's tour money shot. Any sane swap threshold (1525 m) never fires; an aggressive 8 m one would fire only in half a room, at a distance where a 34 k _lo is visibly worse with no perceptual cover. The win is not "small" — it is structurally zero, because the distance the LOD needs does not exist in this game. Per the wind-sway rule: killed, off the backlog forever.

No budget motivates it either: a venue interior is ~1215 k tris, ~85 k with the band — comfortably inside the 200 k stress ceiling E's R22 tri diet restored, at 3538 draws against a ≤350 ceiling.

Side-observation for E (NOT a proposal — no breach motivates it)

All five instruments are exactly 14,000 tris, which is a decimator target, not a measurement of the objects: a mic stand does not need 14 k. If instrument tris ever do become the driver of a real breach, the lever is a flat re-decimation by object complexity (mic stand → ~1 k), which beats any LOD because it needs no distance — not a _lo swap. Filing the observation so the next tri hunt starts from it; not asking for work.


ROUND 23 — v5.0-alpha: the GIG_RANGE chunk-neighbour fix (ledger #6, the carried v4.x item)

PROCITY-D, 2026-07-16. My own filed finding, finally fixed — post-release, as Fable scheduled. Hot path, so the R18-style before/after is the deliverable. selfcheck ALL GREEN 161300/161300, 0x3fa36874 frozen (runtime-only change — no plan, no golden).

The finding grew while it waited: 8.7 % → 42.6 %

I filed this at 8.7 % in R20, measured at katoomba's isolated pub. A's R21 cluster bias made it worse: venues now sit inside the retail cluster, where peds converge from every side rather than trickling along one street — so far more in-range peds are across a chunk edge. Re-measured on the relocated venue: 42.6 % of in-gig-range ped-samples (5094 of 11971) were chunk-blind. Fixing it mattered more in R23 than when I filed it.

The override: the brief contradicts itself, and the measurement broke the tie

The brief said "extend _nearestOpenShop to the ped's chunk + 8 neighbours" and "byte-identical flags-off". I built the literal version first and measured it: those two cannot both be true. On the synthetic town (exactly what ?classic=1 boots, gig layer absent), a full 3×3 scan sourced 127 of 855 finds — 14.9 % — from neighbour chunks: finds v2 never made. The frozen v2 crowd would start shopping ~15 % more. That is a covenant breach, in a clause of the same brief that asked for it.

So the fix is scoped to the GIG path — which is also what I actually filed (the finding was always GIG_RANGE-specific):

  • Ordinary shops → own chunk only. v2 semantics, frozen. A shopfront is noticed from its own block.
  • Gig venues → the full 34 m radius, honest across chunk edges. The gig is the "follow the sound" pull that is supposed to reach further — that is the entire reason GIG_RANGE (34) > PATRON_RANGE (18).
  • No gig on → _gigVenues is empty → the neighbour loop is skipped entirely. ?classic and every flags-off boot take the identical code path and identical cost to v2. Byte-identical by construction, not by measurement luck.

Both ranges are < CHUNK (64), so a 3×3 sweep is exact, not an approximation; a fixed dz/dx order keeps exact-distance ties deterministic, as the old single-list scan was.

Before / after

proof before after
cross-chunk in-range peds that see the gig — katoomba 0 % by construction¹ 4/4 = 100 %
— fitzroy (densest, worst case) 0 % by construction¹ 11/11 = 100 %
same-chunk peds (must not change) 100 % 100 %
classic / flags-off: finds sourced from a neighbour 0 (v2) 0 (127 under the rejected full scan)
determinism (2 sims, same seed) identical identical (identity sets byte-equal)
NaN — katoomba / fitzroy / synthetic 0 0
sim cost, katoomba @14:00, 236 peds 0.056 ms/frame 0.057 ms/frame (+2 %)
lookup cost/call — no gig on 0.58 µs 0.58 µs (loop skipped)
lookup cost/call — gig on 0.58 µs 1.32.7 µs

¹ not a small-sample claim: v2 read shopsByChunk.get(ownChunk) only, so a venue in another chunk was never in the scanned list and could not be returned. 0 % is structural.

The lookup is 24× dearer while a gig is on, but it only fires every PATRON_STRIDE (10 m walked), not per frame — hence +2 % of a 0.056 ms frame cost. Against a 16.7 ms budget that is 0.006 %. Fitzroy at 376 peds stayed clean. Minor known inefficiency: with a gig scheduled but closed (e.g. 14:00) the neighbour loop still runs for nothing (0.58 → 1.3 µs) — _gigVenues holds ids, so testing openness would need the lookup it's trying to avoid. Not worth it at +2 %; noted rather than hidden.

Handshakes

  • D → F (gate): the fix is runtime-only — no plan, no golden, no flag, no fetch. ?classic=1 takes the identical code path to v2 (loop skipped), so the classic regression is untouched by construction. Selfcheck green at 161300. No D-side blocker for v5.0-alpha.
  • Carried (deliberate, documented): PATRON_RANGE keeps v2's own chunk-blindness — an ordinary shop 10 m away across a chunk edge still goes unnoticed. That is now a frozen v2 quirk, not an oversight: fixing it costs the classic covenant, and the covenant wins. Revisit only if the covenant is ever relaxed.
  • Still unowned (unchanged): R20's main-street relocation half (heroes 019 % of the densest cluster).

ROUND 22 — v4.0 THE EPOCH CLOSE: citizens at pack scale (ledger #5)

PROCITY-D, 2026-07-16. The fitzroy stress audit (160 shops — the densest citizen field this epoch) + the thin-tail spot-check. Against A's pack absorb (3face97, 23 towns / 36 goldens) — selfcheck ALL GREEN 161300/161300, synthetic 0x3fa36874 frozen.

Fitzroy stress: PASS — the sim doesn't notice

metric fitzroy_real
shops 160 → 139 seated (21 dropped, counted by cause — A's fence at 2× scale)
graph 1859 nodes / 2408 edges (biggest of the epoch)
active peds 376 (avg == max — saturated)
NaN 0
sim cost 0.08 ms/frame at 376 peds
chunk rosters / identity sigs 22 / 454
venues (A's R21 cluster bias) 3, min 8 shops within 160 m
patronage 3086 checks, 79.6 % null, 103 visits
plan gen 194 ms

Nothing needed touching at 2× scale. Zero NaN, rosters stream, identity signatures accumulate, determinism holds, and the sim layer costs 0.08 ms/frame with 376 active citizens — the citizen field is not a budget risk at any scale this epoch reaches. A's cluster bias survives the mecca (min 8 ≥ the ≥3 bar).

Thin tail: "does 12 shops still read alive?" — it depends entirely on CLUSTERING, and one pack town fails

I spot-checked both 12-shop towns rather than one, because they disagree — and the disagreement is the finding:

darwin_real — ALIVE toowoomba_real — DEAD
shops / road ways 12 / 341 12 / 866
median nearest-neighbour spacing 27 m (a real strip) 388 m (14×)
shops within 160 m of the hub 7 of 12 3
median shop → hub 101 m 739 m (max 2117)
densest cluster 6 neighbours 2
min venue neighbours (A's bias) 6 ✓ 1 (no better candidate exists)
open at 14:00 9 9 (hours are NOT the cause)
patronage 1455 checks, 18 visits (13 hero) 1417 checks, 0 finds, 0 visits
NaN / sim cost 0 / 0.05 ms 0 / 0.05 ms

Both towns pass MIN_TOWN_SHOPS. One has a high street; the other doesn't. Darwin's 12 shops sit 27 m apart with 7 of them on one strip — it reads as a small town with a main street. Toowoomba's 12 sit ~388 m apart across a 4.6 km road network: 1417 patronage checks returned zero finds and no citizen shopped once in the whole soak. It isn't a shopping town — it's a road network with occasional shops. (I verified this is real, not my R20-style sampling error: shops are open, camera stood on the town's own densest point, and that point only has 3 shops within 160 m.)

→ FILED for E / Fable (curation, not code): the floor measures the wrong thing. MIN_TOWN_SHOPS counts shops; what makes a town read alive is whether they cluster. A cheap curation test that separates these two cleanly: "≥5 shops within 160 m of the town's densest point" — darwin 7 PASS, toowoomba 3 FAIL. Toowoomba is in the v4.0 pack today and has no main street to walk. Cull it, or accept it as a known thin-tail (it costs nothing — 0 NaN, 0.05 ms/frame — it just has nothing to do). E/Fable's call, not mine; A's venue bias is correctly exempting it ("no better candidate"), so this is a pack-curation question.

Handshakes

  • D → F (gate): citizens at pack scale GREEN. 23-town pack: fitzroy (160 shops / 376 peds) clean at 0 NaN and 0.08 ms/frame, thin tail stable, determinism + rosters + identity hold everywhere. No D-side blocker for v4.0. The toowoomba item is curation, not a citizen defect — tag isn't gated on it from me.
  • Carried to v4.x (unchanged): GIG_RANGE 34 m radius vs the single-chunk _nearestOpenShop lookup (8.7 % of in-range peds chunk-blind; fix = chunk + 8 neighbours).
  • Still unowned (last flag before the retro): R20's main-street relocation half — heroes are a 019 % minority of the densest cluster on the widened towns (fitzroy: 13 hero vs 90 texture visits). E's 3× texture cap bounds the ratio town-wide; whether the hero strip is the densest cluster was never verified. If it matters for v4.x, it's a one-call re-run of this harness.

ROUND 21 — v4.0-beta CLOSE: relocation verify (ledger #4)

PROCITY-D, 2026-07-16. Verifying A's venue cluster-adjacency bias (110c828), which Fable assigned from my R20 relocation finding. selfcheck ALL GREEN 154141/154141 against A's re-pinned goldens.

Verdict: A's bias PASSES acceptance on all five — my finding is closed

Acceptance was "no venue sits with <3 shops in its 160 m radius." Shops within 160 m @ distance-to-retail-hub, before (R20) → after (R21):

town min shops in 160 m before → after
katoomba_real 9 PASS pub 1→9 @1007→466 m · rsl 10→12 @161→148 m · band_room 7→10 @181→161 m
newtown_real 5 PASS rsl 1→11 @2228→29 m · band_room 0→7 @2433→124 m · pub 0→5 @2190→714 m
bendigo_real 5 PASS rsl 0→10 @451→86 m · band_room 0→10 @1157→96 m · pub 1→5 @504→316 m
castlemaine_real 4 PASS pub 1→5 @223→182 m · band_room 6→6 @0 m · rsl 4→4 @155 m
fremantle_real 3 PASS* band_room 2→8 @612→309 m · pub 1→3 @969→1352 m · rsl 2→3 @561→1292 m

My R20 headline is fixed: katoomba's pub — "a gig with no passing crowd", 1 shop at 1007 m — now sits at 466 m with 9 shops. Newtown is the dramatic one: all three venues were 2.22.4 km out with 01 shops; the rsl now sits 29 m from the hub with 11. Castlemaine's floor did NOT need an exemption (min 4).

The crowd followed — katoomba's pub, same density/seed/21:00

R20 (pre-bias) R21 (post-bias)
passing crowd (maxActive) 34 66 (2×)
patron checks 226 442 (2×)
inside frames 1042 1424
tonightRoster 2 3
NaN 0 0

The "gig with no passing crowd" observation is retired. (null % stays ~96 % at 21:00 — retail is shut at night by design; that's my own R20 correction, not a leak.)

Two readings that need care (so nobody mis-reads my own numbers)

  • *fremantle is the weakest pass (*): its pub/rsl clear the ≥3 bar but moved further from the primary hub (969→1352 m, 561→1292 m) — A's bias optimises local cluster adjacency, and fremantle's venues found a secondary cluster. It satisfies the letter of acceptance and the crowd is healthy in practice (pub: roster 6, inside 2741, maxActive 72), so I'm not calling it a miss — just naming it, since "3 shops at 1.3 km" is a thinner high street than katoomba's "9 shops at 466 m". A's call if they want it tighter.
  • venue-win % DROPPING is not a regression. newtown's rsl went 100 %→21 %, fremantle's pub 100 %→62 %. That metric asks "when a check finds a shop, is it the venue?" — it was only ever 100 % because the venue was alone. Post-bias the venue has real neighbours competing for finds, while total crowd went up (newtown rsl: roster 3, inside 1873, maxActive 55). Isolation flatters that number; ignore it post-bias.

Handshakes

  • D → F (gate): relocation verify GREEN. All five towns pass A's acceptance, the pub finding is closed, 0 NaN at every venue, rosters populate. No D-side gate concern for v4.0-beta.
  • FYI F: selfcheck scans every web/assets/towns/*.json on disk, including E's untracked pack WIP. I saw it transiently RED mid-round (newcastle_real: no open-late shop) purely from E's in-flight files, then green again minutes later at 154141 checks. If you gate while E is mid-fetch you may catch a false red — E's pack is non-gating, so check whether a failure is a pack town before believing it.
  • Still filed for v4.x (agreed, not a mid-beta change): GIG_RANGE is a 34 m radius but _nearestOpenShop searches only the ped's own 64 m chunk → 8.7 % of in-range peds are chunk-blind. Fix = chunk + 8 neighbours.
  • Unowned from R20: the main-street relocation half (heroes 019 % of the densest cluster; castlemaine's main street has zero secondhand shops). E's R20 SUBTLE guardrails (texture capped at 3× heroes) address the ratio town-wide; whether the hero strip is again the densest cluster is unverified. Not in my R21 ledger — flagging it so it doesn't get lost at the epoch close.

ROUND 20 — v4.0-beta THE FIVE TOWNS: citizens at density (ledger #7)

PROCITY-D, 2026-07-16. Part 1 of 2: the pre-density baseline — the gig crowd's behaviour on all five real towns' venues (F's district gate owns placement; I own behaviour). This half is unblocked; the density re-audit waits on E's widened caches → A's absorb. Deliberately measured BEFORE the widening so the after-table is a real A/B.

The gig crowd is behaviourally CORRECT on all five real towns

Per town: stream sim on the real graph, setGig on its first venue, 21:00, camera at the venue, ~1400 frames. Every town generates 3 venues (pub/band_room/rsl — charter's 24) on night hours.

town shops door chunks patron checks null % hit venue venue win % roster NaN
katoomba_real 19 ~5 703 97.3 14 74 2 0
newtown_real 18 15 267 95.5 12 100 4 0
fremantle_real 20 16 429 94.9 22 100 7 0
bendigo_real 9 9 343 97.7 8 100 3 0
castlemaine_real 6 5 364 95.9 6 40 4 0
  • 0 NaN on all five. The gig crowd is numerically safe on every real graph.
  • tonightRoster populates on all five (27) — R14 identity continuity holds on real bones.
  • Venue-win % is the surge working: whenever the patronage check finds any shop, the gig venue beats ordinary shopfronts (GIG_RANGE/gigBest preference) — 100% on three towns, 74% on katoomba. castlemaine's 40% is not a leak: at the 6-shop floor, 3 of its 6 shops ARE venues, so the "other shop" hits are mostly other venues. A quirk of the sparse floor, not the mechanism.

CORRECTION to my own first read of that null % — I misattributed it (measurement wins)

My first pass called the 94.997.7 % null rate "the crowd is sparsity-throttled" and made it the headline before/after metric. Both halves of that were wrong, and I'm correcting the record:

  1. It was measured at gig hours. At 21:00 only 4 of 69 katoomba shops are open — retail is shut by design. A high null rate at 21:00 is correct night behaviour, not sparsity. The gig IS the nightlife.
  2. It was sampled at each town's first venue (shops.find(s => s.venue)). For katoomba that is the pub, which sits 1,000 m from the retail cluster with 1 neighbour — I had parked the camera on the one isolated venue in town. Measured there, the daytime null was 99.4 % with 0 visits; measured on the actual main street, the same town same seed gives 77.2 % and 64 visits. The number was an artefact of where I stood.

The gig-crowd table above still stands — it measures the mechanism (0 NaN, rosters populate, venue-win %), which is location-independent. Only the sparsity interpretation was bad.

The real density A/B — the main street, retail hours (14:00), same seed, like-for-like

Camera on the densest retail cluster (the actual main street), pre-widening cache (from git) vs E's widened cache, 2,000 frames each:

main street @14:00 PRE (20 shops) POST (80 shops)
hub density (shops within 160 m) 8 25
null % 90.2 77.2
total visits 17 64 (3.8×)
hero visits (opshop/book/record/video/pawn/toy) 17 19
texture visits (milkbar/dept/stall) 0 45
hero share of visits 100 % 30 %
hero share of shops 84 % 26 %
NaN 0 0

The widening lands John's directive, measured from the crowd side. The secondhand heroes did not lose footfall — they went 17 → 19 visits. The 45 texture visits are added on top, not taken from them. The street is 3.8× busier while the heroes stay over-indexed (26 % of shops, 30 % of visits). That is precisely "the player should notice the town feels more alive, not that the game changed."

Filed, not fixed

  • Venue placement at density (A/F): katoomba's 3 venues — rsl 13 shops within 160 m, band_room 10, pub 1 (1,000 m from the cluster). A gig at the pub has almost no passing retail crowd. It still draws a roster (peds stream everywhere), so it's a quality observation, not a break. Placement is A's/F's lane.
  • GIG_RANGE vs the chunk lookup (mine): GIG_RANGE is a 34 m radius, but _nearestOpenShop searches only the ped's own 64 m chunk, so an in-range ped across a chunk edge is blind to the gig — 8.7 % of in-range ped-samples (852/9,740). Real but small; a hot-path change mid-beta isn't worth it. Fix (chunk + 8 neighbours) is a v4.x candidate.

FINAL — post A's absorb (5d29863), all five towns at density

A's absorb landed (capacity widened, goldens pinned, selfcheck 51866/51866). Re-ran everything against the absorbed graph. The katoomba numbers barely moved (null 77.2→78.1, visits 64→62, hero share 30→29 %) — the pre-absorb read was already sound.

town cache→seated hub null % visits hero texture hero share NaN maxActive rosters sigs
katoomba_real 80→72 (8) 26 78.1 62 18 44 29 % 0 217 15 262
fremantle_real 80→79 (1) 29 71.1 116 23 93 20 % 0 401 24 483
newtown_real 72→67 (5) 12 85.9 44 6 38 14 % 0 268 19 324
bendigo_real 36→35 (1) 14 87.2 45 1 44 2 % 0 358 21 432
castlemaine_real 24→23 (1) 6 94.9 9 0 9 0 % 0 295 20 359

Sim verdict: zero changes needed at 4× density. 0 NaN on all five, chunk rosters stream (1524), identity signatures accumulate (262483), patronage/loiter/bench-sit all fire, up to 401 active peds with no degradation. Determinism unchanged (identity is graph+seed keyed; shops don't touch it). D→F: green.

THE FINDING → for E / C / Fable: the widening MOVED the main street off the secondhand strip

The hero share falling (29→0 %) is not a behaviour bug — patronage is type-agnostic, so visits track the local shop mix exactly. The mix is the story. Main street = the densest shop cluster (what "walk the main street" means to a player):

town main-st shops heroes on main st hero % of main st town-wide hero % hub moved
katoomba 9 → 27 9 (100 %) → 5 100 % → 19 % 25 % 308 m
fremantle — → 30 5 17 % 25 %
newtown — → 13 2 15 % 27 %
bendigo — → 15 1 7 % 25 %
castlemaine 3 → 7 1 → 0 33 % → 0 % 20 % 434 m

Pre-widening the densest cluster WAS the secondhand strip (katoomba: 9 of 9 main-street shops were heroes). Post-widening the town's centre of gravity relocated 308434 m onto the general-retail cluster, where the heroes are a 019 % minority. Castlemaine's main street now has zero secondhand shops (0 of 4 heroes within 160 m of its hub; median hero is 463 m away). The heroes weren't drowned in place — they were demoted: the liveliest street in town is no longer theirs.

  • The good half stands: heroes kept their absolute footfall (katoomba 17 → 18 visits) and the street is 3.8× busier. The widening works; it just overshot the dial.
  • This is exactly the case John's directive pre-authorised: "if a real town's general retail would drown the secondhand strip visually, E may seed-subsample the texture classes (count it, like every drop)." My read (measurement, not a ruling — E/C/Fable own it): subsample texture until the secondhand strip is again the town's densest cluster. The sparse towns need it most (bendigo 7 %, castlemaine 0 %); katoomba at 19 % is borderline. A hero-adjacency bias on texture seating would be the alternative lever (A's).

Also filed — venue placement at density (A / F)

Venues are seeded from the shop pool, and at real density they land off the retail hub:

town venue distance to hub (shops within 160 m)
katoomba pub 1007 m (1) · rsl 161 m (10) · band_room 181 m (7)
fremantle pub 969 m (1) · rsl 561 m (2) · band_room 612 m (2)
newtown all three 21902433 m (01)
bendigo pub 504 m (1) · rsl 451 m (0) · band_room 1157 m (0)
castlemaine band_room 0 m (6) · rsl 155 m (4) · pub 223 m (1)

A gig 2.4 km from the high street has no passing retail trade. A's 5d29863 "pub facade bias" fixed frontage, not town position — katoomba's pub is still 1007 m out with 1 neighbour. Crowd still forms (peds stream with the camera), so it's a quality call, not a break — but "venue placement still lands" is in A's ledger and F's district gate, so it's theirs. Castlemaine shows the good case (band_room on the hub).


ROUND 19 — v4.0-alpha CLOSE: the fragmentation re-measure (ledger #3)

*PROCITY-D, 2026-07-16. A resolved my R18 finding (commit 6fc4b40: junction-protected DP + bridge-joins

  • shopless-island culling, katoomba golden pinned). This is the post-fix re-measure — same harness, same seed. Verdict: the ruling landed clean — the fragmentation is fully resolved and the sim stays untouched.*

Before → after on the real Katoomba graph

metric R18 (my finding) R19 (post-fix)
connected components 105 1
street-metres in the main net 58.2 % 100 %
shops stranded off-main 2 0 — 1 bridged to main, 1 dropped past SEAT_MAX and counted (norm.dropped, plan_osm.js:319)
% near-crowd on tiny fragments ~10 % 0 % (no fragments left)
nodes / edges 872 / 799 830 / 966

Shops: 20 in cache → 19 seated + 1 counted-drop, 0 silently stranded (ruling satisfied). selfcheck 51670/51670 green, katoomba real-roads golden pinned in the output-drift guard.

The DP-collinear junction-drop mechanism is fixed at the source

Re-ran R18's original failing geometry — a dead-straight cross street through a collinear mid-junction (the exact case B and I both hit). R18: DP flattened it, dropped the junction → max-degree 2, disconnected. R19: the junction is protected from DP → degree-4 crossroads forms, 1 component. Fixed.

Sim confirm on the fixed (bridged) graph — still zero changes

Fresh stream sim, real Katoomba, drove the shop blocks: 0 NaN over 1,303,200 samples, footpaths perfect (0 off-footpath, maxDist 12.9 m), 312 active peds, loiter (251) + bench-sit (77) fire, and patronage rose 24→41 visits (peds now reach shops across the connected graph). fragmentPedFraction: 0. The sim needed nothing — exactly as R18 predicted (a single component is strictly easier than 105).

Handshake

  • D → F: fragmentation verdict post-fix is GREEN — real Katoomba is one connected component, 0 peds stranded, 0 NaN, all citizen behaviours hold, determinism intact (A's golden pins it). No D-side gate concern. Tag away.
  • FYI (B/F budget): edges rose 799→966 (junction protection + bridges) though total street-metres fell (islands culled) — flagged for the tris budget alongside the BIG_CITY fix; not a sim concern.
  • No D-side spawn-bias mitigation needed this epoch — A's pass left nothing to mitigate. (Beta backlog item retired.)

ROUND 18 — v4.0-alpha REAL ROADS: citizens on real bones (ledger #5)

PROCITY-D, 2026-07-16. The audit: does the citizen sim behave on real (irregular) street topology? E landed katoomba_real's real road geometry (484 OSM ways → 872 nodes / 799 edges) mid-audit, so this is measured on the real Katoomba graph, not just a proxy. In-shell: fresh CitizenSim built on plan.streets, driven headless-of-render (rAF throttled). No source changes — the finding is the verdict + the failure list.

Verdict: the graph contract IS the contract — the sim needs ZERO changes

The sim's graph-walk is topology-agnostic by construction (seeded random walk over nodes/edges, dead-end U-turn), so real geometry should just work. It does. Proven on a crafted irregular town (deg-4 crossroads, 3 T-junctions, 5 dead-ends, a 30° acute fork, a cycle) AND the real Katoomba graph (7.2° acute junction, a 1303 m edge, 105 components):

assertion crafted irregular real Katoomba
NaN/Inf ped positions 0 / 240k 0 / 1,095,500
off-footpath samples (ped beyond its own edge band) 0 / 240k 0 (maxDist 12.9 m = main offset)
edges traversed 11 / 11 full main network
peds pinned at dead-ends 0 0
loiter fires 154 ev 174 ev
bench-sit fires (R17) 47 ev 60 ev
patronage fires (stream mode) 165 visits 24 visits, 4.3k inside-frames
chunk streaming over non-grid 7 live rosters / 93 active 13 chunks / 307 active
determinism (2 sims, same seed) 0/120k pos mismatch; identity set visit-order-independent byte-identical rerun; 1167 sigs

Identity signature format is intact (chunkKey#i:pedIndex:pvar:height:speed:edge:forward), no sit term — my R17 benchsit stream doesn't perturb it. Nothing in the sim needed touching for real roads.

The measured leak → FILED FOR A: the real-roads graph is fragmented

katoomba_real's street graph comes out in 105 disconnected components. The main component holds 58% of street-metres (52.8 km) and 18 of 20 shops — the core town is coherent — but 42% of street metres and 2 shops strand on 102 islands (9 single nodes, 76 fragments of 24 nodes, 17 of 520). Live cost to citizens: ~10% of near-main crowd presence lands on tiny fragments (aimless wandering, can't reach the main street or its shops); the 2 stranded shops get no main-crowd traffic.

  • Primary cause — expected, not a bug: on real Katoomba the median fragment is 71.5 m from the main net → mostly peripheral / bbox-clipped roads and genuinely-separate service ways, which is what an OSM extract in a bounded box always contains. The main town is coherent; this is the tail.
  • Secondary mechanism (corroborated with Lane B): A's buildRealRoads runs DouglasPeucker per-way before snapping, so a junction point that is collinear on the through-way gets dropped, with no mid-edge split where one way's endpoint lands on another way's segment — a near-straight through-street with a mid-way junction can lose it → the crossing way strands. I hit this three times crafting the test town (fixed by modelling junctions as shared way-endpoints, as OSM does); Lane B independently hit the same DP-collinear drop on A's selfcheck fixture (LANE_B_NOTES §18). On real Katoomba this bites only the 9 of 85 tiny fragments that sit <10 m from the main network (20 within 30 m) — the ones a snap-tolerance / endpoint-split pass could legitimately join. A fidelity knob (charter risk #4).
  • Reconciles with Lane B: B verified real Katoomba's 126 intersections form correctly (= my 27 deg-4 + 99 deg-3 junction nodes) and concluded connectivity is fine — true for the main component. My add is the global measurement B didn't take: those 126 junctions live in a 480-node main component, but the full graph is still 105 components with 42 % of street-metres on islands. Both true, compatible.
  • The sim degrades gracefully — fragment peds just walk their island; zero NaN, no crash. This is A's graph-construction call (connect-tolerance / mid-edge split), not a sim change. A D-side mitigation (bias spawns toward the main component) is a beta item and needs A's connectivity decision first.

Handshakes

  • D → A: the fragmentation measurement above (105 components, 42% street-metres + 2 shops stranded, root cause + the 9 close-fragments that could be joined). Your knob, your call.
  • D → F (gate): the sim is clean on the real graph — no D-side gate concern. Bench-sit + loiter + patronage + determinism all hold on katoomba_real. When you re-run the town matrix with the roads dimension, the citizen path is green from my side.
  • Re-run note: the whole audit is parameterized on plan.streets; it re-runs on any town's real graph in one call (used it on both the crafted town and real Katoomba this round).

ROUND 17 — v3.2 THE TAIL: the drummer's pelvic-lean (ledger #1)

PROCITY-D, 2026-07-16. The R16 honest limit — the upright bent-leg sit read stiff — is fixed. Files rigs.js, band.js. Byte-identical proof holds (my signature discipline).

The sit clip can't carry a pelvic tilt (Hips.quaternion is unusable on these rigs — R16), so the R16 drummer sat bolt-upright. Fix: a small constant forward torso lean applied POST-MIX. rigs.js spawnRig({seated}) now caches the mid-spine bone (seatBone), and seatedLean(spine) rotates it forward ~0.34 rad each frame AFTER the mixer resets it (so it's a constant offset, never an accumulation). band.js calls it for m.seated members only. The drummer now leans into the kit — verified head 0.28 m forward of the hips, reads as a real drummer from the side (the R16 stiffness is gone).

Byte-identical proof: standing rigs get no seatBone (found only in spawnRig's seated block) and seatedLean is only called for seated figs. Verified with the lean live: streamed walkers 188/188 face travel, keeper is a standing rig (stature 1.718, hasSeatBone:false, innerRotY:π). No existing consumer moves. Seated-only, parallel path — same discipline as R16.


ROUND 17 — v3.2 THE TAIL: bench-sit loiter (ledger #2, the carried R16 stretch)

PROCITY-D, 2026-07-16. The loiter hook that's waited since v1: street peds occasionally sit during a window-shop stop. Files rigs.js (makeActor gains an opt-in setSitting), sim.js (the seeded choice + the near-tier toggle). Byte-identical for every non-sitting ped — the load-bearing proof.

The finding I had to solve first

The R16 seated re-plant lives in spawnRig (drummer path) and is scale-naive — it drops inner by the posed lowest-bone height, which only lands at y=0 for unscaled rigs. But the street crowd runs on makeActor rigs that the sim scales per-citizen (fig.scale = height/1.75). So bench-sit is not the one-line "reuse the existing seam" the brief hoped for — the re-plant had to become scale-aware.

What shipped

  • makeActor(rig, {…, sitClip}) gains a third action sitA, played only on setSitting(true) — so walkers, placeholders, and ?classic (no sitClipsitA null → setSitting no-ops) are byte-identical: the sit action never advances the mixer and the plant never moves.
  • setSitting(s) snaps in/out of the sit (no crossfade — a background ped popping to a sit is fine) and does the scale-aware re-plant: after baking the pose it measures the posed lowest bone's world y and drops inner by (worldLowestY figY) / fig.scale.y, landing the posed soles on the footpath at any citizen scale. Standing up restores the cached bindPlantY exactly. Tags procitySeated.
  • sim.js: a dedicated rng stream sitRng = rng(citySeed,'benchsit',id) (independently keyed → does not shift turn/loit/patron, so the walk/loiter pattern and the identity signature are unchanged). At the window-shop loiter trigger the roll fires ALWAYS (deterministic) but only flips c.sit when a sit clip is loaded (?classic inert at the source). The near-tier update toggles setSitting(c.sit && loiter>0). RigPool.release forces setSitting(false) so a pooled rig is never handed out mid-sit.
  • No lean: bench-sitters sit upright (natural on a verandah step), so the drummer's seatedLean is not applied here — only setSitting's pose + re-plant. No bench-position binding — they sit where they loitered (the footpath/verandah edge under the awnings).

Byte-identical proof (in-shell, default boot, seed 20261990)

Forced a near rig through stand → sit → stand, bone-measured (posed skeleton; Box3 is bind-pose for skinned meshes and lies here):

metric standing seated +1 s sit-idle stood back
head bone y 1.466 1.070 1.071 1.466
bone span 1.667 1.299 (∈ seated gate [0.9,1.5]) 1.285 1.667
lowest bone y (feet) 0.027 0.000 (planted) 0.005 (holds) 0.027
inner.position.y 0.929 0.548 0.548 0.929 (restored)
procitySeated false true true false

Head drops 40 cm, feet re-plant to the footpath at citizen scale (0.978), the one-time plant holds through the sit-idle, and standing up restores the rig byte-for-byte. innerRotY:π throughout (facing convention intact). Draws 35 · tris 6k at the seated block (seated peds are already-near rigs — ~0 draw delta).

  • Seeded path fires: 69 distinct peds sat naturally per ~100 s soak (of ~10 window-shop loiters).
  • Determinism unchanged: identity signature -4,2#0:5:1:1.711:1.370:0:-1 — no sit term; plan golden 0x3fa36874, 15277/15277.
  • Classic covenant airtight at the source: ?classic=1fleetSitClip:false, 31 loiters over the soak but everSat:0 (the gate never flips c.sit), zero fetch delta, anySeatedNow:0.

Both R17 ledger items (#1 pelvic-lean, #2 bench-sit) are delivered — the tail is drained.


ROUND 16 — v3.1 THE FLIP: the drummer finally sits + backline unification

PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's backline[]). Files rigs.js, band.js. Verified against C's landed stage.backline[] + E's sit.glb; F's flip/gate pending (I built to F's published seated-stature contract). Byte-identical proof holds (the load-bearing requirement).

The drummer sits (ledger #2) — and Fable's prescribed _rotWithHips is WRONG for these rigs

The R14 recon + F's brief prescribed a _rotWithHips variant (keep Hips.quaternion to preserve the pelvic tilt). In-shell it MANGLES the pose — head folds to hip height, body laid flat — because the fleet rigs' bind-pose Hips orientation differs from the Mixamo clip's, exactly the failure _rotOnly's own comment documents ("a different-orientation source lays it flat"). So the sit clip drops Hips.quaternion like every other clip (rides the existing _rotOnly); the seated read comes from the leg-bone bends + a new seated foot-replant in spawnRig. _rotWithHips is removed.

  • spawnRig({seated}) (OPT-IN): after the clip plays, tick the mixer once and re-plant the POSED lowest bone to y=0 so the drummer's feet sit on the deck (recovers the hip descent). Standing spawns skip it entirely.
  • The drummer: uses fleet.sitClip when present (retiring the R14 SEAT_DROP sink — it sits on the riser and the re-plant seats it), tags fig.userData.procitySeated = true (F's gate). Reads seated from the crowd POV (the kit hides the lower body); measured stature ~1.11.3 m (F's seated gate [0.9,1.5] ✓). Falls back to the SEAT_DROP standing-sunk hack if sitClip is absent (?noassets, or before F wires {sit}), so it never breaks a boot.
  • Honest limit: without the pelvic tilt (Hips.quaternion unusable), it's an upright bent-leg sit — a bit stiff from a hard side/overhead angle, hidden by the kit from the front. A cleaner pose is a manual pelvic-lean pass (v3.2). It's a genuine improvement over the standing-sunk hack (real bent legs, feet on the deck, passes the seated gate) — good enough for the flip.

Byte-identical proof (keepers / browsers / walkers) — the load-bearing part

The changes are a PARALLEL/opt-in path: _rotOnly untouched (walk/idle unchanged), spawnRig's re-plant is seated-gated (default off), fleet.sitClip is additive, makeActor (walkers) untouched. Verified in-shell with fleet.sitClip null AND loaded: streamed walkers 188/188 face travel, keeper is a standing rig (stature 1.718, innerRotY:π, replanted:false — the seated re-plant does NOT run). No existing consumer moves.

→ Lane F: the {sit} load seam (classic-safe by construction)

loadPedFleet(base, { sit }) — pass sit: <gig-layer active> (true on the default boot, false under ?classic=1). Gated because a raw sit.glb fetch would break the classic zero-fetch-delta covenant; default is sit:false, and absent/off → fleet.sitClip null → the drummer falls back gracefully. My code is committed graceful, so it lands safely before F's commit; the drummer only sits once F passes sit:true (same commit as F's [0.9,1.5] seated-stature gate).

Amp at C's stage.backline[] (ledger #5)

_addBackline now plants at C's stage.backline[0] ({x,z,ry,y}, up-stage, clear of every bandPose

  • watchPoint) instead of D's R15 hardcoded pose — C confirmed backline[0] == that pose, so nothing moves on screen (pub 1.44, 0.32, 3.27, ry=π; 0.75 m bass clearance). Since C dropped its primitive ampStacks, D now owns the whole backline: placeholder-persists (a primitive amp shows immediately, the guitar_amp GLB swaps in), and the primitive is the ?noassets fallback (fleet-gated, no fetch). Verified: GLB swaps in, primitive under ?noassets, no crash, 0 fetch.

ROUND 15 — v3.0 close: the amp one-liner (ledger #4)

PROCITY-D, 2026-07-16. Sole task: move band.js _addBackline to C's §3 pose. Everything else (continuity, ratified sit-clip deferral) is closed. The R13 amp sat at z = stage.z + 0.15 (down-stage) — 0.29 m from the bass rig, interpenetrating the bassist (C's audit caught it). Moved up-stage-right to C's blessed pose (stage.x + w·0.36, deckY, stage.z d·0.22) = (1.44, 0.32, 3.27) at the pub: 0.75 m clear of the bass, behind the front line, flanking C's primitive ampStacks. Verified: draw/tri delta 0 (same GLB moved — pub interior 50 draws · ~183k tris), ?noassets unaffected (amp is fleet-gated dressing → props 0, band placeholders, no crash).


ROUND 14 — v3.0 release: identity continuity (the ped you followed is in the crowd)

PROCITY-D, 2026-07-16. The last charter promise. Behind ?gigs=1; files sim.js, band.js, queue.js. No golden moves — both goldens are plan-gen hashes (independent of runtime crowd identity), and an empty roster is byte-identical to R13. Verified in-shell against A's 3-venue plan.

The tonight roster (sim.js) — who came to the gig

A per-venue Map<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 (gated this._gigVenues.has(shopId)), keyed by the ped's stable c.id so re-entry doesn't dup. Unlike R9 occupancy (which clears on emerge), the roster persists past the ped's dwell — "who came tonight".
  • Queue admits — F relays each VenueQueue.admitOne() via citizens.recordVenueEntry(venueShopId, {pedIndex,height,key}).

It's bounded + per-night: cleared automatically on the setGig(id, false) on→off transition (the gig ends → the crowd disperses), so it survives interior exit/re-enter (gig stays on) but resets each night. Pure bookkeeping — no rng draw, no GPU (draw/tri delta 0).

The crowd consumes it (band.js) — the front slots become the roster

GigCrew.spawn(roomGroup, { …, roster }): crowd slot i fills from roster[i] (its {pedIndex,height}), seeded strangers fill the remainder. This is the release-round's load-bearing safety: each crowd slot is an independently-keyed rng stream (gig/crowd:<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.551.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:

  1. _rotOnly breaks a seated pose — it drops Hips.quaternion (pelvic tilt) + all positions (hip descent), giving a "levitating L-sit". Correct wiring needs a parallel _rotWithHips filter + a post-pose foot re-plant in spawnRig — a change to the shared rig stack (keepers/browsers/band/crowd).
  2. A genuinely seated figure is ~1.2 m crown-above-feet, which fails F's no-giants stature floor [1.4,2.0] (flags_check.py:695) — it needs an F smoke exemption for the seated band member. Both land a qa-red change pending an F handshake, in the freeze round, for a polish item — the drummer already reads seated from the crowd POV (money shot) via the SEAT_DROP hack. Rides Fable's existing v3.1 item "bench-sit loiter from the same sit clip". Recipe (for v3.1): scp Sitting_Idle.fbx → Blender --background --python import/export GLB (no bone rename needed) → web/models/peds/sit.glb_rotWithHips loader + spawnRig re-plant + F stature exemption. See LANE_D_NOTES wishlist #1.

ROUND 13 — v3.0-beta the district: the RY fix, instruments/drummer, the queue, multi-venue surge

PROCITY-D, 2026-07-15. Behind ?gigs=1; verified in-shell against A's landed 3-venue plan (pub #116 pubrock / rsl #404 covers / band_room #490 grunge, 14 gigs / 14 posters) and C's landed 4-pose stage (guitar/vocal/bass + a seated drums on the riser). Files: rigs.js, band.js, new queue.js, sim.js. keepers.js / placeholder.js / impostor.js UNCHANGED — the RY fix at the source corrects them for free.

Debt #4 — the RY audit: fixed once at the source (⚠ this changes the flags-off street VISUAL)

Finding (confirmed in-scene): the imported ped GLBs' visual front is local +Z, but everything downstream assumes Zsim.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 buildFigureinner.rotation.y = Math.PI — normalises every rig's front to Z, matching the rest of the system. Then RY_FLIP is deleted from band.js (band/crowd use C's ry verbatim). Because the impostor atlas bakes this same buildFigure/spawnRig output and the layer's yaw-pick already uses the Z convention, near↔mid stays seamless (both flip together). It's purely visual — a Y-rotation leaves every bone's world-Y untouched, so head-height / feet-plant, the no-giants gate, and all seeded identity/determinism are byte-unchanged (no golden move).

Verified: keeper now faces the shop floor (was the back wall); 223/223 gig-mode + 188/188 flags-off streamed walkers face their edge-travel direction (avg dot 1.000; was 1.0); band faces the crowd + crowd faces the stage with RY_FLIP gone; ?noassets placeholder band (which the old flip left facing the backdrop) is now correct too.

  • → Lane C (pose contract, please land in LANE_C_PUB.md): "Rig visual front is canonical local Z (rigs.js buildFigure normalises the GLB's native +Z). All pose ry (counter.stand, bandPoses, watchPoints, browse points) are the Z-front heading the rig should face — no lane compensates; the R12 RY_FLIP is gone." Your existing ry values were already correct under this — nothing to change.
  • → Lane F / Fable (flags-off flag): the fix is in the shared rig stack, so the default (flags-off) street crowd now faces travel instead of moon-walking. Determinism + the golden plan hash are untouched (visual only), but if any regression snapshots pixels it will move — it's a bug fix, re-pin if needed (improvement, not a leak).

Debt #2 — instrument GLBs wired (placeholder-persists, fail-soft, fleet-gated)

band.js keeps its primitive instruments as the immediate/?noassets fallback, then swaps E's manifest GLBs in async when they resolve (same pattern as interiors/glb.js). Role → fitting: guitarelectric_guitar, bassbass_guitar, vocalmic_stand (planted), drumsdrum_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 26 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 26 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 inside ped is hidden on the street (not in the rendered set — verified 0 inside peds rendered) and re-emerges at the door. No double-presence.

Browser rigs (KeeperManager, the leak-proven pattern)

keepers.js gained browse:true (faces the shelf via ry, no player-greet) + pedIndex (pick the exact fleet ped) + seedKey (distinct browsers per shop). Each browser is a merged ped = ~1 draw. Validated over 12 enter/exit cycles: browser count == min(occupancy, 3) 12/12, worst interior 61 draws ≤350 (C's headroom holds), leak-free (0 GPU delta after warmup), disposed on every exit (keepers.disposeAll() in interior_mode).

→ Lane F: the handoff in web/js/world/interior_mode.js (F owns it) — verified working

Two-line wiring at interior build (F already has keepers + current.browsePoints from C):

// 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 (520 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 520s 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 4060% target), walk speed +~14%, and patronage chance up → more peds shelter in shops (measured inside 6→25). Overcast: ~10%. Clear: v1 behaviour. Seed 20261990 rolls clear (v1-identical); use ?weather=rain to force it.

→ Lane F: wiring in web/index.html (F owns the shell)

  1. Shop door points — build once from the plan, feed the sim:
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.20.3 ms; no T-pose. Skinning/skeleton preserved (65 joints), clips retarget unchanged.
  • Silhouettes + atlas materials intact — hi-vis worker (vest/helmet/boots), suit, business, comical kid all read crisp, no atlas seams/melting; identity variety preserved (pickRig unchanged).
  • Impostor bake clean (19 subjects, 16×5 atlas); determinism holds (507 chunk-keyed identities re-derive from seed).

The flip numbers (full density, perChunk 16 — no reduced-density concession)

metric pre-merge (R6) post-merge (R7) budget
worst continuous-walk street view draws 356 ✗ 241 ≤300
worst view tris ~66k 65,899 ≤200k
worst-view near-rigs (throttled to 8) 17 (full) cap 24
draws / near-rig ~7 ~1
30-chunk soak (3 walks = 726 builds/disposes) leak-free leak-free, 0 GPU delta baseline
heap over soak 50→77→54 78→63 (GC, stable) stable

Full-density shipping defaults (set this round)

enableStream default is now perChunk 16, radius 2 (was 8). Near-cap stays 24 (now 24 draws, not 168). NIGHT_LIVELY_FLOOR 0.5 for the open-late block (R6). No other knobs change.

→ Lane F: the flip wiring in web/index.html (F owns the shell — decision #1, flip on my word)

Invert the R5/R6 flag: stream is the default; ?roster=v1 is the escape hatch.

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/onChunkDisposedsim.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=v1streamMode: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 1123) 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.10.3 ms.

  • Draw budget: worst continuous-walk street view must stay ≤300 (CITY_SPEC). Root cause: each decimated ped is 8 sub-meshes but only 2 materials → ~7 draws per near-rig. So a dense street of near-rigs blows the budget:

    stream perChunk worst-view draws worst near density (within 70m, median)
    16 356 15 ~26
    12 314 ✗ 10 ~18
    8 (new default) 291 10 ~13

    So it ships budget-safe at perChunk=8 today (~13 median within 70m — constant + camera-following vs v1's uniform ~13 that thins as the town scales; and it concentrates ~10 near-tier rigs vs v1's 01 — the visible upgrade). Full density (perChunk 16, ~2× the crowd) needs the budget headroom.

Recommendation for the R7 default-on flip

  1. Land the ped sub-mesh merge (merge each ped's 8 sub-meshes by material → 2 → ~2 draws/rig). Then perChunk 16 worst-view ≈ 260 draws — full density under budget. It's a rigs.js fleet-load optimisation and it is F1-safe in practice: v1's default/spawn view has ~0 near-rigs, so the flags-off draw snapshot is unaffected — the merge only cuts draws where near-rigs cluster (the stream path). Also helps v1 street draws + interior keepers. ~1 session; risk = skinned-mesh merge (verify animation). This is the single thing standing between stream and default-on.
  2. Until then: default-on is safe at perChunk=8 (reduced density) — flip is a judgement call on whether ~10 near-rigs + constant far-field beats v1 today, or wait for the merge for the full crowd.

Known non-blockers: near↔mid is a hard LOD switch (documented, imperceptible at 25m); open-shop spawn-bias beyond the night floor is v2-later (needs per-chunk shop data — the sim is graph-only).


ROUND 5 — chunk-streamed roster (v2, behind ?roster=stream) → Lane F wiring below

As-built implementation of the R3 design note (further down). Default-off; v1 path byte-identical. tools/qa.sh --strict GREEN (sim.js doesn't touch the citygen/manifest gates).

What it does. v1 spreads one fixed roster over the whole town, so big-town streets are uniformly sparse. Stream generates + ticks citizens per 64m chunk, keyed so identity is a pure function of (seed, chunkKey, i) — independent of town size / visit order — and windows them to the live chunks around the camera. Density becomes constant per unit street and follows the player. Everything downstream (LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer) is unchanged and stays global across live chunks.

Measured (test page, 9×9 fixture grid, ~±208m):

  • Constant far-field density: citizens within 70m ≈ 58 at spawn, 129 at 150m out, 83 elsewhere — vs v1 (200 pop) which is uniformly ~1215 everywhere (spread too thin). Stream is ~510× the street density at equal total cost, and it doesn't thin as you walk arbitrarily far.
  • Deterministic: 507 chunk-keyed identities re-derive byte-for-byte from the seed (determinism button, stream mode). Same seed + same walk → same residents.
  • Leak-free: after warming ped GPU uploads, 2 full grid walks (~380 chunk build/dispose cycles) → 0 geometry / 0 texture delta; rig pool capped at 30, live chunks bounded at 25 (R=2 → 5×5).
  • Budget: worst view uses the same near-cap (24) + 1 impostor draw as v1; frame 2.31 ms (~432 fps headroom) with ~500 active (most far = cheap position-advance only). Citizen draw/tri contribution is identical to v1's bound.
  • Flag-off = v1: shell with no flag → streamMode:false, citizen 0 signature 0:13:7:1.786:1.493:3:-1exact 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 counter keeperStand pose as a rig).
  • Leak-free: baseline renderer.info.memory = {geometries 213, textures 198}; after 12 enter/exit shop cycles it returned to exactly 213/198 (Δ geom 0, Δ tex 0). Interiors peak ~303 geoms inside, drop back every exit. Confirms the R-review dispose fix (free the keeper's Skeleton only, never the fleet-shared geo/mats) holds in-shell alongside Lane C's interior dispose.
  • Determinism in-shell: seed 20261990 → byte-identical identity signature across a full reload.
  • ?noassets=1: fleet===null, mode stays placeholder, peds + keeper are placeholder actors, zero ped GLB fetches — town fully playable asset-free.

D2 — decimated ped validation (critical path — E1 landed 518678d) SIGN-OFF FOR F

Validated Lane E's decimated fleet (web/models/peds/, ref lane-e/round4-peds):

  • Per-ped tris: 15642805, avg 2450 — all ≤3k (matches E's _peds_decim.json).
  • Rigs bind + animate: skinning preserved, the shared walk.glb/idle.glb retarget unchanged (skeleton/65 joints intact), near actors animate, mixer 0.10.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:69216931, 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 via renderer.toneMappingExposure, which both three and OutputPass read). The call at web/index.html:245 is harmless — keep or drop it, your call.

(Round-1/3 notes below remain valid; the tri-budget finding #1 is superseded by D2's decimation.)

What shipped

file role
web/js/citizens/rigs.js ported rig stack: fleet loader, _canon/canonRig/_rotOnly, buildFigure (head-bone height-normalise + feet plant), spawnRig (single clip), makeActor (walk↔idle crossfade), pickRig
web/js/citizens/placeholder.js seeded low-poly box humanoid (POLY lock) — walks/idles, planted at y=0, hot-swaps to a rig
web/js/citizens/impostor.js 4-yaw sprite-atlas baker + instanced billboard layer (mid tier, 1 draw call)
web/js/citizens/sim.js deterministic roster, footpath lanes, near/mid/far LOD, rig pool, staggered mixer budget, time-of-day density
web/js/citizens/keepers.js one keeper per shop at the counter slot, idle + greet head/body-turn
web/citizens_test.html standalone harness: fixture 3×3 street graph, sliders, tier debug, determinism check
web/models/peds/* 19 rigged GLBs + walk.glb + idle.glb, byte-identical copies from 90sDJsim (checksummed)

The fleet (copied, never edited)

19 rigged peds (17 normal + 2 comical) + 2 clip-only GLBs, ~37 MB total, copied from johnking@100.91.239.7:~/Documents/90sDJsim/web/world/models/peds/ (also present locally at ~/Documents/90sDJsim/…). SHA-verified byte-identical (house law: canonical source stays upstream).

  • Skeletons canonicalise cleanly: mixamorig1…mixamorig…, so one shared walk clip drives all 19 and one shared idle clip too. Verified in-scene — a single walk.glb animates every ped.
  • walk.glb/idle.glb position tracks + Hips.quaternion are stripped (_rotOnly) — without this the different-scale root track inflates peds to giants. Confirmed: no giants/ants across all 19.

Measured budgets (M3 Ultra, citizens_test.html)

Preview note: the in-app browser reports visibilityState:'hidden', which throttles requestAnimationFrame, so the on-screen fps counter reads low (4784). True cost was measured by timing 140 manual update()+render() frames.

200 citizens, midday (the acceptance fixture):

metric measured CITY_SPEC / Lane-D budget verdict
near (rigged) actives 24 (hard cap) ≤ 24
mid (impostor) ~122
far (culled) ~54
mixer update / frame 0.4 ms max, ~0.2 ms avg < 2 ms comfortably
sim logic / frame 0.24 ms avg 4 ms build budget
full frame CPU (update+render) ~2.6 ms → ~380 fps headroom 60 fps (16.6 ms) huge margin
mid tier draw calls 1 (69 instances, 138 tris) 1 per atlas
impostor atlas texture 2048×640 (16×5 cells, 4 yaws × 19 peds) ≤ 2048, < 512 MB total (~5 MB)
determinism live roster ≡ seed recompute, stable while walking same seed → same crowd

Time-of-day density (200 base): 00:00 → 12 active, 06:00 → 70, 12:00 → 200, 15:00 → 150, 21:00 → 36. Curve in sim.js DAY_CURVE.

⚠️ Findings for Lane E / Lane F (need a decision)

  1. The inherited peds are ~49.7k tris each — too heavy for the CITY_SPEC 200k-tri "typical view". 24 near rigs ≈ 1.2M tris; a realistic 16-rig street view already measured 1.02M tris. Framerate holds on M-series (GPU eats it), but this leaves nothing for Lane B buildings. Recommendation: decimate the ped fleet to ~58k tris in the asset pipeline (Lane E) — at 6k/ped the near cap costs ~144k tris, back within budget. The rig stack is indifferent to poly count; this is purely an asset job. Until then, NEAR_MAX (sim.js) is the throttle.

  2. Draw calls scale with near rigs (~6 calls/ped — each GLB has several sub-meshes/materials). 16 rigs ≈ 144 calls; 24 rigs ≈ ~190. Under the 300-call street budget today, but tight once Lane B adds shells. Recommendation: merge each ped's sub-meshes by material on load (BufferGeometryUtils.mergeGeometries) → ~12 calls/ped. Cheap win, deferred (not blocking).

  3. Peds are metallic-PBR (metalness≈0.5) and render black without a scene.environment. The test scene builds a neutral PMREM; the impostor bake is fed the same env. Lane B's shell must set scene.environment (a sky PMREM) or every citizen — near and mid — goes dark. This is wired: CitizenSim reads scene.environment and passes it to the impostor baker.

  4. Impostor material is toneMapped:false and does ACES + sRGB itself (matching ACESFilmicToneMapping, exposure /0.6). The shell DOES animate renderer.toneMappingExposure (Lane B lighting.js sets it per day segment), so mid billboards drift brighter/darker than the near rigs across the day unless matched. Round-3 fix: CitizenSim.setExposure(e) passthrough added — see the Lane F hook below. (For a non-ACES curve the in-shader ACES in impostor.js IMP_FRAG would also need to match; today both are ACESFilmic so exposure is the only variable.)

Hardened by a 4-dimension adversarial code review (see D-progress.md): 6 confirmed defects fixed — non-deterministic fleet order, shared-geometry disposal, impostor under-exposure, unrestored viewport, missing modelMatrix, frozen exposure.

Integration hooks for Lane F

  • new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet })graph = { nodes:[{id,x,z}], edges:[{id,a,b,width,kind}] }. Swap the fixture graph for Lane A's CityPlan.streets. Footpath lanes are derived from edge width + a 0.9 m margin; pedestrians keep to the right of travel.
  • sim.setPopulation(n) (slider / density) · sim.setTimeOfDay(t01) (drive from the shell's day segment) · sim.setPaused(bool) (wire to visibilitychange) · sim.update(dt) each frame.
  • [Round-3] sim.setExposure(e) — one line, needed for dusk/night. The shell already calls citizens.setTimeOfDay(...) in the street loop (index.html:225); add right after it:
    citizens.setExposure(renderer.toneMappingExposure);   // [Lane F] keep mid billboards matched to day/night exposure
    
    Cheap (sets one uniform), safe to call every frame, and survives the placeholder→rig atlas re-bake (stored internally). Verified in citizens_test.html: uniform tracks 0.55↔2.3 and persists across the fleet upgrade. Without it, mid impostors read too bright at night / too dark at noon vs the near rigs. (This is a Lane-F seam edit in index.html; flagged here per the cross-lane note rule.)
  • [Round-3] Keeper fleet-upgrade (enables F §3.4). Keepers today are placeholder-only because F builds new KeeperManager({ camera, citySeed }) with no fleet. To upgrade them to shared GLB rigs, pass the loaded fleet: new KeeperManager({ camera, citySeed, fleet }). keepers.js already picks a rig when fleet.ready and falls back to a placeholder otherwise — no other change. Dispose is leak-free: verified 15 enter/exit cycles with a rig keeper → renderer.info.memory geo/tex return exactly to baseline (_disposeInner frees the clone's skeleton bone-texture only; shared fleet geo/mats are preserved for siblings). Safe to spawn/remove() per interior enter/exit.
  • Chunk streaming (v1.5): roster is currently whole-graph — fine for v1, but big towns look sparse (see the design note "Chunk-streamed roster" below for the why + the full plan).
  • Keepers: keepers.spawn(roomGroup, { x, z, ry, shopId, type }) — feed x,z,ry from Lane C's interior counter places; call on interior build, keepers.remove(handle) on dispose. keepers.update(dt, playerPos) each interior frame.
  • ?noassets=1 verified: full placeholder town, mixers=0, zero crashes.

Chunk-streamed roster — design note ( IMPLEMENTED in R5, behind ?roster=stream)

Round-3 task 3 (design), built in Round 5 — see the "ROUND 5" section at the top for the as-built result + Lane F wiring. This section is the original spec; the implementation followed it: hook-driven (onChunkBuilt/onChunkDisposed) with a poll fallback, chunk-local identity, global near-cap, owner despawn, constructor opt-in (chunkStream). Kept for the rationale.

The problem (measured). setPopulation(N) builds one global roster and spreads it uniformly over all edges (identityOf picks edge = rng()*edgeCount across the whole graph). On the 12-edge test fixture that fills the view. On a real town — "Boolarra Heads" has hundreds of edges — those same N citizens scatter town-wide, so almost all sit beyond the 70 m cull from any one camera (I measured a 27-node generated town: pop 200 → 4 near / 6 mid / 190 far). Raising N to fill the near streets wastes roster + advance cost on citizens nobody can see, and still can't guarantee local density.

The fix. Generate + tick citizens per chunk, keyed so identity is independent of town size and visit order, and stream them in lockstep with Lane B's chunk window (the chunks already building/ disposing around the player). Density becomes constant per unit street, and total roster is bounded by the live-chunk window, not the town.

Design:

  1. Chunk-local identity. Replace the global running id with a per-chunk key: identityOf(citySeed, chunkKey, i) for i in [0, perChunkCount), and choose the citizen's home edge from that chunk's edges (chunkIndex(plan).chunks[chunkKey].edges), not the global list. Then "same seed + same chunk → same people" holds regardless of what else exists or the order chunks were visited — the determinism property that whole-graph keying quietly loses at scale. perChunkCount is seeded per chunk and scaled by district (main-street chunks busier than residential) × densityAt(tod).
  2. Windowing — poll-driven (works today, no Lane B change). Each frame derive the active chunk set from the camera: chunkKey(⌊camX/chunkSize⌋, ⌊camZ/chunkSize⌋) + neighbours within a radius R (R≥1 so a walker never steps into an unloaded chunk before its owner unloads). Diff vs the live set → build sub-rosters for newly-active chunks, dispose for newly-inactive. This mirrors B's own window and needs nothing from B. Alternative — hook-driven: chunks.js exposes an optional ctx.onChunkBuilt (see chunks.js:40 — "inert unless a consumer sets it"); if B also adds onChunkDisposed, drive spawn/despawn off those instead of polling. Prefer this once B commits the pair; poll until then.
  3. Storage. Swap the single this.roster array for this.chunkRosters = Map<chunkKey, Citizen[]>. The per-frame update() iterates the union of live-chunk citizens. Everything downstream is unchanged — LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer already operate per-citizen and are chunk-agnostic; the near-cap + mixer budget stay global across live chunks (nearest-first selection already does the right thing on the merged set).
  4. Ownership + hand-off. A citizen is owned by its spawn chunk. It walks freely (the graph is continuous); because R≥1 keeps neighbours loaded, crossing a chunk boundary is seamless. It despawns only when its owner chunk unloads — even if it's momentarily standing in a still-loaded neighbour (acceptable: that's behind the player, past the cull). No re-keying on cross, so no identity churn.
  5. Budget. Live window ≈ (2R+1)² chunks × perChunkCount. Tune perChunkCount (~1530) so the near streets reach the 24-cap while total roster stays ~150270 — similar cost to today's flat 200, but concentrated where the camera is instead of smeared across the map.

Migration. Opt-in and back-compatible: new CitizenSim({ …, chunkStream: { chunkIndex, chunkSize, radius } }) switches on chunk-keyed identity + windowing; omit it and the current whole-graph roster is unchanged. So v1 ships as-is and v1.5 flips a constructor option — no rewrite of the hot path.

Explicitly NOT in scope here: cross-chunk social groups, per-district behaviour trees, persistent citizens (a citizen you saw yesterday) — all v2 (docs/V2_IDEAS.md).

Clip wishlist (for the mixamo-fetch run — CHECK THE 34 EXISTING CLIPS FIRST)

Only walk + idle are wired today (they ship with the ped fleet). CITY_SPEC says ~/Documents/mixamo-fetch/out/ already holds 34 clips (sit, lean, look-around, phone, …) — these bind directly through the canonical mixamorig skeleton exactly like walk/idle, so wiring them is a data task, not new rig work. Verify each exists before requesting; never re-download.

Wanted for richer loiter/keeper behaviour, in priority order:

  1. sit — bench-sit at benches/verandah steps (sim loiter has the hook; needs the clip). R13: the gig drummer also wants this (or a bespoke drum/play-drums loop) — the 4th band member is currently a standing rig sunk SEAT_DROP behind the kit, which reads seated only from the front. A real seated clip retires the sink hack (band.js SEAT_DROP + the m.seated branch in update).
  2. lean — loiter against a shopfront (window-shopping stops).
  3. look-around / idle-look — window-shopping variety + a better keeper greet than the body-turn.
  4. talk / gesture — pairs chatting on the footpath (spawn in seeded 2-groups).
  5. carry / laden walk — shoppers with bags after a purchase (content-phase tie-in).
  6. browse / reach — keeper stocking shelves; customer reaching a shelf inside interiors.

Bespoke (won't be on Mixamo, hand-author later): crate-riffle at market stalls, till-operation.

Known limitations (honest)

  • Near↔mid swap is a hard LOD switch at ~25 m (hysteresis 24/27 m). At that range a 128px impostor and the full rig subtend near-identical screen size, so it reads as seamless — but it is a switch, not a cross-fade (rig materials are shared across SkeletonUtils clones, so per-instance opacity fade isn't free). If a pop is ever visible after buildings land, the fix is a short dither/fade band.
  • In ?noassets mode, mid impostors use 8 generic placeholder variants while near placeholders are per-citizen coloured — so a citizen's colour can shift slightly crossing 25 m. Fallback-only; with the real fleet, near and mid are the same baked ped.
  • Loiter timing is dt-driven (cosmetic), so exact positions at time T aren't reproducible across runs; identity (who, which ped, height, speed, spawn beat) is fully seeded and asserted.