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

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

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

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

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

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

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

1363 lines
94 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LANE D — NOTES (measured budgets + clip wishlist)
*Written by PROCITY-D, 2026-07-14. Standalone verification via `web/citizens_test.html`.
Reference stack ported from `90sDJsim/web/world/index.html` ~405520 (loadRig/spawnRig/_canon/
_rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.*
---
## ROUND 29 — v6 SPIKE 1: `look.glb` bound and wired — peds glance (wave 0)
*PROCITY-D, 2026-07-17. The spike finally reaches the game: E's `look.glb` (mesh-free, 65 joints, 6.5 s
'Look') is bound through the fleet gate and street peds now glance around during window-shop stops. `lean`
is not here and never was — E measured the stash (R28). Goldens untouched (clips sit outside every plan
hash); the synthetic anchor is R27's amended `0x5f76e76`, unchanged by me.*
### What shipped
- **`rigs.js` — `loadPedFleet(base, { sit, look })`** → `fleet.lookClip`, the exact twin of the R16 sit gate.
The shell passes `look: !CLASSIC`, so **`?classic` fetches nothing** — a raw fetch would have breached the
zero-fetch-delta covenant (E flagged 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 1012 @161148 m · band_room 710 @181161 m |
| newtown_real | **5** PASS | **rsl 1→11 @2228→29 m** · band_room 07 @2433124 m · pub 05 @2190714 m |
| bendigo_real | **5** PASS | rsl 010 @45186 m · band_room 010 @115796 m · pub 15 @504316 m |
| castlemaine_real | **4** PASS | pub 15 @223182 m · band_room 66 @0 m · rsl 44 @155 m |
| fremantle_real | **3** PASS* | band_room 28 @612309 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 (9691352 m, 5611292 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.278.1, visits 6462, hero share 3029 %)
the pre-absorb read was already sound.
| town | cacheseated | hub | null % | visits | hero | texture | hero share | NaN | maxActive | rosters | sigs |
|---|---|---|---|---|---|---|---|---|---|---|---|
| katoomba_real | 8072 (8) | 26 | 78.1 | 62 | 18 | 44 | 29 % | **0** | 217 | 15 | 262 |
| fremantle_real | 8079 (1) | 29 | 71.1 | 116 | 23 | 93 | 20 % | **0** | 401 | 24 | 483 |
| newtown_real | 7267 (5) | 12 | 85.9 | 44 | 6 | 38 | 14 % | **0** | 268 | 19 | 324 |
| bendigo_real | 3635 (1) | 14 | 87.2 | 45 | 1 | 44 | 2 % | **0** | 358 | 21 | 432 |
| castlemaine_real | 2423 (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 (290 %) 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 2441 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 799966 (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 `sitClip` `sitA` 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=1` `fleetSitClip: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)` onoff 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)
```js
// 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 Z* — `sim.js` heading math (`facing = atan2(-dx,-dz)`), C's pose `ry`
(counter.stand / bandPoses / watchPoints / browse points), the placeholder (`placeholder.js:61` builds
toes to Z *on purpose*, "like the GLB rigs"), and the impostor bake/pick convention. So the GLB was the
lone outlier: **keepers faced the back wall, browsers faced away, and every streamed rig walker moon-walked
(faced anti-travel)** — all subtly, for ~12 rounds, because near+mid were consistently-wrong-together
(seamless) and peds are mostly seen from behind. R12's band carried a local `RY_FLIP=π` that patched only
the gig.
**Fix:** one line in `rigs.js buildFigure``inner.rotation.y = Math.PI` — normalises every rig's front
to Z, matching the rest of the system. Then `RY_FLIP` is deleted from `band.js` (band/crowd use C's `ry`
verbatim). Because the impostor atlas bakes this same `buildFigure`/`spawnRig` output and the layer's
yaw-pick already uses the Z convention, near↔mid stays seamless (both flip together). It's **purely
visual** — a Y-rotation leaves every bone's world-Y untouched, so head-height / feet-plant, the no-giants
gate, and all seeded identity/determinism are byte-unchanged (**no golden move**).
**Verified:** keeper now faces the shop floor (was the back wall); **223/223** gig-mode + **188/188**
flags-off streamed walkers face their edge-travel direction (avg dot **1.000**; was 1.0); band faces the
crowd + crowd faces the stage with `RY_FLIP` gone; `?noassets` placeholder band (which the old flip left
facing the backdrop) is now correct too.
- **→ Lane C (pose contract, please land in LANE_C_PUB.md):** *"Rig visual front is canonical **local Z**
(`rigs.js 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: `guitar`
`electric_guitar`, `bass`→`bass_guitar`, `vocal`→`mic_stand` (planted), `drums`→`drum_kit` (planted at the
riser), plus one `guitar_amp` as backline dressing. Manifest is **self-fetched** (promise-cached) so band.js
stays decoupled from C's glb.js, and the whole upgrade is **gated on `fleet.ready`** → under `?noassets`
(fleet null) it never fetches (proven: fresh-tab network shows **0** `procity_fit_*` / `manifest`
requests). Disposal-guarded by a generation counter; GLB clones share the cached geo/mats (detach-only,
never dispose — only the `ownGeo`-tagged primitives are freed). Orientation lives in one `GLB_FIT` table
(guitars need a +Z roll to sling; the kit is scaled 0.66 to fit C's tight 0.73 m vocal↔drums gap).
### Debt #3 — the drummer (4-piece, Fable's ruling)
`bandPoses.forEach` already picks up C's 4th `drums` pose (`seated:true`, riser `y`). The drummer sinks
`SEAT_DROP` (0.52 m) so the kit hides the straight legs and it reads seated **from the front — the crowd
POV / money shot**; from a hard side angle the sink shows (a standing rig faked as seated). A true seated
pose is a clip job — see the wishlist. Build is **defensive**: if John had vetoed to a trio, C simply
omits the pose and the band is 3 with zero D change.
### The outdoor queue (charter item — new `web/js/citizens/queue.js`)
`VenueQueue`: a seeded 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):
```js
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:
```js
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):
```js
// 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:
```js
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.**
```js
const rosterV1 = params.get('roster') === 'v1'; // escape hatch → the old fixed roster
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
graph: plan.streets, fleet,
chunkStream: rosterV1 ? null : { radius: 2 } }); // default-on
```
Hours-aware (optional, recommended): after construction, `citizens.setNightLivelyChunks(keys)` where
`keys` = the openLate shop's lot→chunk + 1-ring (`sim.chunkKeyAt(x,z)`; openLate = `hours[1] >= 22`).
Everything else (`setTimeOfDay`/`setExposure`/`update`/`setPaused`) is unchanged. The
hook-driven window (`onChunkBuilt`/`onChunkDisposed` → `sim.onChunkBuilt/onChunkDisposed`) is still the
preferred precise option; poll works with zero wiring.
**Verified in my harness (test page + runtime-enabled shell):** no-flag boot → `streamMode:true`,
perChunk 16, determinism ✓; `?roster=v1``streamMode:false`, fixed roster, determinism ✓. The
in-shell no-flag-URL boot completes once F lands the two-line flip above — the roster itself is proven
to run correctly in the shell at the shipping default (the soak + flip numbers above are in-shell).
---
## ROUND 6 — streamed-roster "default-on readiness" memo (→ Fable + Lane F, for the R7 flip call)
**Verdict: functionally ready (deterministic, leak-free, hours-aware, composes) — but full-density
default-on is gated on one optimisation (ped sub-mesh merge). Ships budget-safe TODAY at reduced
density.** Flag stays default-off this round. `qa.sh --strict` GREEN (sim.js doesn't touch its gates).
All numbers below are in-shell (real town "Boolarra Heads", seed 20261990), stream runtime-enabled
(F wires `?roster=stream` in F2 — call-site in the R5 section below).
### Soak (gate-2 + far-field, `?roster=stream`) ✅
- **Leak-free**: 3 full street-graph walks = **242 chunk builds / 242 disposes → 0 GPU geometry / 0
texture delta** (renderer.info constant 205/141); **16 shop enter/exit cycles → 0 delta**. JS heap
oscillates 50→77→**54** MB (GC recovers — not monotonic, no leak).
- **Determinism**: 188 live chunk-keyed identities re-derive byte-for-byte from the seed, in-shell.
- **Flag-off byte-identical**: shell citizen-0 signature `0:13:7:1.786:1.493:3:-1` (R4 golden), still
exact after this round's changes. 0 console errors across the soak.
### Hours-aware density ✅ (implemented this round)
Each chunk thins per-frame by the same `densityAt(tod)` curve v1 uses. New: `setNightLivelyChunks(keys)`
gives the open-late block a night floor (`NIGHT_LIVELY_FLOOR=0.5`). Measured: the open-late **video**
shop (hours 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:-1`**exact match** to the R4 golden capture. No perturbation.
### → Lane F: wiring `?roster=stream` in `web/index.html` (F owns the shell)
Minimum (poll-driven, zero Lane B changes — works today):
```js
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**:
```js
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:
```js
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 placeholderrig atlas re-bake
(stored internally). **Verified** in `citizens_test.html`: uniform tracks 0.552.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 (R1
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 R1 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)
- Nearmid 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.