PROCITY/docs/LANES/LANE_D_NOTES.md
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

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

1876 lines
129 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 42 (§42.4) — RECAST, WIRED: five bodies swapped in place, and the bench mirror retired
Summary in `D-progress.md` §Round 42; this file keeps the parts a future lane needs to *reuse*.
Gates: `tools/qa/r42_cast.py` (new, six arms), `tools/qa/r42_shots.py` (new, two frames),
`tools/qa/r41_citizens.py`, `tools/qa/interior_scale_check.py`, `tools/qa/r41_shots.py`.
### THE LAW: a roster swap is an IN-PLACE assignment, never a delete-and-append
`pickRig` indexes `PED_NAMES` and `fleet.all` (= `normal ++ comical`), and every citizen stores its
result as `pedIndex`, which is *in the identity signature* and *is the impostor atlas's subject
index*. So the pool LENGTHS are the identity, and:
- **Replacing a name at its index** changes the wardrobe and nothing else. Measured: the identity
signature over 150 active citizens is **byte-identical** before and after the swap — 0 differing
lines, same seed, same bookmark, fresh contexts.
- **Deleting a name** shortens the pool, renumbers every slot after it, moves every seeded identity
downstream and re-indexes the atlas. That is the R2 fleet-order bug arriving through a different
door. Lane E deliberately left the rejects in `PED_NAMES` for exactly this reason so the swap
could be one atomic edit.
The same law applies to the gated `djs` tail: it is APPENDED to `normal`, so `?classic=1` still
shortens the pool from the end without renumbering a base slot.
### VERIFYING A PED CONTRACT FROM THE GLB BYTES — and the one-line trap that makes it vacuous
`tools/qa/r42_cast.py` arm 1 reads the GLB JSON chunk directly (no browser, no three, no deps) and
replays **rigs.js's own filter** over every clip the fleet can bind — the 8 base ped clips plus Lane
E's 46-clip library, 54 in all — counting bindable tracks per ped. A contract-clean ped scores
**3 456 / 3 460**, worst single clip 64, sole unbound node `mixamorig:Reference`.
**The trap:** glTF animation channels use `target.path = "rotation"`, but three's GLTFLoader renames
the track to `.quaternion`, and `_rotOnly` filters on `.quaternion`. Filter the raw glTF path and you
match **zero tracks on every ped**, the totals come out `0/0`, and the arm passes while measuring
nothing. Map `rotation→quaternion`, `translation→position` before comparing.
**Every gate needs a control that can fail, and this one was on disk already:** the retired
`comical_boy_01` scores **3 132 / 3 460** (worst clip 58) because six of its thumb bones —
`{Left,Right}HandThumb{2,3,4}` — are named without the `mixamorig:` prefix. A ped that shipped for
41 rounds had been silently dropping six tracks per clip. That is what "the node count is not the
test" means in practice.
### DRAW-NEUTRALITY IS NOT MEASURABLE AS A TOTAL. Measure the crowd as a difference.
A street total is a number about the whole town on a shared tree with four other lanes landing work
in the same hour, sampled while a wall-clock crowd walks through frame. Diffing two of them cannot
resolve a five-body swap. What can: render the frame, hide `citizens.group`, render again, and take
the difference — the crowd's own cost, in one frame, with nothing else changing.
Measured at all 8 bookmarks: **the whole crowd costs `nearRigs × 1 + 1`** — one draw per near-tier
rig (every ped GLB is one primitive and one material since the R7 merge) and exactly one for the
entire instanced billboard layer however many mid citizens it holds (37 at `street_noon`, 36 at
`patronage_door`). There is no mechanism by which a one-for-one swap can move that. Use this
decomposition instead of a total whenever the question is "did MY change cost draws".
### THE IMPOSTOR SEAM: 1.000 was never the contract, so use the roster as its own control
R2's bug was a **systematic** 1.67× — a double-tone-mapped impostor material, every ped at once. The
instrument for "does the bake still match its rigs" is therefore not an absolute tolerance. The bake
lights the ped with its own key+hemi+env and the street lights it with the sun, so the
billboard/rig luminance ratio is per-albedo: the **19 bodies this round did not touch span
0.4702.100 (median 0.972)**, and the worst-matched body in the town is `man_dj_streetwear_01` at
0.470 — shipped long before this round.
So the arm asks two questions instead of one: is there a **systematic** shift (new-five median
1.098 vs untouched median 0.972 = **1.13×**), and is any individual body **outside the spread the
town already ships** (none is). Method: stage the subject in the live shell at a fixed spot in front
of the camera, once as a near rig posed at the atlas's own bake phase (`0.35 + idx·0.017`) and once
as a single instance of the LIVE atlas; screenshot each against an EMPTY frame and count only the
pixels that differ. Crop to the billboard quad's own projected box — an unbounded full-frame
difference reported 68 000 "ped" pixels the moment a distant chunk streamed in. Absolute ratios
wobble a little run to run (ACES makes a global exposure ramp non-linear), so every subject is
measured **inside one run** and the claim is the within-run comparison.
### THE BENCH MIRROR IS GONE — and this is why mirrors are not gates
R41 shipped a copy of Lane B's bench placement rule in `sim.js` and filed the seam. B shipped
`benchStops(plan)` in §42.5 *and, in the same round, fixed the rule the mirror had copied*: benches
had been placed with `yaw = atan2(ux, uz)` — local +Z **down the street** — while both files' comments
said "facing the road". The mirror was faithful to a wrong line, and a faithful mirror of a wrong
line is still wrong.
```js
import { benchStops } from '../world/furniture.js';
// benchStationsFor(e) is now a thin adapter: wrap the sim edge back into the two-node plan shape
// resolveEdges() wants, and hand it to Lane B's own rule. No arithmetic of its own.
```
Three things worth carrying forward:
1. **The import must be STATIC.** The bench-stop check draws three seeded randoms per crossed
station, so a dynamically-imported station list draws zero randoms until it lands and "same seed
→ same crowd" starts depending on the network. `chunks.js` already imports `furniture.js` on
every boot (classic and `?noassets` included), so on the game path this costs no fetch, no module
and no behaviour. On the standalone `citizens_test.html` it does add one depot fetch
(`procity_street_bench_01.glb`, fail-soft): page smoked green, 507 active, 0 console errors.
2. **`_seatPose` needed no edit**, because it was written in the bench's own local frame (+Z front,
+X plank). The corrected yaw carries straight through it. Live sitters measured: **+0.06 m in
front of the bench origin, ±0.38 m along the plank, 0.0° between the sitter's look direction and
the direction to the road.**
3. **The station COUNT is what determinism depends on**, not the yaw — same cadence, same side rule,
same `len 6` end margin ⇒ same number of randoms drawn. Identity and posture signatures are
byte-identical across the rewire (three-way: pre-swap, post-swap, post-bench-rewire).
Gate: `r41_citizens.py` arm 6 now reads **14/14 stations matched to real instanced geometry within
2 cm and 14/14 on yaw**, control at 2 m offset matching 0/14. Station-level geometry check across 40
stations: bench front vs direction-to-road **0.0° (min = median = max)**, front vs street 90.0°.
### A SCAN-THEN-RE-ENTER SHOT HARNESS PHOTOGRAPHS THE PAST
`r41_shots.py`'s browse arm walked up to 8 occupied shops at ~3.1 s each, kept the id of the best,
exited, and re-entered it 1625 s later. Browsers are a function of LIVE patronage occupancy, so
whether they are still there on the re-entry is not something the harness controls.
**Measure the filing before you fix it.** F filed this as *deterministically* red; run verbatim in
its own harness on today's tree it came back **green** (`browsePoints: 3, browsers: 1`), and two
isolated repros agreed (one: 2 browsers still present, occupancy 2 → 2, 16.4 s scan). So the defect
is not "always red" — it is **flaky by construction**, and that is precisely the property that cannot
go `--strict`. Fixing a flake by widening a timeout or retrying hides it; the fix is to **take the
frame inside the room, the moment a candidate beats the incumbent, and never leave.** The winning
shop's occupancy at entry and at the end of the scan is printed into the sidecar on every run so the
window stays legible rather than becoming a story in a comment.
Generalise: any harness that CHOOSES a subject by scanning a live simulation and then RETURNS to it
is racing that simulation. Shoot in place, or freeze first (Lane B's `DBG.freeze()`, R42 §42.5).
---
## ROUND 41 (§41.3) — THE CITIZENS COME ALIVE: 46 shipped · 29 wired · 20 on the street
Summary in `D-progress.md` §Round 41; this is the measured detail. Lane E's `web/models/clips/*.glb`
+ `web/assets/motion_manifest.json` are the input, verified before wiring (`python3
pipeline/clips_verify.py` → GREEN 0/0, 46 clips / 6 groups / 3 498 124 B on today's tree).
### The one number that says what the round did
A census of the live crowd at the retail heart, 12 samples × 146 active citizens, same seed, same
pose, two arms:
| state | `?clips=0` (R40) | default (R41) |
|---|---:|---:|
| walking | **99.3%** | 78.1% |
| bench-sit (on a real bench) | — | **9.5%** |
| shopfront lean | — | **7.1%** |
| stopped at a window, own seeded idle | 0.4% | **5.1%** |
| R17 free sit · R29 glance | 0.2% · 0.2% | 0.1% · 0.1% |
| distinct clips in play across the crowd | **4** (`walk`/`idle`/`sit`/`look`, 99.3% on `walk.glb`) | **20** |
`?clips=0` is a new flag in `rigs.js` that turns the motion library off and NOTHING else. It exists
because `?classic=1` is a bad control for this — it also changes the ped pool (17 vs 22), the
sit/look/dance clips, the fog, the game and half the shell, so a heap or draw delta measured against
it says nothing about the library.
### The files, and the one law that holds them together
| file | what it is |
|---|---|
| `web/js/citizens/postures.js` | **NEW.** Pure, THREE-free, load-state-free: the clip pools and the seeded pickers. Node imports it directly (the gate does). |
| `web/js/citizens/clipbank.js` | **NEW.** The group loader. **Dynamically imported** by rigs.js behind the gate, so `?classic=1` does not fetch the module either. |
| `rigs.js` | `loadPedFleet` gains `clips = dance` (the R36 `djs = dance` trick again: the shell already passes `dance: !CLASSIC`, so **no shell edit was needed**). `makeActor` gains per-instance clip swapping; `spawnRig` gains `setClip`. |
| `sim.js` · `keepers.js` · `band.js` | the states. |
**THE LAW: posture is a pure function of (citySeed, id), decided once, never re-rolled.** Lazy
loading can change *when* a posture appears but never *which one it is*. That is what makes six
groups affordable — and it is gated: arm 3 of `tools/qa/r41_citizens.py` delays **every** clip GLB by
2 s with a Playwright route and asserts the posture signature is byte-identical to the unstalled run.
The same discipline runs one level down. `fleet.clipsRequested` is published **synchronously** by
`loadPedFleet` before the dynamic import even starts, and every roll that can move a citizen (bench,
lean, pause) is gated on that — never on `fleet.bank`, which only answers "is it resident yet".
Otherwise the first second of a boot would draw a different number of randoms than the rest of it and
"same seed → same crowd" would depend on the network. Every such roll is also drawn
**unconditionally** (the R17/R29 pattern: roll always, act only if allowed), and the POSE falls back
to the R2/R16/R29 base clip while the variant is in flight — so a slow fetch changes nothing at all.
### Clip → state map (29 of the 46 wired, 17 deliberately not)
| category | group | clips used | drives |
|---|---|---|---|
| idle | `idles.glb` | all 10 | **per-citizen deterministic idle** (`posturesFor().idle`) — the resting action of every near-tier actor, so it plays on every window pause, and the seeded shopkeeper idle |
| locomotion | `locomotion.glb` | `walk_shopping_bag` (1 of 6) | the walk pool is `['@walk','@walk','walk_shopping_bag']`**1 in 3 citizens carries a shopping bag**; `@walk` is the R2 `walk.glb` |
| sitlean | `sitlean.glb` | all 8 | 4 sits on **Lane B's actual benches**, 4 leans on **shopfront walls** |
| browse | `browse.glb` | 5 of 8 | interior browsers at Lane C's browse points, seeded per (shopId, slot) |
| venue | `venue.glb` | 5 of 6 | gig crowd widening + `venue_bartending` for pub/rsl/band_room keepers + `venue_headphones` for record-shop keepers |
| social | `social.glb` | **0 of 8** | **not loaded, not fetched.** A two-person clip needs a two-person state machine and a partner-pairing pass. 896 KB for a state D does not have. → R42 |
Not used and why: `browse_hold_turn_l/r` (172°/147° loop seam — one-shot turns with no turn state),
the four `turn_*` + `walk_to_stand` (the sim turns instantly at a node; a turn-state machine is R42's
job and it is the only thing that makes those five clips mean anything), `venue_clap_seated` (no
seated venue slot yet).
**Loop safety is measured, not assumed.** E ships `loopSeamDeg` per clip; `clipbank.js` puts anything
with `loopable:false` **and** a seam > 25° on `THREE.LoopPingPong` instead of repeat. Exactly one pool
clip lands there — `browse_pick_up` (92.15°) — and ping-ponging it is why it reads as crate-digging
(down, up, down) rather than a teleport back to the start. The other 28 either loop or have a
measured seam ≤ 5° (E's `loopable` flag is conservative; the seam is the ground truth).
### THE BENCH — the state that needed geometry, and how the mirror is kept honest
R17's bench-sit sat a ped down at whatever graph node it stopped at, upright, on air ("no
bench-position binding" — its own comment). R41 binds it. Lane B places benches at
`s = 14, 40, 66, …` (step 26 m) on alternating sides of every edge (`furniture.js:203-209`).
`sim.js benchStationsFor(edge)` mirrors that rule exactly, and a ped can only take a bench on ITS
OWN footpath side: the sim's lane perpendicular is `forward × (uz, ux)` = `forward ×` the furniture
perpendicular, so the match condition is `side === forward`.
The bench's local +Z is its FRONT (B's template puts the backrest at z = 0.2, the seat at y = 0.45)
and rig fronts are local Z after the R13 facing-normalise, so a sitter is `benchYaw + π`, nudged
6 cm onto the seat. The plank is 1.6 m — **it seats two**, ±0.38 m on a seeded draw. That was a
measured need, not a flourish: the first soak put `-1,-6#3` and `-1,-6#5` on the same station to the
centimetre.
**A mirror that drifts is a ped sitting on air again, so it is gated, not trusted.** Arm 6 of
`tools/qa/r41_citizens.py` walks every station the sim derives inside the streamed window and
requires real instanced geometry within **2 cm** of it, with the yaw matching mod 2π. Today:
**14/14 position, 14/14 yaw**, against a scene holding 2 435 instances. **CONTROL:** the same
stations offset 2 m match **0/14** — so "coincides" is a measurement, not a hit on any nearby prop.
> **→ LANE B, one line, and D deletes the mirror:** please export
> `benchStops(plan)` from `web/js/world/furniture.js` next to the existing `busShelterStops(plan)`
> (same shape, same reason it exists). D switches to the import and drops `benchStationsFor`
> entirely. Until then the gate is the seatbelt.
>
> **→ LANE B, observed while mirroring, not fixed (your file):** `pushYaw(lists.bench, …, yaw + …)`
> with `yaw = atan2(ux, uz)` maps the bench's local **+Z to ALONG the edge**, so the bench's 1.6 m
> plank runs ACROSS the footpath and it faces up the street, not the road — the comment on line 203
> says "facing the road". The streetlight arm above it (`armYaw`, line 197, "arm reaches over the
> road") has the same convention and so has the same question. D's sitters bind to the bench
> TRANSFORM, so they sit correctly either way; this is only cosmetic and only yours to rule on.
### THE SHOPFRONT LEAN — and the measurement that moved it
First wired to the R17/R29 window-shop loiter, which fires at a graph NODE — i.e. at an
intersection, where there is rarely a shopfront to lean on. **Measured: 0 leans in a 9 s run.** Moved
to the R8 patronage stride check (every 10 m walked), which is already the moment the sim asks "is
there a shop beside me" — which is exactly when a leaner is beside a wall. Strictly downstream of the
duck-in decision, on its own `leanstop` stream.
The wall anchor uses `shop._raw` — the door point **before** R40's footpath clamp. The clamp exists
to drag doors onto the walkable strip (its whole job), but a leaner wants the facade the door was
derived from, not the kerb the ped walks on. `_raw` is the shell's `lot + front-normal·(d/2+0.6)`,
i.e. 0.6 m in front of the facade; push `LEAN_WALL_BACK` 0.30 m further out along the edge normal and
the ped's back is ~0.15 m off the wall, offset `LEAN_SIDE` 1.05 m along the frontage so they are not
in the doorway, facing the road.
### THE WINDOW PAUSE — the measurement that changed the design
The headline of the round is the per-citizen idle. Wired only to R17/R29's node loiter, a census of
the live crowd found **0.8% of citizens stopped at any instant** — edges are long, node arrivals are
rare — so nine of the ten new idles were assigned, deterministic, and **never seen**. That is a real
failure of the brief dressed up as a green tick.
Fix, on the same stride check as the lean: a ped who walks past a shop and neither goes in nor leans
on it sometimes just **stops and looks at the window**, in their own seeded idle. No reposition, no
new clip, no new fetch — the actor's resting action already IS this citizen's idle
(`setIdleClip` at acquire). Stopped-citizen share went **0.8% → 5.1%**, and all ten idles now appear
in a 12-sample census (`idle_cocky_lean` 1.5/sample down to `idle_breathing` 0.4/sample).
### Rates, and what they buy (all seeded, all tunable in one place at the top of `sim.js`)
| constant | value | effect at the retail heart |
|---|---:|---|
| `BENCH_STOP_FRAC` · `BENCH_DWELL` | 0.40 · 920 s | 9.5% of the crowd seated |
| `LEAN_FRAC` · `LEAN_DWELL` | 0.12 · 614 s | 7.1% leaning |
| `PAUSE_FRAC` · `PAUSE_DWELL` | 0.17 · 49 s | 5.1% stopped in their own idle |
### The pooled-actor problem, and why `makeActor` grew four methods
Near actors are POOLED by ped type and recycled between citizens (R2 decision #1), so a per-citizen
idle cannot be baked in at construction — a recycled actor would carry the previous citizen's
posture. `makeActor` now memoises one `AnimationAction` per SOURCE clip and re-points four live slots
(`walkA`/`idleA`/`sitA`/`lookA`) on acquire, ≤3 per frame (`NEW_RIG_PER_FRAME`). three.js shares the
`PropertyMixer` bindings per (root, track) across every action on the same root, so the marginal cost
of an extra action is its interpolants, not another copy of the skeleton. `setSitting(s, clip)` and
`setLooking(l, fade, clip)` gained an optional clip and keep their exact R16/R29 semantics with it
omitted; `setIdleClip(null)` / `setWalkClip(null)` restore the base actions, which is precisely what a
citizen whose clip has not landed (or a boot with no bank) gets — the R40 actor, byte for byte.
`spawnRig` (single-clip figures: keepers, browsers, gig) gained `setClip`, the **self-heal seam**: a
figure is posed with whatever exists at spawn and upgrades in place the frame its lazily-fetched group
lands. Its re-plant generalises R29's lesson — `_rotOnly` drops the Hips POSITION track, so a clip's
authored vertical motion comes out as the FEET moving (R29 measured +0.205 m on look.glb). spawnRig's
original seated re-plant samples once at t=0, which is right for a fixed pose and wrong for a clip
whose lowest bone travels, so `setClip` samples the posed skeleton at 6 points across the clip and
plants the MINIMUM: soles can never sink through the floor, and the residual float is the clip's own
authored range rather than an arbitrary phase's error.
### Memory and fetch — the ledger, measured in fresh headless contexts
| | fetches | bytes resident | groups / clips |
|---|---:|---:|---|
| at boot | **4** (manifest 16 KB · `clipbank.js` · `idles.glb` · `locomotion.glb`) | **1 242 272 B** | 2 / 16 |
| + first street sit/lean intent | +1 `sitlean.glb` | 1 600 384 B | 3 / 24 |
| + first interior with browsers | +1 `browse.glb` | 2 076 440 B | 4 / 32 |
| + first gig night (from the street, `setGig`) | +1 `venue.glb` | 2 602 476 B | 5 / 38 |
| ever | **never** `social.glb` | — | — |
| `?clips=0` · `?classic=1` · `?noassets=1` | **0** | 0 | 0 / 0 |
**One honest line on the `?classic=1` fetch delta.** It is zero for ASSETS — no clip GLB, no
manifest, and not even `clipbank.js` (dynamic import behind the gate). It is **+1 JS module**:
`postures.js` (7 098 B, pure data + five pure functions) is a static import of `sim.js`/`keepers.js`/
`band.js` and so is fetched on every boot, exactly as R40's `door_snap.js` (4 017 B) already is. Zero
asset bytes, zero GLB, zero behaviour under the gate — but stated rather than left to be found.
Six fetches at boot was the thing the round warned about; the answer is **two GLBs at boot, three
on first demand and one never**, and each lazy group is promise-cached so N racing callers cost one fetch (proven:
the bank does not grow across 6 interior enter/exit cycles). **Heap delta (library ON OFF),
`performance.memory` after a forced GC in a fresh context: +3.34 MB and +6.87 MB across two runs** —
the spread is GC timing, the floor is the resident clip bytes plus parsed `AnimationClip` overhead.
`mixerMs` is unchanged: median 0.1 ms in both arms, max 0.3 ms vs 0.2 ms.
### Zero draw — before/after, same seed, same bookmarks
| view | `?clips=0` | default | Δ | budget |
|---|---:|---:|---:|---|
| street_noon | 193 | 193 | **+0** | ≤300 |
| crossroads_busy | 108 | 108 | **+0** | ≤300 |
| night_crowd | 128 | 128 | **+0** | ≤300 |
| market_square | 94 | 94 | **+0** | ≤300 |
| night_neon | 111 | 111 | **+0** | ≤300 |
| interior (record) | 110 | 110 | **+0** | ≤350, margin **240** |
Worst street view over the five bookmarks: **193 both arms**. Ruling 4 gave this round no street
headroom and the round did not spend any: the clips are skeleton-only (E: tris = 0, meshes = 0,
materials = 0, images = 0 on all six groups), so the ONLY mechanism by which they could move a draw
count is repositioning a ped onto a bench or a wall — and that is bounded by the unchanged near-cap
of 24 rigs, each of which is already one draw. **Caveat stated rather than hidden:** 193 is the worst
of these five bookmarks on this seed, not the town's global worst; B's R40 number for the street law
is 291/300 at a night pose these bookmarks do not reach. The delta is the claim, and the delta is 0.
### Leak
`tools/qa/r41_citizens.py` arm 6b: warm the caches with one enter/exit, then **6 more cycles into the
same shop** — geometries 118 → 118 (**+0**), textures 91 → 92 (**+1**), clip bank flat at 4 groups /
32 clips. The R2 shared-resource disposal contract still holds with more clips resident:
`AnimationClip`s hold no GPU resources (these groups are 0 meshes / 0 materials / 0 images), and
`_disposeInner` still disposes only the clone's own `Skeleton`. Warm-first is deliberate — counting
cycle 1 measures cold start, not retention, and doing so is what made this arm read `+10` once.
### Gates added (both new files, no collision)
- `node tools/qa/r41_postures.mjs` — zero deps, ~40 ms. Manifest resolution (every pool id resolves
with the right category and group, **CONTROL:** a bogus id fails the same resolver) · determinism
over 1 000 citizens in two module instances (**CONTROL:** a different seed differs) · stream
isolation (the 6 R41 keys collide with none of the 12 pre-R41 keys; **CONTROL:** all 18 streams
produce different draws on the same id) · pool spread (all 10 idles, 90110 each per 1 000) · loop
safety (**CONTROL:** the ping-pong class is non-empty).
- `tools/.venv/bin/python tools/qa/r41_citizens.py` — 7 arms in fresh headless contexts against a
no-store server: boot ledger · lazy loading · determinism incl. the **2 s forced-stall** control ·
draw table both arms · `?noassets=1` + `?classic=1` · bench binding + control · leak · gig widening.
- `tools/.venv/bin/python tools/qa/r41_shots.py` — the two acceptance shots, camera chosen by
measurement (bench station with the most shop doors around it, occlusion-raycast, then held while
the crowd fills in) and every figure measured for stature.
**→ F: three lines for `qa.sh`.** All three exit 0/1 and are deterministic.
### The stature law, and why the first version of it was wrong
The shot harness first checked an absolute seated band `[0.9, 1.5]` m (R16's, written for the
drummer on `sit.glb`) and called a 1.32 m leaner a failure. It is not one: the library's four wall
leans take 1525% off a standing crown **by construction** (you are leaning back), and its four sits
run from bolt-upright-in-a-chair to slumped. A fixed band is the wrong instrument. What R10 forbids
is a GIANT and what R16 forbids is a FOLD (keeping `Hips.quaternion` lays the body flat, head at hip
height). So the gate now reports stature with its ratio to that citizen's own nominal height and
fails on **stature > 2.0 m**, **stature < 55% of nominal**, or a nominal height outside the seeded
[1.4, 2.0]. Measured today across both shots: leans 93%, sits 7586%, browsers/keepers 93101%,
walkers 9699% of nominal. No giant, no fold.
### Shots
- `docs/shots/laneD/r41_street_postures.jpg` (+ `.txt` sidecar with the human-sized line) — seed
20261990, camera (11.6, 58.5) yaw 1.890. **8 near-tier rigs, 3 distinct clips**: a hi-vis worker
seated on a real Lane B bench, a comical ped walking centre-frame, six more walkers (a third of
them on `walk_shopping_bag`). 126 draws / 300.
- `docs/shots/laneD/r41_browse_interior.jpg` (+ `.txt`) — The Op Shop, 3 browse points: a browser at
the clothes rack on `browse_hold_idle` and the keeper at the counter on `idle_happy_1`. 69/350.
Framing note for whoever re-shoots: "inside the frustum" is not "in the picture". Three runs reported
36 rigs in shot while the JPEG showed footpath and a gum-tree billboard. `r41_shots.py` now
ray-tests every candidate figure from the lens and counts only the ones that can actually be seen,
and raycasts the camera pose itself (an unchecked "best score" pose put the lens inside a building
twice — high score, grey wall).
### Goldens
`node web/js/citygen/selfcheck.js`**157 647/157 647, fingerprint `0x5f76e76`**, unmoved. No citygen
file was touched.
### Filed to R42
1. **The turn state.** `turn_left_90` / `turn_right_90` / `turn_walk_180` / `turn_in_place` /
`walk_to_stand` are shipped and unused because the sim turns instantly at a node. A short turn
state at `_advance`'s node branch (hold the ped for the clip's duration, blend the heading) makes
five clips real and would kill the remaining tell that these are waypoint walkers.
2. **`social.glb`.** 8 clips, 896 KB, needs a partner-pairing pass: two peds who arrive at the same
loiter within N metres take `social_shake_1`/`_2` (E flagged the pair) or the conversation loop
facing each other. The manifest's `pair` field is the whole contract.
3. **`benchStops(plan)`** from Lane B (above) retires D's mirror.
4. **`browse_hold_walk` / `browse_hold_turn_*`** want a browser who walks the shop floor rather than
standing at a fixed point — Lane C's browse points would need a small path, not a pose.
---
## ROUND 40 (§40.5) — THE DOOR POINTS AND THE FOOTPATH: the kerb clamp + the gate
Summary in `D-progress.md` §Round 40; this is the measured detail a later lane will want.
### Today's numbers (all re-measured 2026-08-04, `tools/qa/door_footpath_check.mjs`)
R39 shipped **0 door points on a footpath corpus-wide** (0/493 · 0/72 · 0/139 · 0/30 — the
`(sin,cos)` sign at `index.html:428`-era, B's lineage table). B's R39 `+sin/+cos` fix, measured
on today's tree, before any D change:
| town | strict footpath | walkable front | residue classes (walkable) |
|---|---|---|---|
| synthetic@20261990 | 447/493 · 90.7% | 473 · 95.9% | shop:carriageway 3 · stall:carriageway 8 · stall:in-a-lot 6 · anchor:off-verge 1 · shop:in-a-lot 2 |
| katoomba_real | 71/72 · 98.6% | 71 · 98.6% | shop:off-verge 1 (0.85 m) |
| fitzroy_real | 138/139 · 99.3% | 138 · 99.3% | shop:off-verge 1 (0.85 m) |
| bowral_real | 28/30 · 93.3% | 28 · 93.3% | shop:off-verge 2 (0.85 m) |
After D's clamp (`door_snap.js` in `sim.setShops`): **katoomba 72/72, fitzroy 139/139, bowral
30/30 — 100% on both measures; synthetic unchanged at 473/493 walkable (95.9%)**. Corpus
transitions: fixes 4, breaks 0, moves 7 points total, max move 5.41 m (synthetic) / 0.85 m (real).
### The two residue species, and why they got different answers
1. **The 0.85 m kerb miss (fixed here).** `plan_osm.js:466-468` sets a real-town lot back at
`max(KERB, roadWidth(pe)/2 + FOOTPATH)` from the centreline. On corridors where that exceeds
`edge.width/2`, the facade stands past the corridor edge, so the shell's door point
(`d/2 + 0.6`) lands just OUTSIDE `vergeBand(e)` — off the walkable strip by under a metre. The
ped steered to the gutter shadow, not the footpath. `snapDoorToFootpath` clamps the point into
`[kerb+0.45, corridorEdge0.45]` of its nearest footpath-bearing edge. Registry's published
split, the sim's own edges — no lots, no plan, no rng, so "sim stays graph-only" holds and same
seed → same doors.
2. **The plaza fronts (measured, deliberately NOT dragged).** The synthetic's big strict-footpath
residue is 36 market stalls + arcade shops, 5073 m from every footpath-bearing edge, fronting
the market square / covered arcade. An UNCAPPED snap "passes" them (measured: 488/493 strict)
by moving doors up to **59.85 m** onto a main street they do not front — peds would duck into a
stall by vanishing at a point three shops away. That is the R8 defect wearing a green tick. The
6 m cap exists to forbid exactly this; the gate scores plaza ground as a valid FRONT instead.
### For whoever owns the derivation next (F's shell / B was last in)
The remaining 20 synthetic misses are artefacts of the **fixed nudge** at `web/index.html:433-437`:
`door = lot + (sin ry, cos ry) · (d/2 + 0.6)` assumes 0.6 m in front of the facade midline is clear
public ground. On 4.5 m stalls packed in the square, that lands inside the neighbouring stall
(6 cases) or past the square's kerb line (8); on two synthetic corner shops it clips the cross
street's carriageway. If a round ever needs those: derive the point from `buildShopfront`'s own
`doorRect` (buildings.js:410-416, :583-597 — the actual door, not the lot midline) instead of the
nudge. I did not touch it — not my file, and the gate passes without it.
### The gate (`tools/qa/door_footpath_check.mjs`) — what keeps this honest
- Imports the SHIPPED `door_snap.js` (the sim's code path, not a copy) and replicates the shell's
raw derivation; measures both, so a regression in either shows as its own number.
- **CONTROL:** the back point — the exact R8→R39 defect — must score under the floor on every town
(reads 0.413.3%). A heading has no falsifiable arm inside the file that computes it (B's R39
keeper insight), so this gate carries one.
- **FIX-ONLY:** the clamp may break 0 previously-passing points, ever.
- Deterministic (two runs byte-identical), ~2 s, zero deps, exit 0/1. **→ F: one line in qa.sh,
please — `node tools/qa/door_footpath_check.mjs` with the other node gates.**
- Goldens: selfcheck 157,647/157,647, fingerprint `0x5f76e76`, run today after the change.
### Shot
`docs/shots/laneD/r40_front_duckin.jpg` — near-tier rig frozen 3.2 m from the VIDEO BARN door,
mid-stride ON the footpath under the verandah (staged via the sim's own `_beginVisit` + `setPaused`
at the threshold; the steering, the stored door point and the entry radius are all shipped code).
---
## ROUND 36 (wave 2.5) — CLASSIC KEEPS ITS PEOPLE: the DJ bodies gated out of `?classic`
**The ruling (Fable, on F's §36.4 held warn):** `b339402` (July 20) appended the 5 trellis'd DJ
bodies (`woman_dj_01/02/03`, `dj_techno_01`, `dj_phrtt_01`) to `PED_NAMES.normal` UNCONDITIONALLY —
so `?classic=1` fetched them and its seeded crowd pool widened 17→22, six epochs after the covenant
froze. Ruled option (b): the five gate OUT of classic's pool, mirroring the sit/look/dance `!CLASSIC`
clip gates. Classic is the v2 town INCLUDING ITS PEOPLE; the DJs walk every other boot.
**Mechanism (D's call — one file, `rigs.js`, per the wave-2.5 pathspec law):**
- `PED_NAMES.normal` is restored to the covenanted **base 17** (exact pre-July-20 list, order
preserved). The five move to a new `PED_NAMES.djs` extension list.
- `loadPedFleet` gains **`djs = dance`** — a new opt whose DEFAULT tracks the existing `dance` flag.
The shell already passes `dance: !CLASSIC` (index.html:194), so the classic-ness signal arrives
with ZERO shell edits: classic ⇒ `dance:false``djs:false` ⇒ no DJ fetch, 17-pool; default ⇒
`djs:true` ⇒ all 22. Separable for callers wanting clips without bodies (or vice versa).
- The roster is built `djs ? [...normal, ...djs] : normal` — the five APPEND, so no base slot is
renumbered in either mode: the default-boot 22-list is byte-identical to the pre-ruling list, and
classic's 17-list is byte-identical to pre-b339402. `pickRig` maps `r01 * 997 % pool.length`, so
pool LENGTH is crowd identity — 17 restores the exact pre-July-20 mapping, not merely the width.
- Known side effect (accepted, noted): `citizens_test.html` calls `loadPedFleet` with no opts ⇒
`dance:false` ⇒ its dev-harness roster shows 17+2, not 22+2. It is not a covenanted surface; a
future D round can pass `{djs:true}` there if the harness wants the five back.
**Measured (fresh contexts each, port-isolated no-store servers :8971 current / :8972 pre-b339402
shadow tree, seed 20261990):**
| check | `?classic=1` (gated) | default boot (gated) |
|---|---|---|
| DJ-GLB fetches | **0 of 5** (21 ped-GLB fetches total = 17+2 bodies + walk+idle) | **5 of 5** |
| pool | **17 normal** / 2 comical / 19 all | **22 normal** / 24 all |
| crowd census | 129 active, 129 named, **19 distinct — all base-17 comical, zero DJ** | 153 named street peds incl. **37 DJ-bodied** (techno 13 / w01 8 / phrtt 7 / w03 6 / w02 3) |
| gig crowd (F's organic proof) | n/a (gig layer absent) | **preserved** — pinned seed's venue crew (12) includes `dj_phrtt_01` + `woman_dj_02` in the crowd |
| console errors | **0** | **0** |
**The pre-July-20 identity check (the R29 lesson: measure, don't assume):** booted `?classic=1` on a
shadow tree whose `rigs.js` is `b339402^` (everything else current), same seed, same server law.
Roster arrays identical; the full 129-citizen census — every `(id, pedRoll, pedIndex, pedName)`
tuple — is **byte-identical** to the gated tree's classic boot. Classic's crowd is not just 17-wide;
it is the SAME crowd, ped for ped, as before July 20. (This held by construction — the five were
appended, so gating them off restores both list content and length — but it is now held by
measurement too.)
## ROUND 29 — v6 SPIKE 1: `look.glb` bound and wired — peds glance (wave 0)
*PROCITY-D, 2026-07-17. The spike finally reaches the game: E's `look.glb` (mesh-free, 65 joints, 6.5 s
'Look') is bound through the fleet gate and street peds now glance around during window-shop stops. `lean`
is not here and never was — E measured the stash (R28). Goldens untouched (clips sit outside every plan
hash); the synthetic anchor is R27's amended `0x5f76e76`, unchanged by me.*
### What shipped
- **`rigs.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.