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

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

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

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

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

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

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

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

121 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 41 (§41.3) — THE CITIZENS COME ALIVE: 46 shipped · 29 wired · 20 on the street

Summary in D-progress.md §Round 41; this is the measured detail. Lane E's web/models/clips/*.glb

  • web/assets/motion_manifest.json are the input, verified before wiring (python3 pipeline/clips_verify.py → GREEN 0/0, 46 clips / 6 groups / 3 498 124 B on today's tree).

The one number that says what the round did

A census of the live crowd at the retail heart, 12 samples × 146 active citizens, same seed, same pose, two arms:

state ?clips=0 (R40) default (R41)
walking 99.3% 78.1%
bench-sit (on a real bench) 9.5%
shopfront lean 7.1%
stopped at a window, own seeded idle 0.4% 5.1%
R17 free sit · R29 glance 0.2% · 0.2% 0.1% · 0.1%
distinct clips in play across the crowd 4 (walk/idle/sit/look, 99.3% on walk.glb) 20

?clips=0 is a new flag in rigs.js that turns the motion library off and NOTHING else. It exists because ?classic=1 is a bad control for this — it also changes the ped pool (17 vs 22), the sit/look/dance clips, the fog, the game and half the shell, so a heap or draw delta measured against it says nothing about the library.

The files, and the one law that holds them together

file what it is
web/js/citizens/postures.js NEW. Pure, THREE-free, load-state-free: the clip pools and the seeded pickers. Node imports it directly (the gate does).
web/js/citizens/clipbank.js NEW. The group loader. Dynamically imported by rigs.js behind the gate, so ?classic=1 does not fetch the module either.
rigs.js loadPedFleet gains clips = dance (the R36 djs = dance trick again: the shell already passes dance: !CLASSIC, so no shell edit was needed). makeActor gains per-instance clip swapping; spawnRig gains setClip.
sim.js · keepers.js · band.js the states.

THE LAW: posture is a pure function of (citySeed, id), decided once, never re-rolled. Lazy loading can change when a posture appears but never which one it is. That is what makes six groups affordable — and it is gated: arm 3 of tools/qa/r41_citizens.py delays every clip GLB by 2 s with a Playwright route and asserts the posture signature is byte-identical to the unstalled run.

The same discipline runs one level down. fleet.clipsRequested is published synchronously by loadPedFleet before the dynamic import even starts, and every roll that can move a citizen (bench, lean, pause) is gated on that — never on fleet.bank, which only answers "is it resident yet". Otherwise the first second of a boot would draw a different number of randoms than the rest of it and "same seed → same crowd" would depend on the network. Every such roll is also drawn unconditionally (the R17/R29 pattern: roll always, act only if allowed), and the POSE falls back to the R2/R16/R29 base clip while the variant is in flight — so a slow fetch changes nothing at all.

Clip → state map (29 of the 46 wired, 17 deliberately not)

category group clips used drives
idle idles.glb all 10 per-citizen deterministic idle (posturesFor().idle) — the resting action of every near-tier actor, so it plays on every window pause, and the seeded shopkeeper idle
locomotion locomotion.glb walk_shopping_bag (1 of 6) the walk pool is ['@walk','@walk','walk_shopping_bag']1 in 3 citizens carries a shopping bag; @walk is the R2 walk.glb
sitlean sitlean.glb all 8 4 sits on Lane B's actual benches, 4 leans on shopfront walls
browse browse.glb 5 of 8 interior browsers at Lane C's browse points, seeded per (shopId, slot)
venue venue.glb 5 of 6 gig crowd widening + venue_bartending for pub/rsl/band_room keepers + venue_headphones for record-shop keepers
social social.glb 0 of 8 not loaded, not fetched. A two-person clip needs a two-person state machine and a partner-pairing pass. 896 KB for a state D does not have. → R42

Not used and why: browse_hold_turn_l/r (172°/147° loop seam — one-shot turns with no turn state), the four turn_* + walk_to_stand (the sim turns instantly at a node; a turn-state machine is R42's job and it is the only thing that makes those five clips mean anything), venue_clap_seated (no seated venue slot yet).

Loop safety is measured, not assumed. E ships loopSeamDeg per clip; clipbank.js puts anything with loopable:false and a seam > 25° on THREE.LoopPingPong instead of repeat. Exactly one pool clip lands there — browse_pick_up (92.15°) — and ping-ponging it is why it reads as crate-digging (down, up, down) rather than a teleport back to the start. The other 28 either loop or have a measured seam ≤ 5° (E's loopable flag is conservative; the seam is the ground truth).

THE BENCH — the state that needed geometry, and how the mirror is kept honest

R17's bench-sit sat a ped down at whatever graph node it stopped at, upright, on air ("no bench-position binding" — its own comment). R41 binds it. Lane B places benches at s = 14, 40, 66, … (step 26 m) on alternating sides of every edge (furniture.js:203-209). sim.js benchStationsFor(edge) mirrors that rule exactly, and a ped can only take a bench on ITS OWN footpath side: the sim's lane perpendicular is forward × (uz, ux) = forward × the furniture perpendicular, so the match condition is side === forward.

The bench's local +Z is its FRONT (B's template puts the backrest at z = 0.2, the seat at y = 0.45) and rig fronts are local Z after the R13 facing-normalise, so a sitter is benchYaw + π, nudged 6 cm onto the seat. The plank is 1.6 m — it seats two, ±0.38 m on a seeded draw. That was a measured need, not a flourish: the first soak put -1,-6#3 and -1,-6#5 on the same station to the centimetre.

A mirror that drifts is a ped sitting on air again, so it is gated, not trusted. Arm 6 of tools/qa/r41_citizens.py walks every station the sim derives inside the streamed window and requires real instanced geometry within 2 cm of it, with the yaw matching mod 2π. Today: 14/14 position, 14/14 yaw, against a scene holding 2 435 instances. CONTROL: the same stations offset 2 m match 0/14 — so "coincides" is a measurement, not a hit on any nearby prop.

→ LANE B, one line, and D deletes the mirror: please export benchStops(plan) from web/js/world/furniture.js next to the existing busShelterStops(plan) (same shape, same reason it exists). D switches to the import and drops benchStationsFor entirely. Until then the gate is the seatbelt.

→ LANE B, observed while mirroring, not fixed (your file): pushYaw(lists.bench, …, yaw + …) with yaw = atan2(ux, uz) maps the bench's local +Z to ALONG the edge, so the bench's 1.6 m plank runs ACROSS the footpath and it faces up the street, not the road — the comment on line 203 says "facing the road". The streetlight arm above it (armYaw, line 197, "arm reaches over the road") has the same convention and so has the same question. D's sitters bind to the bench TRANSFORM, so they sit correctly either way; this is only cosmetic and only yours to rule on.

THE SHOPFRONT LEAN — and the measurement that moved it

First wired to the R17/R29 window-shop loiter, which fires at a graph NODE — i.e. at an intersection, where there is rarely a shopfront to lean on. Measured: 0 leans in a 9 s run. Moved to the R8 patronage stride check (every 10 m walked), which is already the moment the sim asks "is there a shop beside me" — which is exactly when a leaner is beside a wall. Strictly downstream of the duck-in decision, on its own leanstop stream.

The wall anchor uses shop._raw — the door point before R40's footpath clamp. The clamp exists to drag doors onto the walkable strip (its whole job), but a leaner wants the facade the door was derived from, not the kerb the ped walks on. _raw is the shell's lot + front-normal·(d/2+0.6), i.e. 0.6 m in front of the facade; push LEAN_WALL_BACK 0.30 m further out along the edge normal and the ped's back is ~0.15 m off the wall, offset LEAN_SIDE 1.05 m along the frontage so they are not in the doorway, facing the road.

THE WINDOW PAUSE — the measurement that changed the design

The headline of the round is the per-citizen idle. Wired only to R17/R29's node loiter, a census of the live crowd found 0.8% of citizens stopped at any instant — edges are long, node arrivals are rare — so nine of the ten new idles were assigned, deterministic, and never seen. That is a real failure of the brief dressed up as a green tick.

Fix, on the same stride check as the lean: a ped who walks past a shop and neither goes in nor leans on it sometimes just stops and looks at the window, in their own seeded idle. No reposition, no new clip, no new fetch — the actor's resting action already IS this citizen's idle (setIdleClip at acquire). Stopped-citizen share went 0.8% → 5.1%, and all ten idles now appear in a 12-sample census (idle_cocky_lean 1.5/sample down to idle_breathing 0.4/sample).

Rates, and what they buy (all seeded, all tunable in one place at the top of sim.js)

constant value effect at the retail heart
BENCH_STOP_FRAC · BENCH_DWELL 0.40 · 920 s 9.5% of the crowd seated
LEAN_FRAC · LEAN_DWELL 0.12 · 614 s 7.1% leaning
PAUSE_FRAC · PAUSE_DWELL 0.17 · 49 s 5.1% stopped in their own idle

The pooled-actor problem, and why makeActor grew four methods

Near actors are POOLED by ped type and recycled between citizens (R2 decision #1), so a per-citizen idle cannot be baked in at construction — a recycled actor would carry the previous citizen's posture. makeActor now memoises one AnimationAction per SOURCE clip and re-points four live slots (walkA/idleA/sitA/lookA) on acquire, ≤3 per frame (NEW_RIG_PER_FRAME). three.js shares the PropertyMixer bindings per (root, track) across every action on the same root, so the marginal cost of an extra action is its interpolants, not another copy of the skeleton. setSitting(s, clip) and setLooking(l, fade, clip) gained an optional clip and keep their exact R16/R29 semantics with it omitted; setIdleClip(null) / setWalkClip(null) restore the base actions, which is precisely what a citizen whose clip has not landed (or a boot with no bank) gets — the R40 actor, byte for byte.

spawnRig (single-clip figures: keepers, browsers, gig) gained setClip, the self-heal seam: a figure is posed with whatever exists at spawn and upgrades in place the frame its lazily-fetched group lands. Its re-plant generalises R29's lesson — _rotOnly drops the Hips POSITION track, so a clip's authored vertical motion comes out as the FEET moving (R29 measured +0.205 m on look.glb). spawnRig's original seated re-plant samples once at t=0, which is right for a fixed pose and wrong for a clip whose lowest bone travels, so setClip samples the posed skeleton at 6 points across the clip and plants the MINIMUM: soles can never sink through the floor, and the residual float is the clip's own authored range rather than an arbitrary phase's error.

Memory and fetch — the ledger, measured in fresh headless contexts

fetches bytes resident groups / clips
at boot 4 (manifest 16 KB · clipbank.js · idles.glb · locomotion.glb) 1 242 272 B 2 / 16
+ first street sit/lean intent +1 sitlean.glb 1 600 384 B 3 / 24
+ first interior with browsers +1 browse.glb 2 076 440 B 4 / 32
+ first gig night (from the street, setGig) +1 venue.glb 2 602 476 B 5 / 38
ever never social.glb
?clips=0 · ?classic=1 · ?noassets=1 0 0 0 / 0

One honest line on the ?classic=1 fetch delta. It is zero for ASSETS — no clip GLB, no manifest, and not even clipbank.js (dynamic import behind the gate). It is +1 JS module: postures.js (7 098 B, pure data + five pure functions) is a static import of sim.js/keepers.js/ band.js and so is fetched on every boot, exactly as R40's door_snap.js (4 017 B) already is. Zero asset bytes, zero GLB, zero behaviour under the gate — but stated rather than left to be found.

Six fetches at boot was the thing the round warned about; the answer is two GLBs at boot, three on first demand and one never, and each lazy group is promise-cached so N racing callers cost one fetch (proven: the bank does not grow across 6 interior enter/exit cycles). Heap delta (library ON OFF), performance.memory after a forced GC in a fresh context: +3.34 MB and +6.87 MB across two runs — the spread is GC timing, the floor is the resident clip bytes plus parsed AnimationClip overhead. mixerMs is unchanged: median 0.1 ms in both arms, max 0.3 ms vs 0.2 ms.

Zero draw — before/after, same seed, same bookmarks

view ?clips=0 default Δ budget
street_noon 193 193 +0 ≤300
crossroads_busy 108 108 +0 ≤300
night_crowd 128 128 +0 ≤300
market_square 94 94 +0 ≤300
night_neon 111 111 +0 ≤300
interior (record) 110 110 +0 ≤350, margin 240

Worst street view over the five bookmarks: 193 both arms. Ruling 4 gave this round no street headroom and the round did not spend any: the clips are skeleton-only (E: tris = 0, meshes = 0, materials = 0, images = 0 on all six groups), so the ONLY mechanism by which they could move a draw count is repositioning a ped onto a bench or a wall — and that is bounded by the unchanged near-cap of 24 rigs, each of which is already one draw. Caveat stated rather than hidden: 193 is the worst of these five bookmarks on this seed, not the town's global worst; B's R40 number for the street law is 291/300 at a night pose these bookmarks do not reach. The delta is the claim, and the delta is 0.

Leak

tools/qa/r41_citizens.py arm 6b: warm the caches with one enter/exit, then 6 more cycles into the same shop — geometries 118 → 118 (+0), textures 91 → 92 (+1), clip bank flat at 4 groups / 32 clips. The R2 shared-resource disposal contract still holds with more clips resident: AnimationClips hold no GPU resources (these groups are 0 meshes / 0 materials / 0 images), and _disposeInner still disposes only the clone's own Skeleton. Warm-first is deliberate — counting cycle 1 measures cold start, not retention, and doing so is what made this arm read +10 once.

Gates added (both new files, no collision)

  • node tools/qa/r41_postures.mjs — zero deps, ~40 ms. Manifest resolution (every pool id resolves with the right category and group, CONTROL: a bogus id fails the same resolver) · determinism over 1 000 citizens in two module instances (CONTROL: a different seed differs) · stream isolation (the 6 R41 keys collide with none of the 12 pre-R41 keys; CONTROL: all 18 streams produce different draws on the same id) · pool spread (all 10 idles, 90110 each per 1 000) · loop safety (CONTROL: the ping-pong class is non-empty).
  • tools/.venv/bin/python tools/qa/r41_citizens.py — 7 arms in fresh headless contexts against a no-store server: boot ledger · lazy loading · determinism incl. the 2 s forced-stall control · draw table both arms · ?noassets=1 + ?classic=1 · bench binding + control · leak · gig widening.
  • tools/.venv/bin/python tools/qa/r41_shots.py — the two acceptance shots, camera chosen by measurement (bench station with the most shop doors around it, occlusion-raycast, then held while the crowd fills in) and every figure measured for stature.

→ F: three lines for qa.sh. All three exit 0/1 and are deterministic.

The stature law, and why the first version of it was wrong

The shot harness first checked an absolute seated band [0.9, 1.5] m (R16's, written for the drummer on sit.glb) and called a 1.32 m leaner a failure. It is not one: the library's four wall leans take 1525% off a standing crown by construction (you are leaning back), and its four sits run from bolt-upright-in-a-chair to slumped. A fixed band is the wrong instrument. What R10 forbids is a GIANT and what R16 forbids is a FOLD (keeping Hips.quaternion lays the body flat, head at hip height). So the gate now reports stature with its ratio to that citizen's own nominal height and fails on stature > 2.0 m, stature < 55% of nominal, or a nominal height outside the seeded [1.4, 2.0]. Measured today across both shots: leans 93%, sits 7586%, browsers/keepers 93101%, walkers 9699% of nominal. No giant, no fold.

Shots

  • docs/shots/laneD/r41_street_postures.jpg (+ .txt sidecar with the human-sized line) — seed 20261990, camera (11.6, 58.5) yaw 1.890. 8 near-tier rigs, 3 distinct clips: a hi-vis worker seated on a real Lane B bench, a comical ped walking centre-frame, six more walkers (a third of them on walk_shopping_bag). 126 draws / 300.
  • docs/shots/laneD/r41_browse_interior.jpg (+ .txt) — The Op Shop, 3 browse points: a browser at the clothes rack on browse_hold_idle and the keeper at the counter on idle_happy_1. 69/350.

Framing note for whoever re-shoots: "inside the frustum" is not "in the picture". Three runs reported 36 rigs in shot while the JPEG showed footpath and a gum-tree billboard. r41_shots.py now ray-tests every candidate figure from the lens and counts only the ones that can actually be seen, and raycasts the camera pose itself (an unchecked "best score" pose put the lens inside a building twice — high score, grey wall).

Goldens

node web/js/citygen/selfcheck.js157 647/157 647, fingerprint 0x5f76e76, unmoved. No citygen file was touched.

Filed to R42

  1. The turn state. turn_left_90 / turn_right_90 / turn_walk_180 / turn_in_place / walk_to_stand are shipped and unused because the sim turns instantly at a node. A short turn state at _advance's node branch (hold the ped for the clip's duration, blend the heading) makes five clips real and would kill the remaining tell that these are waypoint walkers.
  2. social.glb. 8 clips, 896 KB, needs a partner-pairing pass: two peds who arrive at the same loiter within N metres take social_shake_1/_2 (E flagged the pair) or the conversation loop facing each other. The manifest's pair field is the whole contract.
  3. benchStops(plan) from Lane B (above) retires D's mirror.
  4. browse_hold_walk / browse_hold_turn_* want a browser who walks the shop floor rather than standing at a fixed point — Lane C's browse points would need a small path, not a pose.

ROUND 40 (§40.5) — THE DOOR POINTS AND THE FOOTPATH: the kerb clamp + the gate

Summary in D-progress.md §Round 40; this is the measured detail a later lane will want.

Today's numbers (all re-measured 2026-08-04, tools/qa/door_footpath_check.mjs)

R39 shipped 0 door points on a footpath corpus-wide (0/493 · 0/72 · 0/139 · 0/30 — the (sin,cos) sign at index.html:428-era, B's lineage table). B's R39 +sin/+cos fix, measured on today's tree, before any D change:

town strict footpath walkable front residue classes (walkable)
synthetic@20261990 447/493 · 90.7% 473 · 95.9% shop:carriageway 3 · stall:carriageway 8 · stall:in-a-lot 6 · anchor:off-verge 1 · shop:in-a-lot 2
katoomba_real 71/72 · 98.6% 71 · 98.6% shop:off-verge 1 (0.85 m)
fitzroy_real 138/139 · 99.3% 138 · 99.3% shop:off-verge 1 (0.85 m)
bowral_real 28/30 · 93.3% 28 · 93.3% shop:off-verge 2 (0.85 m)

After D's clamp (door_snap.js in sim.setShops): katoomba 72/72, fitzroy 139/139, bowral 30/30 — 100% on both measures; synthetic unchanged at 473/493 walkable (95.9%). Corpus transitions: fixes 4, breaks 0, moves 7 points total, max move 5.41 m (synthetic) / 0.85 m (real).

The two residue species, and why they got different answers

  1. The 0.85 m kerb miss (fixed here). plan_osm.js:466-468 sets a real-town lot back at max(KERB, roadWidth(pe)/2 + FOOTPATH) from the centreline. On corridors where that exceeds edge.width/2, the facade stands past the corridor edge, so the shell's door point (d/2 + 0.6) lands just OUTSIDE vergeBand(e) — off the walkable strip by under a metre. The ped steered to the gutter shadow, not the footpath. snapDoorToFootpath clamps the point into [kerb+0.45, corridorEdge0.45] of its nearest footpath-bearing edge. Registry's published split, the sim's own edges — no lots, no plan, no rng, so "sim stays graph-only" holds and same seed → same doors.
  2. The plaza fronts (measured, deliberately NOT dragged). The synthetic's big strict-footpath residue is 36 market stalls + arcade shops, 5073 m from every footpath-bearing edge, fronting the market square / covered arcade. An UNCAPPED snap "passes" them (measured: 488/493 strict) by moving doors up to 59.85 m onto a main street they do not front — peds would duck into a stall by vanishing at a point three shops away. That is the R8 defect wearing a green tick. The 6 m cap exists to forbid exactly this; the gate scores plaza ground as a valid FRONT instead.

For whoever owns the derivation next (F's shell / B was last in)

The remaining 20 synthetic misses are artefacts of the fixed nudge at web/index.html:433-437: door = lot + (sin ry, cos ry) · (d/2 + 0.6) assumes 0.6 m in front of the facade midline is clear public ground. On 4.5 m stalls packed in the square, that lands inside the neighbouring stall (6 cases) or past the square's kerb line (8); on two synthetic corner shops it clips the cross street's carriageway. If a round ever needs those: derive the point from buildShopfront's own doorRect (buildings.js:410-416, :583-597 — the actual door, not the lot midline) instead of the nudge. I did not touch it — not my file, and the gate passes without it.

The gate (tools/qa/door_footpath_check.mjs) — what keeps this honest

  • Imports the SHIPPED door_snap.js (the sim's code path, not a copy) and replicates the shell's raw derivation; measures both, so a regression in either shows as its own number.
  • CONTROL: the back point — the exact R8→R39 defect — must score under the floor on every town (reads 0.413.3%). A heading has no falsifiable arm inside the file that computes it (B's R39 keeper insight), so this gate carries one.
  • FIX-ONLY: the clamp may break 0 previously-passing points, ever.
  • Deterministic (two runs byte-identical), ~2 s, zero deps, exit 0/1. → F: one line in qa.sh, please — node tools/qa/door_footpath_check.mjs with the other node gates.
  • Goldens: selfcheck 157,647/157,647, fingerprint 0x5f76e76, run today after the change.

Shot

docs/shots/laneD/r40_front_duckin.jpg — near-tier rig frozen 3.2 m from the VIDEO BARN door, mid-stride ON the footpath under the verandah (staged via the sim's own _beginVisit + setPaused at the threshold; the steering, the stored door point and the entry radius are all shipped code).


ROUND 36 (wave 2.5) — CLASSIC KEEPS ITS PEOPLE: the DJ bodies gated out of ?classic

The ruling (Fable, on F's §36.4 held warn): b339402 (July 20) appended the 5 trellis'd DJ bodies (woman_dj_01/02/03, dj_techno_01, dj_phrtt_01) to PED_NAMES.normal UNCONDITIONALLY — so ?classic=1 fetched them and its seeded crowd pool widened 17→22, six epochs after the covenant froze. Ruled option (b): the five gate OUT of classic's pool, mirroring the sit/look/dance !CLASSIC clip gates. Classic is the v2 town INCLUDING ITS PEOPLE; the DJs walk every other boot.

Mechanism (D's call — one file, rigs.js, per the wave-2.5 pathspec law):

  • PED_NAMES.normal is restored to the covenanted base 17 (exact pre-July-20 list, order preserved). The five move to a new PED_NAMES.djs extension list.
  • loadPedFleet gains djs = dance — a new opt whose DEFAULT tracks the existing dance flag. The shell already passes dance: !CLASSIC (index.html:194), so the classic-ness signal arrives with ZERO shell edits: classic ⇒ dance:falsedjs:false ⇒ no DJ fetch, 17-pool; default ⇒ djs:true ⇒ all 22. Separable for callers wanting clips without bodies (or vice versa).
  • The roster is built djs ? [...normal, ...djs] : normal — the five APPEND, so no base slot is renumbered in either mode: the default-boot 22-list is byte-identical to the pre-ruling list, and classic's 17-list is byte-identical to pre-b339402. pickRig maps r01 * 997 % pool.length, so pool LENGTH is crowd identity — 17 restores the exact pre-July-20 mapping, not merely the width.
  • Known side effect (accepted, noted): citizens_test.html calls loadPedFleet with no opts ⇒ dance:false ⇒ its dev-harness roster shows 17+2, not 22+2. It is not a covenanted surface; a future D round can pass {djs:true} there if the harness wants the five back.

Measured (fresh contexts each, port-isolated no-store servers :8971 current / :8972 pre-b339402 shadow tree, seed 20261990):

check ?classic=1 (gated) default boot (gated)
DJ-GLB fetches 0 of 5 (21 ped-GLB fetches total = 17+2 bodies + walk+idle) 5 of 5
pool 17 normal / 2 comical / 19 all 22 normal / 24 all
crowd census 129 active, 129 named, 19 distinct — all base-17 comical, zero DJ 153 named street peds incl. 37 DJ-bodied (techno 13 / w01 8 / phrtt 7 / w03 6 / w02 3)
gig crowd (F's organic proof) n/a (gig layer absent) preserved — pinned seed's venue crew (12) includes dj_phrtt_01 + woman_dj_02 in the crowd
console errors 0 0

The pre-July-20 identity check (the R29 lesson: measure, don't assume): booted ?classic=1 on a shadow tree whose rigs.js is b339402^ (everything else current), same seed, same server law. Roster arrays identical; the full 129-citizen census — every (id, pedRoll, pedIndex, pedName) tuple — is byte-identical to the gated tree's classic boot. Classic's crowd is not just 17-wide; it is the SAME crowd, ped for ped, as before July 20. (This held by construction — the five were appended, so gating them off restores both list content and length — but it is now held by measurement too.)

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.