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>
129 KiB
LANE D — NOTES (measured budgets + clip wishlist)
Written by PROCITY-D, 2026-07-14. Standalone verification via web/citizens_test.html.
Reference stack ported from 90sDJsim/web/world/index.html ~405–520 (loadRig/spawnRig/_canon/
_rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.
ROUND 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_NAMESfor 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.470–2.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.
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:
- 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.jsalready importsfurniture.json every boot (classic and?noassetsincluded), so on the game path this costs no fetch, no module and no behaviour. On the standalonecitizens_test.htmlit does add one depot fetch (procity_street_bench_01.glb, fail-soft): page smoked green, 507 active, 0 console errors. _seatPoseneeded 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.- The station COUNT is what determinism depends on, not the yaw — same cadence, same side rule,
same
len − 6end 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 16–25 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.jsonare 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)fromweb/js/world/furniture.jsnext to the existingbusShelterStops(plan)(same shape, same reason it exists). D switches to the import and dropsbenchStationsForentirely. Until then the gate is the seatbelt.→ LANE B, observed while mirroring, not fixed (your file):
pushYaw(lists.bench, …, yaw + …)withyaw = 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 · 9–20 s | 9.5% of the crowd seated |
LEAN_FRAC · LEAN_DWELL |
0.12 · 6–14 s | 7.1% leaning |
PAUSE_FRAC · PAUSE_DWELL |
0.17 · 4–9 s | 5.1% stopped in their own idle |
The pooled-actor problem, and why makeActor grew four methods
Near actors are POOLED by ped type and recycled between citizens (R2 decision #1), so a per-citizen
idle cannot be baked in at construction — a recycled actor would carry the previous citizen's
posture. makeActor now memoises one AnimationAction per SOURCE clip and re-points four live slots
(walkA/idleA/sitA/lookA) on acquire, ≤3 per frame (NEW_RIG_PER_FRAME). three.js shares the
PropertyMixer bindings per (root, track) across every action on the same root, so the marginal cost
of an extra action is its interpolants, not another copy of the skeleton. setSitting(s, clip) and
setLooking(l, fade, clip) gained an optional clip and keep their exact R16/R29 semantics with it
omitted; setIdleClip(null) / setWalkClip(null) restore the base actions, which is precisely what a
citizen whose clip has not landed (or a boot with no bank) gets — the R40 actor, byte for byte.
spawnRig (single-clip figures: keepers, browsers, gig) gained setClip, the self-heal seam: a
figure is posed with whatever exists at spawn and upgrades in place the frame its lazily-fetched group
lands. Its re-plant generalises R29's lesson — _rotOnly drops the Hips POSITION track, so a clip's
authored vertical motion comes out as the FEET moving (R29 measured +0.205 m on look.glb). spawnRig's
original seated re-plant samples once at t=0, which is right for a fixed pose and wrong for a clip
whose lowest bone travels, so setClip samples the posed skeleton at 6 points across the clip and
plants the MINIMUM: soles can never sink through the floor, and the residual float is the clip's own
authored range rather than an arbitrary phase's error.
Memory and fetch — the ledger, measured in fresh headless contexts
| fetches | bytes resident | groups / clips | |
|---|---|---|---|
| at boot | 4 (manifest 16 KB · clipbank.js · idles.glb · locomotion.glb) |
1 242 272 B | 2 / 16 |
| + first street sit/lean intent | +1 sitlean.glb |
1 600 384 B | 3 / 24 |
| + first interior with browsers | +1 browse.glb |
2 076 440 B | 4 / 32 |
+ first gig night (from the street, setGig) |
+1 venue.glb |
2 602 476 B | 5 / 38 |
| ever | never social.glb |
— | — |
?clips=0 · ?classic=1 · ?noassets=1 |
0 | 0 | 0 / 0 |
One honest line on the ?classic=1 fetch delta. It is zero for ASSETS — no clip GLB, no
manifest, and not even clipbank.js (dynamic import behind the gate). It is +1 JS module:
postures.js (7 098 B, pure data + five pure functions) is a static import of sim.js/keepers.js/
band.js and so is fetched on every boot, exactly as R40's door_snap.js (4 017 B) already is. Zero
asset bytes, zero GLB, zero behaviour under the gate — but stated rather than left to be found.
Six fetches at boot was the thing the round warned about; the answer is two GLBs at boot, three
on first demand and one never, and each lazy group is promise-cached so N racing callers cost one fetch (proven:
the bank does not grow across 6 interior enter/exit cycles). Heap delta (library ON − OFF),
performance.memory after a forced GC in a fresh context: +3.34 MB and +6.87 MB across two runs —
the spread is GC timing, the floor is the resident clip bytes plus parsed AnimationClip overhead.
mixerMs is unchanged: median 0.1 ms in both arms, max 0.3 ms vs 0.2 ms.
Zero draw — before/after, same seed, same bookmarks
| view | ?clips=0 |
default | Δ | budget |
|---|---|---|---|---|
| street_noon | 193 | 193 | +0 | ≤300 |
| crossroads_busy | 108 | 108 | +0 | ≤300 |
| night_crowd | 128 | 128 | +0 | ≤300 |
| market_square | 94 | 94 | +0 | ≤300 |
| night_neon | 111 | 111 | +0 | ≤300 |
| interior (record) | 110 | 110 | +0 | ≤350, margin 240 |
Worst street view over the five bookmarks: 193 both arms. Ruling 4 gave this round no street headroom and the round did not spend any: the clips are skeleton-only (E: tris = 0, meshes = 0, materials = 0, images = 0 on all six groups), so the ONLY mechanism by which they could move a draw count is repositioning a ped onto a bench or a wall — and that is bounded by the unchanged near-cap of 24 rigs, each of which is already one draw. Caveat stated rather than hidden: 193 is the worst of these five bookmarks on this seed, not the town's global worst; B's R40 number for the street law is 291/300 at a night pose these bookmarks do not reach. The delta is the claim, and the delta is 0.
Leak
tools/qa/r41_citizens.py arm 6b: warm the caches with one enter/exit, then 6 more cycles into the
same shop — geometries 118 → 118 (+0), textures 91 → 92 (+1), clip bank flat at 4 groups /
32 clips. The R2 shared-resource disposal contract still holds with more clips resident:
AnimationClips hold no GPU resources (these groups are 0 meshes / 0 materials / 0 images), and
_disposeInner still disposes only the clone's own Skeleton. Warm-first is deliberate — counting
cycle 1 measures cold start, not retention, and doing so is what made this arm read +10 once.
Gates added (both new files, no collision)
node tools/qa/r41_postures.mjs— zero deps, ~40 ms. Manifest resolution (every pool id resolves with the right category and group, CONTROL: a bogus id fails the same resolver) · determinism over 1 000 citizens in two module instances (CONTROL: a different seed differs) · stream isolation (the 6 R41 keys collide with none of the 12 pre-R41 keys; CONTROL: all 18 streams produce different draws on the same id) · pool spread (all 10 idles, 90–110 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 15–25% 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 75–86%, browsers/keepers 93–101%,
walkers 96–99% of nominal. No giant, no fold.
Shots
docs/shots/laneD/r41_street_postures.jpg(+.txtsidecar 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 onwalk_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 onbrowse_hold_idleand the keeper at the counter onidle_happy_1. 69/350.
Framing note for whoever re-shoots: "inside the frustum" is not "in the picture". Three runs reported
3–6 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
- The turn state.
turn_left_90/turn_right_90/turn_walk_180/turn_in_place/walk_to_standare 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. social.glb. 8 clips, 896 KB, needs a partner-pairing pass: two peds who arrive at the same loiter within N metres takesocial_shake_1/_2(E flagged the pair) or the conversation loop facing each other. The manifest'spairfield is the whole contract.benchStops(plan)from Lane B (above) retires D's mirror.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
- The 0.85 m kerb miss (fixed here).
plan_osm.js:466-468sets a real-town lot back atmax(KERB, roadWidth(pe)/2 + FOOTPATH)from the centreline. On corridors where that exceedsedge.width/2, the facade stands past the corridor edge, so the shell's door point (d/2 + 0.6) lands just OUTSIDEvergeBand(e)— off the walkable strip by under a metre. The ped steered to the gutter shadow, not the footpath.snapDoorToFootpathclamps the point into[kerb+0.45, corridorEdge−0.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. - The plaza fronts (measured, deliberately NOT dragged). The synthetic's big strict-footpath residue is 36 market stalls + arcade shops, 50–73 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.4–13.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.mjswith 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.normalis restored to the covenanted base 17 (exact pre-July-20 list, order preserved). The five move to a newPED_NAMES.djsextension list.loadPedFleetgainsdjs = dance— a new opt whose DEFAULT tracks the existingdanceflag. The shell already passesdance: !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.pickRigmapsr01 * 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.htmlcallsloadPedFleetwith 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 passeslook: !CLASSIC, so?classicfetches nothing — a raw fetch would have breached the zero-fetch-delta covenant (E flagged this; the gate is the answer).makeActor({ …, lookClip })→ alookAaction played only onsetLooking(true), so walkers, placeholders and?classicnever advance it.setLookingcrossfades (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 dedicatedglancerng 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.RigPoolthreadslookClip; 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=1boot shows a ped glancing on the footpath, head sweeping, feet planted (screenshot taken). Classic/?noassetsuntouched. - 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:301still says "the synthetic golden above stays 0x3fa36874" while line 267 pins the R27-amended0x5f76e76. 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 3–4k _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 (15–25 m) never fires; an aggressive 8 m one
would fire only in half a room, at a distance where a 3–4 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 ~12–15 k tris, ~85 k with the band — comfortably inside the 200 k stress ceiling E's R22 tri diet restored, at 35–38 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 →
_gigVenuesis empty → the neighbour loop is skipped entirely.?classicand 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.3–2.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 2–4× 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=1takes the identical code path to v2 (loop skipped), so the classic regression is untouched by construction. Selfcheck green at 161300. No D-side blocker forv5.0-alpha. - Carried (deliberate, documented):
PATRON_RANGEkeeps 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 0–19 % 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_RANGE34 m radius vs the single-chunk_nearestOpenShoplookup (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 0–19 % minority of the densest cluster on the widened towns (fitzroy: 13 hero vs 90 texture visits). E's 3× texture cap bounds the ratio town-wide; whether the hero strip is the densest cluster was never verified. If it matters for v4.x, it's a one-call re-run of this harness.
ROUND 21 — v4.0-beta CLOSE: relocation verify (ledger #4)
PROCITY-D, 2026-07-16. Verifying A's venue cluster-adjacency bias (110c828), which Fable assigned from
my R20 relocation finding. selfcheck ALL GREEN 154141/154141 against A's re-pinned goldens.
Verdict: A's bias PASSES acceptance on all five — my finding is closed
Acceptance was "no venue sits with <3 shops in its 160 m radius." Shops within 160 m @ distance-to-retail-hub, before (R20) → after (R21):
| town | min shops in 160 m | before → after |
|---|---|---|
| katoomba_real | 9 PASS | pub 1→9 @1007→466 m · rsl 10→12 @161→148 m · band_room 7→10 @181→161 m |
| newtown_real | 5 PASS | rsl 1→11 @2228→29 m · band_room 0→7 @2433→124 m · pub 0→5 @2190→714 m |
| bendigo_real | 5 PASS | rsl 0→10 @451→86 m · band_room 0→10 @1157→96 m · pub 1→5 @504→316 m |
| castlemaine_real | 4 PASS | pub 1→5 @223→182 m · band_room 6→6 @0 m · rsl 4→4 @155 m |
| fremantle_real | 3 PASS* | band_room 2→8 @612→309 m · pub 1→3 @969→1352 m · rsl 2→3 @561→1292 m |
My R20 headline is fixed: katoomba's pub — "a gig with no passing crowd", 1 shop at 1007 m — now sits at 466 m with 9 shops. Newtown is the dramatic one: all three venues were 2.2–2.4 km out with 0–1 shops; the rsl now sits 29 m from the hub with 11. Castlemaine's floor did NOT need an exemption (min 4).
The crowd followed — katoomba's pub, same density/seed/21:00
| R20 (pre-bias) | R21 (post-bias) | |
|---|---|---|
| passing crowd (maxActive) | 34 | 66 (2×) |
| patron checks | 226 | 442 (2×) |
| inside frames | 1042 | 1424 |
| tonightRoster | 2 | 3 |
| NaN | 0 | 0 |
The "gig with no passing crowd" observation is retired. (null % stays ~96 % at 21:00 — retail is shut at night by design; that's my own R20 correction, not a leak.)
Two readings that need care (so nobody mis-reads my own numbers)
- *fremantle is the weakest pass (*): its pub/rsl clear the ≥3 bar but moved further from the primary hub (969→1352 m, 561→1292 m) — A's bias optimises local cluster adjacency, and fremantle's venues found a secondary cluster. It satisfies the letter of acceptance and the crowd is healthy in practice (pub: roster 6, inside 2741, maxActive 72), so I'm not calling it a miss — just naming it, since "3 shops at 1.3 km" is a thinner high street than katoomba's "9 shops at 466 m". A's call if they want it tighter.
- venue-win % DROPPING is not a regression. newtown's rsl went 100 %→21 %, fremantle's pub 100 %→62 %. That metric asks "when a check finds a shop, is it the venue?" — it was only ever 100 % because the venue was alone. Post-bias the venue has real neighbours competing for finds, while total crowd went up (newtown rsl: roster 3, inside 1873, maxActive 55). Isolation flatters that number; ignore it post-bias.
Handshakes
- D → F (gate): relocation verify GREEN. All five towns pass A's acceptance, the pub finding is closed,
0 NaN at every venue, rosters populate. No D-side gate concern for
v4.0-beta. - FYI F:
selfcheckscans everyweb/assets/towns/*.jsonon 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_RANGEis a 34 m radius but_nearestOpenShopsearches 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 0–19 % 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 2–4) 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.
tonightRosterpopulates on all five (2–7) — 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.9–97.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:
- 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.
- 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_RANGEvs the chunk lookup (mine): GIG_RANGE is a 34 m radius, but_nearestOpenShopsearches only the ped's own 64 m chunk, so an in-range ped across a chunk edge is blind to the gig — 8.7 % of in-range ped-samples (852/9,740). Real but small; a hot-path change mid-beta isn't worth it. Fix (chunk + 8 neighbours) is a v4.x candidate.
FINAL — post A's absorb (5d29863), all five towns at density
A's absorb landed (capacity widened, goldens pinned, selfcheck 51866/51866). Re-ran everything against
the absorbed graph. The katoomba numbers barely moved (null 77.2→78.1, visits 64→62, hero share 30→29 %)
— the pre-absorb read was already sound.
| town | cache→seated | hub | null % | visits | hero | texture | hero share | NaN | maxActive | rosters | sigs |
|---|---|---|---|---|---|---|---|---|---|---|---|
| katoomba_real | 80→72 (8) | 26 | 78.1 | 62 | 18 | 44 | 29 % | 0 | 217 | 15 | 262 |
| fremantle_real | 80→79 (1) | 29 | 71.1 | 116 | 23 | 93 | 20 % | 0 | 401 | 24 | 483 |
| newtown_real | 72→67 (5) | 12 | 85.9 | 44 | 6 | 38 | 14 % | 0 | 268 | 19 | 324 |
| bendigo_real | 36→35 (1) | 14 | 87.2 | 45 | 1 | 44 | 2 % | 0 | 358 | 21 | 432 |
| castlemaine_real | 24→23 (1) | 6 | 94.9 | 9 | 0 | 9 | 0 % | 0 | 295 | 20 | 359 |
Sim verdict: zero changes needed at 4× density. 0 NaN on all five, chunk rosters stream (15–24), identity signatures accumulate (262–483), patronage/loiter/bench-sit all fire, up to 401 active peds with no degradation. Determinism unchanged (identity is graph+seed keyed; shops don't touch it). D→F: green.
THE FINDING → for E / C / Fable: the widening MOVED the main street off the secondhand strip
The hero share falling (29→0 %) is not a behaviour bug — patronage is type-agnostic, so visits track the local shop mix exactly. The mix is the story. Main street = the densest shop cluster (what "walk the main street" means to a player):
| town | main-st shops | heroes on main st | hero % of main st | town-wide hero % | hub moved |
|---|---|---|---|---|---|
| katoomba | 9 → 27 | 9 (100 %) → 5 | 100 % → 19 % | 25 % | 308 m |
| fremantle | — → 30 | 5 | 17 % | 25 % | — |
| newtown | — → 13 | 2 | 15 % | 27 % | — |
| bendigo | — → 15 | 1 | 7 % | 25 % | — |
| castlemaine | 3 → 7 | 1 → 0 | 33 % → 0 % | 20 % | 434 m |
Pre-widening the densest cluster WAS the secondhand strip (katoomba: 9 of 9 main-street shops were heroes). Post-widening the town's centre of gravity relocated 308–434 m onto the general-retail cluster, where the heroes are a 0–19 % 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 2190–2433 m (0–1) |
| bendigo | pub 504 m (1) · rsl 451 m (0) · band_room 1157 m (0) |
| castlemaine | band_room 0 m (6) · rsl 155 m (4) · pub 223 m (1) |
A gig 2.4 km from the high street has no passing retail trade. A's 5d29863 "pub facade bias" fixed frontage,
not town position — katoomba's pub is still 1007 m out with 1 neighbour. Crowd still forms (peds stream with
the camera), so it's a quality call, not a break — but "venue placement still lands" is in A's ledger and
F's district gate, so it's theirs. Castlemaine shows the good case (band_room on the hub).
ROUND 19 — v4.0-alpha CLOSE: the fragmentation re-measure (ledger #3)
*PROCITY-D, 2026-07-16. A resolved my R18 finding (commit 6fc4b40: junction-protected DP + bridge-joins
- shopless-island culling, katoomba golden pinned). This is the post-fix re-measure — same harness, same seed. Verdict: the ruling landed clean — the fragmentation is fully resolved and the sim stays untouched.*
Before → after on the real Katoomba graph
| metric | R18 (my finding) | R19 (post-fix) |
|---|---|---|
| connected components | 105 | 1 |
| street-metres in the main net | 58.2 % | 100 % |
| shops stranded off-main | 2 | 0 — 1 bridged to main, 1 dropped past SEAT_MAX and counted (norm.dropped, plan_osm.js:319) |
| % near-crowd on tiny fragments | ~10 % | 0 % (no fragments left) |
| nodes / edges | 872 / 799 | 830 / 966 |
Shops: 20 in cache → 19 seated + 1 counted-drop, 0 silently stranded (ruling satisfied). selfcheck
51670/51670 green, katoomba real-roads golden pinned in the output-drift guard.
The DP-collinear junction-drop mechanism is fixed at the source
Re-ran R18's original failing geometry — a dead-straight cross street through a collinear mid-junction (the exact case B and I both hit). R18: DP flattened it, dropped the junction → max-degree 2, disconnected. R19: the junction is protected from DP → degree-4 crossroads forms, 1 component. Fixed.
Sim confirm on the fixed (bridged) graph — still zero changes
Fresh stream sim, real Katoomba, drove the shop blocks: 0 NaN over 1,303,200 samples, footpaths perfect
(0 off-footpath, maxDist 12.9 m), 312 active peds, loiter (251) + bench-sit (77) fire, and patronage
rose 24→41 visits (peds now reach shops across the connected graph). fragmentPedFraction: 0. The sim
needed nothing — exactly as R18 predicted (a single component is strictly easier than 105).
Handshake
- D → F: fragmentation verdict post-fix is GREEN — real Katoomba is one connected component, 0 peds stranded, 0 NaN, all citizen behaviours hold, determinism intact (A's golden pins it). No D-side gate concern. Tag away.
- FYI (B/F budget): edges rose 799→966 (junction protection + bridges) though total street-metres fell (islands culled) — flagged for the tris budget alongside the BIG_CITY fix; not a sim concern.
- No D-side spawn-bias mitigation needed this epoch — A's pass left nothing to mitigate. (Beta backlog item retired.)
ROUND 18 — v4.0-alpha REAL ROADS: citizens on real bones (ledger #5)
PROCITY-D, 2026-07-16. The audit: does the citizen sim behave on real (irregular) street topology?
E landed katoomba_real's real road geometry (484 OSM ways → 872 nodes / 799 edges) mid-audit, so
this is measured on the real Katoomba graph, not just a proxy. In-shell: fresh CitizenSim built
on plan.streets, driven headless-of-render (rAF throttled). No source changes — the finding is the
verdict + the failure list.
Verdict: the graph contract IS the contract — the sim needs ZERO changes
The sim's graph-walk is topology-agnostic by construction (seeded random walk over nodes/edges, dead-end U-turn), so real geometry should just work. It does. Proven on a crafted irregular town (deg-4 crossroads, 3 T-junctions, 5 dead-ends, a 30° acute fork, a cycle) AND the real Katoomba graph (7.2° acute junction, a 1303 m edge, 105 components):
| assertion | crafted irregular | real Katoomba |
|---|---|---|
| NaN/Inf ped positions | 0 / 240k | 0 / 1,095,500 |
| off-footpath samples (ped beyond its own edge band) | 0 / 240k | 0 (maxDist 12.9 m = main offset) |
| edges traversed | 11 / 11 | full main network |
| peds pinned at dead-ends | 0 | 0 |
| loiter fires | 154 ev | 174 ev |
| bench-sit fires (R17) | 47 ev | 60 ev |
| patronage fires (stream mode) | 165 visits | 24 visits, 4.3k inside-frames |
| chunk streaming over non-grid | 7 live rosters / 93 active | 13 chunks / 307 active |
| determinism (2 sims, same seed) | 0/120k pos mismatch; identity set visit-order-independent | byte-identical rerun; 1167 sigs |
Identity signature format is intact (chunkKey#i:pedIndex:pvar:height:speed:edge:forward), no sit
term — my R17 benchsit stream doesn't perturb it. Nothing in the sim needed touching for real roads.
The measured leak → FILED FOR A: the real-roads graph is fragmented
katoomba_real's street graph comes out in 105 disconnected components. The main component holds
58% of street-metres (52.8 km) and 18 of 20 shops — the core town is coherent — but 42% of street
metres and 2 shops strand on 102 islands (9 single nodes, 76 fragments of 2–4 nodes, 17 of 5–20).
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
buildRealRoadsruns Douglas–Peucker 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'sselfcheckfixture (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 actionsitA, played only onsetSitting(true)— so walkers, placeholders, and?classic(nositClip→sitAnull →setSittingno-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 dropsinnerby(worldLowestY − figY) / fig.scale.y, landing the posed soles on the footpath at any citizen scale. Standing up restores the cachedbindPlantYexactly. TagsprocitySeated.sim.js: a dedicated rng streamsitRng = rng(citySeed,'benchsit',id)(independently keyed → does not shiftturn/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 flipsc.sitwhen a sit clip is loaded (?classicinert at the source). The near-tier update togglessetSitting(c.sit && loiter>0).RigPool.releaseforcessetSitting(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
seatedLeanis not applied here — onlysetSitting'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: 6–9 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— nositterm; plan golden0x3fa36874, 15277/15277. - Classic covenant airtight at the source:
?classic=1→fleetSitClip:false, 31 loiters over the soak buteverSat:0(the gate never flipsc.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.sitClipwhen present (retiring the R14SEAT_DROPsink — it sits on the riser and the re-plant seats it), tagsfig.userData.procitySeated = true(F's gate). Reads seated from the crowd POV (the kit hides the lower body); measured stature ~1.1–1.3 m (F's seated gate [0.9,1.5] ✓). Falls back to theSEAT_DROPstanding-sunk hack ifsitClipis 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, theguitar_ampGLB swaps in), and the primitive is the?noassetsfallback (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(gatedthis._gigVenues.has(shopId)), keyed by the ped's stablec.idso re-entry doesn't dup. Unlike R9 occupancy (which clears on emerge), the roster persists past the ped's dwell — "who came tonight". - Queue admits — F relays each
VenueQueue.admitOne()viacitizens.recordVenueEntry(venueShopId, {pedIndex,height,key}).
It's bounded + per-night: cleared automatically on the setGig(id, false) on→off transition (the gig
ends → the crowd disperses), so it survives interior exit/re-enter (gig stays on) but resets each night.
Pure bookkeeping — no rng draw, no GPU (draw/tri delta 0).
The crowd consumes it (band.js) — the front slots become the roster
GigCrew.spawn(roomGroup, { …, roster }): crowd slot i fills from roster[i] (its {pedIndex,height}),
seeded strangers fill the remainder. This is the release-round's load-bearing safety: each crowd slot is
an independently-keyed rng stream (gig/crowd:<gid>:<slot>), and _make's existing override branches
already skip a slot's own height+pickRig draws when identity is supplied — so a roster override touches
only that slot, never another slot / the sway-phase stream / the band / either golden. An empty
roster is byte-identical to R13 (verified). Roster heights are the sim's 1.55–1.95 m, inside the
no-giants [1.4,2.0] gate. crew.spawn returns { band, crowd, fromRoster } and each member carries
member.pedIndex (for F's continuity smoke — assert on the seam, not pixels).
→ Lane F: the seam (three touches; the clear is automatic)
// 1. interior — pass the venue's roster into the crowd (interior_mode.js:239):
crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints,
gig: tonight, roster: citizens.tonightRoster(shop.id) });
// 2. street — relay each queue admit into the roster (index.html per-venue loop ~380-393):
q.spawn(scene, { queueZone, gigId, onAdmit: (id) => citizens.recordVenueEntry(venueShopId, id) });
// 3. nothing — the roster auto-clears on your existing setGig(id, false) at the gig's end.
Accessor for your smoke: window.PROCITY.citizens.tonightRoster(venueShopId) → [{pedIndex, height}]
(insertion-ordered, deduped, bounded). Assert crowd ⊇ roster ∩ cap via crew.crewInfo.fromRoster +
member.pedIndex ∈ roster. admitOne() now returns {pedIndex,height,key} (was 1) — truthy, so your
existing if (q.admitOne()) still holds. Also: crew.spawn is now null-gig-safe (quiet-night guard).
Verified in-shell (seed 20261990, The Exchange Hotel #116)
| check | result |
|---|---|
| roster API — record / dedup-by-key / invalid-pedIndex-ignore / per-venue / clear-on-gig-off | all ✓ |
| surge continuity — real peds ducked in → roster → crowd | 8/8 (same pedIndex + height) |
queue continuity — admits relayed via onAdmit → roster |
4/4 |
| consume — front slots = roster, tail = seeded strangers unchanged | ✓ |
| determinism — empty-roster crowd == R13 baseline (pedIndex + stature per slot) | byte-identical ✓ |
| soak — 7 nights × 3 venues, enter/exit + re-spawn | leak geo Δ0 / tex Δ0, roster bounded (≤ cap, cleared per night), no identity churn |
| budget | interior gig 64 draws ≤350, 0 new rigs (continuity is bookkeeping) |
?noassets=1&gigs=1 |
crowd = placeholders using roster heights, no crash, 0 fetch |
Debt #2 (the drummer sit clip) — INVESTIGATED, deferred to v3.1 (real blockers, not a punt)
The clip does exist — ~/Documents/mixamo-fetch/out/ is NOT on m3ultra (Fable's path is stale; the
bank lives on the tailnet source box johnking@100.91.239.7). Sitting_Idle.fbx there converts cleanly
via Blender to a 394 KB mesh-free sit.glb that binds (65/65 bones; the fleet's _canon keeps the
colon, mixamorig:Hips, so raw Mixamo naming is already canonical). But wiring it is a v3.1 change, not
a release-week one, for two real reasons:
_rotOnlybreaks a seated pose — it dropsHips.quaternion(pelvic tilt) + all positions (hip descent), giving a "levitating L-sit". Correct wiring needs a parallel_rotWithHipsfilter + a post-pose foot re-plant inspawnRig— a change to the shared rig stack (keepers/browsers/band/crowd).- A genuinely seated figure is ~1.2 m crown-above-feet, which fails F's no-giants stature floor
[1.4,2.0] (
flags_check.py:695) — it needs an F smoke exemption for the seated band member. Both land a qa-red change pending an F handshake, in the freeze round, for a polish item — the drummer already reads seated from the crowd POV (money shot) via theSEAT_DROPhack. Rides Fable's existing v3.1 item "bench-sit loiter from the samesitclip". Recipe (for v3.1): scp Sitting_Idle.fbx → Blender--background --pythonimport/export GLB (no bone rename needed) →web/models/peds/sit.glb→_rotWithHipsloader +spawnRigre-plant + F stature exemption. See LANE_D_NOTES wishlist #1.
ROUND 13 — v3.0-beta the district: the RY fix, instruments/drummer, the queue, multi-venue surge
PROCITY-D, 2026-07-15. Behind ?gigs=1; verified in-shell against A's landed 3-venue plan (pub #116
pubrock / rsl #404 covers / band_room #490 grunge, 14 gigs / 14 posters) and C's landed 4-pose
stage (guitar/vocal/bass + a seated drums on the riser). Files: rigs.js, band.js, new queue.js,
sim.js. keepers.js / placeholder.js / impostor.js UNCHANGED — the RY fix at the source corrects
them for free.
Debt #4 — the RY audit: fixed once at the source (⚠ this changes the flags-off street VISUAL)
Finding (confirmed in-scene): the imported ped GLBs' visual front is local +Z, but everything
downstream assumes −Z — sim.js heading math (facing = atan2(-dx,-dz)), C's pose ry
(counter.stand / bandPoses / watchPoints / browse points), the placeholder (placeholder.js:61 builds
toes to −Z on purpose, "like the GLB rigs"), and the impostor bake/pick convention. So the GLB was the
lone outlier: keepers faced the back wall, browsers faced away, and every streamed rig walker moon-walked
(faced anti-travel) — all subtly, for ~12 rounds, because near+mid were consistently-wrong-together
(seamless) and peds are mostly seen from behind. R12's band carried a local RY_FLIP=π that patched only
the gig.
Fix: one line in rigs.js buildFigure — inner.rotation.y = Math.PI — normalises every rig's front
to −Z, matching the rest of the system. Then RY_FLIP is deleted from band.js (band/crowd use C's ry
verbatim). Because the impostor atlas bakes this same buildFigure/spawnRig output and the layer's
yaw-pick already uses the −Z convention, near↔mid stays seamless (both flip together). It's purely
visual — a Y-rotation leaves every bone's world-Y untouched, so head-height / feet-plant, the no-giants
gate, and all seeded identity/determinism are byte-unchanged (no golden move).
Verified: keeper now faces the shop floor (was the back wall); 223/223 gig-mode + 188/188
flags-off streamed walkers face their edge-travel direction (avg dot 1.000; was −1.0); band faces the
crowd + crowd faces the stage with RY_FLIP gone; ?noassets placeholder band (which the old flip left
facing the backdrop) is now correct too.
- → Lane C (pose contract, please land in LANE_C_PUB.md): "Rig visual front is canonical local −Z
(
rigs.js buildFigurenormalises the GLB's native +Z). All posery(counter.stand, bandPoses, watchPoints, browse points) are the −Z-front heading the rig should face — no lane compensates; the R12RY_FLIPis gone." Your existingryvalues were already correct under this — nothing to change. - → Lane F / Fable (flags-off flag): the fix is in the shared rig stack, so the default (flags-off) street crowd now faces travel instead of moon-walking. Determinism + the golden plan hash are untouched (visual only), but if any regression snapshots pixels it will move — it's a bug fix, re-pin if needed (improvement, not a leak).
Debt #2 — instrument GLBs wired (placeholder-persists, fail-soft, fleet-gated)
band.js keeps its primitive instruments as the immediate/?noassets fallback, then swaps E's manifest
GLBs in async when they resolve (same pattern as interiors/glb.js). Role → fitting: guitar→
electric_guitar, bass→bass_guitar, vocal→mic_stand (planted), drums→drum_kit (planted at the
riser), plus one guitar_amp as backline dressing. Manifest is self-fetched (promise-cached) so band.js
stays decoupled from C's glb.js, and the whole upgrade is gated on fleet.ready → under ?noassets
(fleet null) it never fetches (proven: fresh-tab network shows 0 procity_fit_* / manifest
requests). Disposal-guarded by a generation counter; GLB clones share the cached geo/mats (detach-only,
never dispose — only the ownGeo-tagged primitives are freed). Orientation lives in one GLB_FIT table
(guitars need a +Z roll to sling; the kit is scaled 0.66 to fit C's tight 0.73 m vocal↔drums gap).
Debt #3 — the drummer (4-piece, Fable's ruling)
bandPoses.forEach already picks up C's 4th drums pose (seated:true, riser y). The drummer sinks
SEAT_DROP (0.52 m) so the kit hides the straight legs and it reads seated from the front — the crowd
POV / money shot; from a hard side angle the sink shows (a standing rig faked as seated). A true seated
pose is a clip job — see the wishlist. Build is defensive: if John had vetoed to a trio, C simply
omits the pose and the band is 3 with zero D change.
The outdoor queue (charter item — new web/js/citizens/queue.js)
VenueQueue: a seeded 2–6 line on the same gate-protected rig path, idle-animated, that drains as punters
are admitted and empties by close. Consumes B's venue.queueZone when present, else a straight line off
the door — the fallback trails along the frontage tangent so it hugs the building instead of spilling
onto the road. Verified: spawn (5 rigs, human-sized, hug the frontage, face the venue), admitOne() drains
the head + shuffles everyone up to target, and a seeded auto-drain safety. F seam (wire per venue off
the street-side gig state):
const q = new VenueQueue({ citySeed: plan.citySeed, fleet });
q.spawn(streetGroup, { door, queueZone, gigId }); // at 'doors' (+ 'on' overflow); door = {x,z,ry}, ry = facing the entrance
q.admitOne(); // on each admitted punter (cover-charge / entry hook)
q.update(dt); // each street frame — idle + lerp + slow auto-drain
q.disposeAll(); // at 'done' / venue out of view
→ Lane B: publish venue.queueZone = { x, z, ry, len } — (x,z) = head by the door, ry = the
heading a punter faces (toward the entrance), len = line length; the line trails single-file opposite the
facing, so pick ry to run it along the verandah.
Multi-venue surge (sim.js)
setGig(venueShopId, on) widened from a single _gigVenue to a _gigVenues Set so several concurrent
gigs each pull their own block; _nearestOpenShop now prefers the nearest gig venue in GIG_RANGE across
all of them. F's R12 single-venue call is a subset — still works. Non-gig patronage path is byte-identical.
Measured budgets (seed 20261990, "Boolarra Heads", The Exchange Hotel #116)
| thing | number | budget |
|---|---|---|
| gig-night interior, rig fleet + GLB instruments | 56 draws · 183k tris | ≤350 draws ✓ |
| — of which instruments (5 GLBs @ ~14k, E's ≤15k target) | ~70k tris | (no interior tri cap) |
| band / crowd | 4 on the deck (3 front + seated drummer) · 8 at 8 watch points | crowd ≤ watchPoints ✓ |
| outdoor queue | seeded 2–6 rigs (~1 draw each) at the frontage | stated |
?noassets+?gigs |
band+queue placeholders, 0 GLB/manifest fetch | silent-and-fine ✓ |
| flags-off street walkers face travel | 188/188 (dot 1.0) | determinism byte-unchanged ✓ |
RSL (#404) fields the crowd-cap stress case: CROWD_CAP is 12, real cap = watchPoints.length per venue
(≤ always holds). Re-measure the RSL interior once its archetype dressing lands.
ROUND 9 — interior presence: occupancy truth + browser rigs (→ Lane F handoff below)
Follow a ped into a shop and find them browsing. D owns occupancy truth; C owns the browse points;
F wires the handoff. Validated end-to-end in-shell (?stock=real). qa.sh --strict GREEN; v1
(?roster=v1) path untouched.
D owns occupancy truth ✅ (the C→D→F seam)
Patronage now records who's inside which shop, keyed by shopId (from the door point). New API:
citizens.occupancyOf(shopId) → { count, occupants: [{ seed, enteredAt, pedIndex }] }
count = peds patronage currently has inside (pre-cap); pedIndex lets a browser BE the exact ped who
ducked in off the street (identity continuity). Occupancy is cleaned on emerge, on chunk-drop, and on
stream toggle. Deterministic: same seed + same update sequence → identical occupied shops AND
occupants (verified: 17 shops, seed:pedIndex byte-equal across two runs).
- Consistency (D2): an
insideped is hidden on the street (not in the rendered set — verified 0 inside peds rendered) and re-emerges at the door. No double-presence.
Browser rigs ✅ (KeeperManager, the leak-proven pattern)
keepers.js gained browse:true (faces the shelf via ry, no player-greet) + pedIndex
(pick the exact fleet ped) + seedKey (distinct browsers per shop). Each browser is a merged ped
= ~1 draw. Validated over 12 enter/exit cycles: browser count == min(occupancy, 3) 12/12,
worst interior 61 draws ≤350 (C's headroom holds), leak-free (0 GPU delta after warmup),
disposed on every exit (keepers.disposeAll() in interior_mode).
→ Lane F: the handoff in web/js/world/interior_mode.js (F owns it) — verified working
Two-line wiring at interior build (F already has keepers + current.browsePoints from C):
// on enter(shop), after buildInterior:
const occ = citizens.occupancyOf(shop.id);
const pts = (current.browsePoints || []).slice(0, Math.min(occ.count, 3)); // cap 3
pts.forEach((pt, i) => keepers.spawn(current.group, {
x: pt.x, z: pt.z, ry: pt.ry, shopId: shop.id, browse: true,
pedIndex: occ.occupants[i] ? occ.occupants[i].pedIndex : null,
seedKey: `${shop.id}#${i}`,
}));
// exit() already calls keepers.disposeAll() → browsers freed with the room. Leak-proven.
And web/index.html: citizens.setShops(...) door points must carry shopId: s.id (add it to the
R8 recipe — one field). Everything else (patronage, weather) is unchanged.
Closing-time (pending A's ruling): my dwell model already handles it sensibly — _openAt(hours)
stops new entries once a shop closes, and existing occupants clear within their short seeded dwell
(5–20 s, tiny vs the hour-long closing window), so no one is stuck inside a shut shop. I'll adopt A's
explicit ruling when it lands if it differs.
Evidence: docs/shots/laneD/r9_browsers_in_shop.jpg (two browsers in a toy shop among the priced stock).
ROUND 8 — shop patronage v0 + weather reaction (→ Lane F wiring below)
The crowd comes alive: streamed peds duck into open shops they pass and re-emerge; at night only the
open-late video shop draws visitors; rain thins + shelters the crowd. Default-on for the streamed
roster (post-flip), behind ?patronage=0 off-switch. Verified in-shell (real plan shops) + test page.
qa.sh --strict GREEN. v1 (?roster=v1) path untouched (no patron fields, golden identity holds).
Patronage (D1) ✅
State machine per streamed ped: walking → (every ~10m, if a nearby OPEN shop is within 18m, a seeded
roll) → going (steer to the door) → inside (hidden, seeded 5–20s dwell) → emerge (resumes
its footpath walk). Hours-aware via the shop's hours.
- Day-long: peds visibly enter/leave shops of all types (noon: up to ~38 inside near a shopfront, 7 types visited).
- Night: at 22:48 only the video shop (
hours[1]≥22) is open → patronage is video-only (onlyVideo:true), and its block stays lively (the R6 night floor). The patronage roll ramps up as the streets empty so the few peds out concentrate at the one open shop. - Deterministic (chunk-keyed identities unchanged: 150 re-derive from seed), leak-free (0 GPU delta over a 2-walk patronage churn — enter/emerge reuses the existing actor lifecycle), and budget-neutral / positive (inside peds are hidden → draws −1 vs patronage-off at the same spot).
Weather reaction (D2) ✅ — reads Lane B's PROCITY.weather contract (does NOT import weather.js)
sim.setWeather({state,intensity}) each frame. Rain (intensity 0.8): density −56% (in the
40–60% target), walk speed +~14%, and patronage chance up → more peds shelter in shops (measured
inside 6→25). Overcast: ~−10%. Clear: v1 behaviour. Seed 20261990 rolls clear (v1-identical);
use ?weather=rain to force it.
→ Lane F: wiring in web/index.html (F owns the shell)
- Shop door points — build once from the plan, feed the sim:
const CH = 64, shopsByChunk = new Map();
for (const s of plan.shops) {
const l = plan.lots.find(x => x.id === s.lot); if (!l) continue;
const ry = l.ry || 0, fx = -Math.sin(ry), fz = -Math.cos(ry); // facade normal → the street
const x = l.x + fx * (l.d / 2 + 0.6), z = l.z + fz * (l.d / 2 + 0.6); // door at the shopfront
const k = citizens.chunkKeyAt(x, z);
(shopsByChunk.get(k) || shopsByChunk.set(k, []).get(k)).push({ x, z, hours: s.hours });
}
if (!rosterV1) citizens.setShops(shopsByChunk); // patronage needs door points; inert without
if (params.get('patronage') === '0') citizens.setPatronage(false); // off-switch
(PATRON_RANGE 18m tolerates door-point imprecision; if a door reads wrong, nudge the +0.6.)
2. Weather — in the street branch each frame: citizens.setWeather(window.PROCITY.weather);
(B's contract is always a valid {state,intensity}; F sets {clear,0} when ?weather is off.)
3. setNightLivelyChunks (openLate block) — already wired in F1; unchanged.
Baseline note (flip protocol): patronage default-on may move the flags-off draw baseline a hair — in the direction of fewer draws (inside peds hidden), and the default spawn view has ~0 near-rigs so it's near-nil. If F1's regression trips, re-pin per the flip protocol (it's an improvement, not a leak).
ROUND 7 — MERGE VALIDATED · GO for the roster flip (→ Lane F: flip on this) · escape hatch ready
Verdict: GO. Lane E's R7 ped-merge closed the one gap the R6 memo named. Full-density streamed
roster (perChunk 16) is now under budget in a real town. Flip it default-on.
qa.sh --strict GREEN. All numbers in-shell (real town "Boolarra Heads", seed 20261990).
Merge validation (E1 — pipeline/merge_ped.py, 447188a) ✅
- Every one of the 19 peds is now 1 mesh / 1 material (was 8 meshes / 2 materials) → ~1 draw per near-rig; fleet 116→24 draws at the 24-cap (E's own count: 92→19). Bonus: GPU memory dropped too (geometries 205→160, textures 141→90 in-shell).
- Rigs bind + animate (walk/idle) — mixer 0.2–0.3 ms; no T-pose. Skinning/skeleton preserved (65 joints), clips retarget unchanged.
- Silhouettes + atlas materials intact — hi-vis worker (vest/helmet/boots), suit, business, comical
kid all read crisp, no atlas seams/melting; identity variety preserved (
pickRigunchanged). - Impostor bake clean (19 subjects, 16×5 atlas); determinism holds (507 chunk-keyed identities re-derive from seed).
The flip numbers (full density, perChunk 16 — no reduced-density concession)
| metric | pre-merge (R6) | post-merge (R7) | budget |
|---|---|---|---|
| worst continuous-walk street view draws | 356 ✗ | 241 ✅ | ≤300 |
| worst view tris | ~66k | 65,899 ✅ | ≤200k |
| worst-view near-rigs | (throttled to 8) | 17 (full) | cap 24 |
| draws / near-rig | ~7 | ~1 | — |
| 30-chunk soak (3 walks = 726 builds/disposes) | leak-free | leak-free, 0 GPU delta | baseline |
| heap over soak | 50→77→54 | 78→63 (GC, stable) | stable |
Full-density shipping defaults (set this round)
enableStream default is now perChunk 16, radius 2 (was 8). Near-cap stays 24 (now 24 draws,
not 168). NIGHT_LIVELY_FLOOR 0.5 for the open-late block (R6). No other knobs change.
→ Lane F: the flip wiring in web/index.html (F owns the shell — decision #1, flip on my word)
Invert the R5/R6 flag: stream is the default; ?roster=v1 is the escape hatch.
const rosterV1 = params.get('roster') === 'v1'; // escape hatch → the old fixed roster
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
graph: plan.streets, fleet,
chunkStream: rosterV1 ? null : { radius: 2 } }); // default-on
Hours-aware (optional, recommended): after construction, citizens.setNightLivelyChunks(keys) where
keys = the openLate shop's lot→chunk + 1-ring (sim.chunkKeyAt(x,z); openLate = hours[1] >= 22).
Everything else (setTimeOfDay/setExposure/update/setPaused) is unchanged. The
hook-driven window (onChunkBuilt/onChunkDisposed → sim.onChunkBuilt/onChunkDisposed) is still the
preferred precise option; poll works with zero wiring.
Verified in my harness (test page + runtime-enabled shell): no-flag boot → streamMode:true,
perChunk 16, determinism ✓; ?roster=v1 → streamMode:false, fixed roster, determinism ✓. The
in-shell no-flag-URL boot completes once F lands the two-line flip above — the roster itself is proven
to run correctly in the shell at the shipping default (the soak + flip numbers above are in-shell).
ROUND 6 — streamed-roster "default-on readiness" memo (→ Fable + Lane F, for the R7 flip call)
Verdict: functionally ready (deterministic, leak-free, hours-aware, composes) — but full-density
default-on is gated on one optimisation (ped sub-mesh merge). Ships budget-safe TODAY at reduced
density. Flag stays default-off this round. qa.sh --strict GREEN (sim.js doesn't touch its gates).
All numbers below are in-shell (real town "Boolarra Heads", seed 20261990), stream runtime-enabled
(F wires ?roster=stream in F2 — call-site in the R5 section below).
Soak (gate-2 + far-field, ?roster=stream) ✅
- Leak-free: 3 full street-graph walks = 242 chunk builds / 242 disposes → 0 GPU geometry / 0 texture delta (renderer.info constant 205/141); 16 shop enter/exit cycles → 0 delta. JS heap oscillates 50→77→54 MB (GC recovers — not monotonic, no leak).
- Determinism: 188 live chunk-keyed identities re-derive byte-for-byte from the seed, in-shell.
- Flag-off byte-identical: shell citizen-0 signature
0:13:7:1.786:1.493:3:-1(R4 golden), still exact after this round's changes. 0 console errors across the soak.
Hours-aware density ✅ (implemented this round)
Each chunk thins per-frame by the same densityAt(tod) curve v1 uses. New: setNightLivelyChunks(keys)
gives the open-late block a night floor (NIGHT_LIVELY_FLOOR=0.5). Measured: the open-late video
shop (hours 11–23) block holds ~half its crowd at night while ordinary streets go near-empty
(ordinary 65→6, ~90% drop; open-late block 31→16). Lane F call-site: compute the openLate
shop's lot→chunk (+ 1-ring) and citizens.setNightLivelyChunks(keys) once after enabling stream
(sim.chunkKeyAt(x,z) gives the key). Empty set = uniform thinning (safe default).
Composition with ?dig=1 ✅
The streamed roster is street-tier only: the shell frame loop calls citizens.update in the
street branch only, so the roster is fully inert while a shop/dig is open — no shared state, no fight.
Proven: enter/exit record shops (the dig context) with stream on is leak-free and the roster resumes
intact. (The riffle itself is Lane C+F's flag; couldn't trigger its raycast open via synthetic input
without pointer-lock, but the composition claim is mode-separation, which holds by construction.)
Same reasoning covers the all-on combo — roster shares no state with winmap (B glass) or dig (C).
Perf / budget — the ONE gap ⚠️ (default-on blocker at full density)
-
Stream vs v1 (pop 140) at the busiest node: frame +0.29 ms (0.89 vs 0.60 ms), draws ~parity; most of the ~500 active are far = cheap position-advance only. Mixer stays 0.1–0.3 ms.
-
Draw budget: worst continuous-walk street view must stay ≤300 (CITY_SPEC). Root cause: each decimated ped is 8 sub-meshes but only 2 materials → ~7 draws per near-rig. So a dense street of near-rigs blows the budget:
stream perChunkworst-view draws worst near density (within 70m, median) 16 356 ✗ 15 ~26 12 314 ✗ 10 ~18 8 (new default) 291 ✓ 10 ~13 So it ships budget-safe at
perChunk=8today (~13 median within 70m — constant + camera-following vs v1's uniform ~13 that thins as the town scales; and it concentrates ~10 near-tier rigs vs v1's 0–1 — the visible upgrade). Full density (perChunk 16, ~2× the crowd) needs the budget headroom.
Recommendation for the R7 default-on flip
- Land the ped sub-mesh merge (merge each ped's 8 sub-meshes by material → 2 → ~2 draws/rig).
Then
perChunk 16worst-view ≈ 260 draws — full density under budget. It's arigs.jsfleet-load optimisation and it is F1-safe in practice: v1's default/spawn view has ~0 near-rigs, so the flags-off draw snapshot is unaffected — the merge only cuts draws where near-rigs cluster (the stream path). Also helps v1 street draws + interior keepers. ~1 session; risk = skinned-mesh merge (verify animation). This is the single thing standing between stream and default-on. - Until then: default-on is safe at
perChunk=8(reduced density) — flip is a judgement call on whether ~10 near-rigs + constant far-field beats v1 today, or wait for the merge for the full crowd.
Known non-blockers: near↔mid is a hard LOD switch (documented, imperceptible at 25m); open-shop spawn-bias beyond the night floor is v2-later (needs per-chunk shop data — the sim is graph-only).
ROUND 5 — chunk-streamed roster (v2, behind ?roster=stream) ✅ → Lane F wiring below
As-built implementation of the R3 design note (further down). Default-off; v1 path byte-identical.
tools/qa.sh --strict GREEN (sim.js doesn't touch the citygen/manifest gates).
What it does. v1 spreads one fixed roster over the whole town, so big-town streets are uniformly
sparse. Stream generates + ticks citizens per 64m chunk, keyed so identity is a pure function of
(seed, chunkKey, i) — independent of town size / visit order — and windows them to the live chunks
around the camera. Density becomes constant per unit street and follows the player. Everything
downstream (LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer) is unchanged and stays
global across live chunks.
Measured (test page, 9×9 fixture grid, ~±208m):
- Constant far-field density: citizens within 70m ≈ 58 at spawn, 129 at 150m out, 83 elsewhere — vs v1 (200 pop) which is uniformly ~12–15 everywhere (spread too thin). Stream is ~5–10× the street density at equal total cost, and it doesn't thin as you walk arbitrarily far.
- Deterministic: 507 chunk-keyed identities re-derive byte-for-byte from the seed (determinism button, stream mode). Same seed + same walk → same residents.
- Leak-free: after warming ped GPU uploads, 2 full grid walks (~380 chunk build/dispose cycles) → 0 geometry / 0 texture delta; rig pool capped at 30, live chunks bounded at 25 (R=2 → 5×5).
- Budget: worst view uses the same near-cap (24) + 1 impostor draw as v1; frame 2.31 ms (~432 fps headroom) with ~500 active (most far = cheap position-advance only). Citizen draw/tri contribution is identical to v1's bound.
- Flag-off = v1: shell with no flag →
streamMode:false, citizen 0 signature0:13:7:1.786:1.493:3:-1— exact match to the R4 golden capture. No perturbation.
→ Lane F: wiring ?roster=stream in web/index.html (F owns the shell)
Minimum (poll-driven, zero Lane B changes — works today):
const streamOpt = params.get('roster') === 'stream' ? { radius: 2, perChunk: 16 } : null;
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
graph: plan.streets, fleet, chunkStream: streamOpt });
Preferred (hook-driven — the sub-roster window then matches Lane B's built-chunk window exactly; the sim auto-stops polling on the first hook call). Wire onto the chunks ctx only when the flag is on:
if (streamOpt) {
chunks.ctx.onChunkBuilt = (key) => citizens.onChunkBuilt(key); // Lane B calls this (chunks.js:41)
chunks.ctx.onChunkDisposed = (key) => citizens.onChunkDisposed(key); // chunks.js:47
}
(chunks already exposes ctx; if not, expose it or pass the two callbacks into createWorld.)
Nothing else changes — setTimeOfDay/setExposure/update/setPaused are identical. Flag-off must
not construct with chunkStream, so the v1 path (golden identities) is untouched. The 64m chunk key
(${cx},${cz}, Math.floor(v/64)) matches Lane B's planutil.js exactly, so B's keys line up with ours.
Hours-aware density is in (task 3): each chunk is thinned per-frame by the same densityAt(tod) curve
v1 uses, so streamed streets empty at night too. Open-shop spawn-bias weighting is left for v2-later
(needs shop data in the chunk; the sim is graph-only today) — noted, not built.
ROUND 4 — in-shell verify + decimation validation (→ Fable, → Lane F)
All three R4 tasks done. tools/qa.sh --strict GREEN. Sign-off for Lane F below.
D1 — in-shell rig/keeper verification (web/index.html) ✅
Verified in the real game (not the test page), via window.PROCITY:
- Placeholder→rig upgrade fires for both peds (
citizens.mode→'rig', 19 fleet, pool active) and keepers (keeper spawns at the counterkeeperStandpose as a rig). - Leak-free: baseline
renderer.info.memory= {geometries 213, textures 198}; after 12 enter/exit shop cycles it returned to exactly 213/198 (Δ geom 0, Δ tex 0). Interiors peak ~303 geoms inside, drop back every exit. Confirms the R-review dispose fix (free the keeper's Skeleton only, never the fleet-shared geo/mats) holds in-shell alongside Lane C's interior dispose. - Determinism in-shell: seed 20261990 → byte-identical identity signature across a full reload.
?noassets=1:fleet===null, mode staysplaceholder, peds + keeper are placeholder actors, zero ped GLB fetches — town fully playable asset-free.
D2 — decimated ped validation (critical path — E1 landed 518678d) ✅ SIGN-OFF FOR F
Validated Lane E's decimated fleet (web/models/peds/, ref lane-e/round4-peds):
- Per-ped tris: 1564–2805, avg 2450 — all ≤3k (matches E's
_peds_decim.json). - Rigs bind + animate: skinning preserved, the shared
walk.glb/idle.glbretarget unchanged (skeleton/65 joints intact), near actors animate, mixer 0.1–0.3 ms. - No skinning explosions: 24-teleport pool-eviction churn → 0 broken meshes; silhouettes read at street distance; identity variety intact (verified visually — kid/luchador/hi-vis/elder/ business all distinct, no melted hands/faces).
- Impostor atlas bakes clean from the decimated rigs; determinism + leak checks re-pass.
- Gate-3 number for Lane F: at the 24-rig near cap the fleet now contributes ≈59.6k tris (was ~1.2M). Test-page whole-view total 88.9k tris (was ~1.5M). In-shell sample near rig 2729 tris, keeper 2073 tris (was 41k). F is clear to re-measure gate 3 — the fleet leaves ~140k for the town, comfortably ≤200k. (The R1 "peds too heavy" finding is now resolved by E's decimation.)
D3 — exposure sync at dusk/night — FOUND + FIXED A REAL BUG ✅
Verifying the shell exposed a genuine rendering bug (only correct on my direct-canvas test page):
the impostor material was toneMapped:false and applied ACES in-shader, but the shell renders
through an EffectComposer (RenderPass → bloom → OutputPass). three only tone-maps materials when
rendering to the canvas (three.module.js:6921–6931, currentRenderTarget === null); into a
composer target, materials output linear and OutputPass applies ACES once. So my self-tone-mapping
impostors were double-tone-mapped in the actual game (too dark), matching the rigs only on the
test page. Fix (impostor.js): the impostor is now a normal toneMapped:true material — it outputs
linear and uses three's own <tonemapping_fragment> + <colorspace_fragment> call-sites (the
*_pars_* are auto-injected — including them ourselves double-defines RRTAndODTFit and fails to
compile), so it tone-maps identically to the near rigs on both paths: canvas → ACES per-material;
composer → linear → OutputPass ACES. Verified: near rig + mid impostor match brightness at noon AND
the scene reads correctly at night 22:00 in the shell (docs/shots/laneD/r4_shell_*.jpg), and the
test-page canvas path still matches.
- Note for Lane F:
citizens.setExposure()is now a no-op (exposure is global viarenderer.toneMappingExposure, which both three and OutputPass read). The call atweb/index.html:245is harmless — keep or drop it, your call.
(Round-1/3 notes below remain valid; the tri-budget finding #1 is superseded by D2's decimation.)
What shipped
| file | role |
|---|---|
web/js/citizens/rigs.js |
ported rig stack: fleet loader, _canon/canonRig/_rotOnly, buildFigure (head-bone height-normalise + feet plant), spawnRig (single clip), makeActor (walk↔idle crossfade), pickRig |
web/js/citizens/placeholder.js |
seeded low-poly box humanoid (POLY lock) — walks/idles, planted at y=0, hot-swaps to a rig |
web/js/citizens/impostor.js |
4-yaw sprite-atlas baker + instanced billboard layer (mid tier, 1 draw call) |
web/js/citizens/sim.js |
deterministic roster, footpath lanes, near/mid/far LOD, rig pool, staggered mixer budget, time-of-day density |
web/js/citizens/keepers.js |
one keeper per shop at the counter slot, idle + greet head/body-turn |
web/citizens_test.html |
standalone harness: fixture 3×3 street graph, sliders, tier debug, determinism check |
web/models/peds/* |
19 rigged GLBs + walk.glb + idle.glb, byte-identical copies from 90sDJsim (checksummed) |
The fleet (copied, never edited)
19 rigged peds (17 normal + 2 comical) + 2 clip-only GLBs, ~37 MB total, copied from
johnking@100.91.239.7:~/Documents/90sDJsim/web/world/models/peds/ (also present locally at
~/Documents/90sDJsim/…). SHA-verified byte-identical (house law: canonical source stays upstream).
- Skeletons canonicalise cleanly:
mixamorig1…→mixamorig…, so one shared walk clip drives all 19 and one shared idle clip too. Verified in-scene — a singlewalk.glbanimates every ped. walk.glb/idle.glbposition tracks +Hips.quaternionare stripped (_rotOnly) — without this the different-scale root track inflates peds to giants. Confirmed: no giants/ants across all 19.
Measured budgets (M3 Ultra, citizens_test.html)
Preview note: the in-app browser reports visibilityState:'hidden', which throttles requestAnimationFrame,
so the on-screen fps counter reads low (47–84). True cost was measured by timing 140 manual
update()+render() frames.
200 citizens, midday (the acceptance fixture):
| metric | measured | CITY_SPEC / Lane-D budget | verdict |
|---|---|---|---|
| near (rigged) actives | 24 (hard cap) | ≤ 24 | ✅ |
| mid (impostor) | ~122 | — | — |
| far (culled) | ~54 | — | — |
| mixer update / frame | 0.4 ms max, ~0.2 ms avg | < 2 ms | ✅ comfortably |
| sim logic / frame | 0.24 ms avg | 4 ms build budget | ✅ |
| full frame CPU (update+render) | ~2.6 ms → ~380 fps headroom | 60 fps (16.6 ms) | ✅ huge margin |
| mid tier draw calls | 1 (69 instances, 138 tris) | 1 per atlas | ✅ |
| impostor atlas texture | 2048×640 (16×5 cells, 4 yaws × 19 peds) | ≤ 2048, < 512 MB total | ✅ (~5 MB) |
| determinism | live roster ≡ seed recompute, stable while walking | same seed → same crowd | ✅ |
Time-of-day density (200 base): 00:00 → 12 active, 06:00 → 70, 12:00 → 200, 15:00 → 150,
21:00 → 36. Curve in sim.js DAY_CURVE.
⚠️ Findings for Lane E / Lane F (need a decision)
-
The inherited peds are ~49.7k tris each — too heavy for the CITY_SPEC 200k-tri "typical view". 24 near rigs ≈ 1.2M tris; a realistic 16-rig street view already measured 1.02M tris. Framerate holds on M-series (GPU eats it), but this leaves nothing for Lane B buildings. Recommendation: decimate the ped fleet to ~5–8k tris in the asset pipeline (Lane E) — at 6k/ped the near cap costs ~144k tris, back within budget. The rig stack is indifferent to poly count; this is purely an asset job. Until then,
NEAR_MAX(sim.js) is the throttle. -
Draw calls scale with near rigs (~6 calls/ped — each GLB has several sub-meshes/materials). 16 rigs ≈ 144 calls; 24 rigs ≈ ~190. Under the 300-call street budget today, but tight once Lane B adds shells. Recommendation: merge each ped's sub-meshes by material on load (
BufferGeometryUtils.mergeGeometries) → ~1–2 calls/ped. Cheap win, deferred (not blocking). -
Peds are metallic-PBR (
metalness≈0.5) and render black without ascene.environment. The test scene builds a neutral PMREM; the impostor bake is fed the same env. Lane B's shell must setscene.environment(a sky PMREM) or every citizen — near and mid — goes dark. This is wired:CitizenSimreadsscene.environmentand passes it to the impostor baker. -
Impostor material is
toneMapped:falseand does ACES + sRGB itself (matchingACESFilmicToneMapping, exposure/0.6). The shell DOES animaterenderer.toneMappingExposure(Lane Blighting.jssets it per day segment), so mid billboards drift brighter/darker than the near rigs across the day unless matched. Round-3 fix:CitizenSim.setExposure(e)passthrough added — see the Lane F hook below. (For a non-ACES curve the in-shader ACES inimpostor.js IMP_FRAGwould also need to match; today both are ACESFilmic so exposure is the only variable.)
Hardened by a 4-dimension adversarial code review (see D-progress.md): 6 confirmed defects fixed — non-deterministic fleet order, shared-geometry disposal, impostor under-exposure, unrestored viewport, missing modelMatrix, frozen exposure.
Integration hooks for Lane F
new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet })—graph = { nodes:[{id,x,z}], edges:[{id,a,b,width,kind}] }. Swap the fixture graph for Lane A'sCityPlan.streets. Footpath lanes are derived from edgewidth+ a 0.9 m margin; pedestrians keep to the right of travel.sim.setPopulation(n)(slider / density) ·sim.setTimeOfDay(t01)(drive from the shell's day segment) ·sim.setPaused(bool)(wire tovisibilitychange) ·sim.update(dt)each frame.- [Round-3]
sim.setExposure(e)— one line, needed for dusk/night. The shell already callscitizens.setTimeOfDay(...)in the street loop (index.html:225); add right after it:
Cheap (sets one uniform), safe to call every frame, and survives the placeholder→rig atlas re-bake (stored internally). Verified incitizens.setExposure(renderer.toneMappingExposure); // [Lane F] keep mid billboards matched to day/night exposurecitizens_test.html: uniform tracks 0.55↔2.3 and persists across the fleet upgrade. Without it, mid impostors read too bright at night / too dark at noon vs the near rigs. (This is a Lane-F seam edit inindex.html; flagged here per the cross-lane note rule.) - [Round-3] Keeper fleet-upgrade (enables F §3.4). Keepers today are placeholder-only because F
builds
new KeeperManager({ camera, citySeed })with no fleet. To upgrade them to shared GLB rigs, pass the loaded fleet:new KeeperManager({ camera, citySeed, fleet }).keepers.jsalready picks a rig whenfleet.readyand falls back to a placeholder otherwise — no other change. Dispose is leak-free: verified 15 enter/exit cycles with a rig keeper →renderer.info.memorygeo/tex return exactly to baseline (_disposeInnerfrees the clone's skeleton bone-texture only; shared fleet geo/mats are preserved for siblings). Safe to spawn/remove()per interior enter/exit. - Chunk streaming (v1.5): roster is currently whole-graph — fine for v1, but big towns look sparse (see the design note "Chunk-streamed roster" below for the why + the full plan).
- Keepers:
keepers.spawn(roomGroup, { x, z, ry, shopId, type })— feedx,z,ryfrom Lane C's interior counterplaces; call on interior build,keepers.remove(handle)on dispose.keepers.update(dt, playerPos)each interior frame. ?noassets=1verified: full placeholder town, mixers=0, zero crashes.
Chunk-streamed roster — design note (✅ IMPLEMENTED in R5, behind ?roster=stream)
Round-3 task 3 (design), built in Round 5 — see the "ROUND 5" section at the top for the as-built
result + Lane F wiring. This section is the original spec; the implementation followed it: hook-driven
(onChunkBuilt/onChunkDisposed) with a poll fallback, chunk-local identity, global near-cap, owner
despawn, constructor opt-in (chunkStream). Kept for the rationale.
The problem (measured). setPopulation(N) builds one global roster and spreads it uniformly over
all edges (identityOf picks edge = rng()*edgeCount across the whole graph). On the 12-edge test
fixture that fills the view. On a real town — "Boolarra Heads" has hundreds of edges — those same N
citizens scatter town-wide, so almost all sit beyond the 70 m cull from any one camera (I measured a
27-node generated town: pop 200 → 4 near / 6 mid / 190 far). Raising N to fill the near streets
wastes roster + advance cost on citizens nobody can see, and still can't guarantee local density.
The fix. Generate + tick citizens per chunk, keyed so identity is independent of town size and visit order, and stream them in lockstep with Lane B's chunk window (the chunks already building/ disposing around the player). Density becomes constant per unit street, and total roster is bounded by the live-chunk window, not the town.
Design:
- Chunk-local identity. Replace the global running
idwith a per-chunk key:identityOf(citySeed, chunkKey, i)fori in [0, perChunkCount), and choose the citizen's home edge from that chunk's edges (chunkIndex(plan).chunks[chunkKey].edges), not the global list. Then "same seed + same chunk → same people" holds regardless of what else exists or the order chunks were visited — the determinism property that whole-graph keying quietly loses at scale.perChunkCountis seeded per chunk and scaled by district (main-street chunks busier than residential) ×densityAt(tod). - Windowing — poll-driven (works today, no Lane B change). Each frame derive the active chunk set
from the camera:
chunkKey(⌊camX/chunkSize⌋, ⌊camZ/chunkSize⌋)+ neighbours within a radius R (R≥1 so a walker never steps into an unloaded chunk before its owner unloads). Diff vs the live set → build sub-rosters for newly-active chunks, dispose for newly-inactive. This mirrors B's own window and needs nothing from B. Alternative — hook-driven:chunks.jsexposes an optionalctx.onChunkBuilt(seechunks.js:40— "inert unless a consumer sets it"); if B also addsonChunkDisposed, drive spawn/despawn off those instead of polling. Prefer this once B commits the pair; poll until then. - Storage. Swap the single
this.rosterarray forthis.chunkRosters = Map<chunkKey, Citizen[]>. The per-frameupdate()iterates the union of live-chunk citizens. Everything downstream is unchanged — LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer already operate per-citizen and are chunk-agnostic; the near-cap + mixer budget stay global across live chunks (nearest-first selection already does the right thing on the merged set). - Ownership + hand-off. A citizen is owned by its spawn chunk. It walks freely (the graph is continuous); because R≥1 keeps neighbours loaded, crossing a chunk boundary is seamless. It despawns only when its owner chunk unloads — even if it's momentarily standing in a still-loaded neighbour (acceptable: that's behind the player, past the cull). No re-keying on cross, so no identity churn.
- Budget. Live window ≈ (2R+1)² chunks ×
perChunkCount. TuneperChunkCount(~15–30) so the near streets reach the 24-cap while total roster stays ~150–270 — similar cost to today's flat 200, but concentrated where the camera is instead of smeared across the map.
Migration. Opt-in and back-compatible: new CitizenSim({ …, chunkStream: { chunkIndex, chunkSize, radius } }) switches on chunk-keyed identity + windowing; omit it and the current whole-graph roster is
unchanged. So v1 ships as-is and v1.5 flips a constructor option — no rewrite of the hot path.
Explicitly NOT in scope here: cross-chunk social groups, per-district behaviour trees, persistent
citizens (a citizen you saw yesterday) — all v2 (docs/V2_IDEAS.md).
Clip wishlist (for the mixamo-fetch run — CHECK THE 34 EXISTING CLIPS FIRST)
Only walk + idle are wired today (they ship with the ped fleet). CITY_SPEC says
~/Documents/mixamo-fetch/out/ already holds 34 clips (sit, lean, look-around, phone, …) — these
bind directly through the canonical mixamorig skeleton exactly like walk/idle, so wiring them is a
data task, not new rig work. Verify each exists before requesting; never re-download.
Wanted for richer loiter/keeper behaviour, in priority order:
sit— bench-sit at benches/verandah steps (sim loiter has the hook; needs the clip). R13: the gig drummer also wants this (or a bespokedrum/play-drumsloop) — the 4th band member is currently a standing rig sunkSEAT_DROPbehind the kit, which reads seated only from the front. A real seated clip retires the sink hack (band.jsSEAT_DROP+ them.seatedbranch inupdate).lean— loiter against a shopfront (window-shopping stops).look-around/idle-look— window-shopping variety + a better keeper greet than the body-turn.talk/gesture— pairs chatting on the footpath (spawn in seeded 2-groups).carry/ laden walk — shoppers with bags after a purchase (content-phase tie-in).browse/reach— keeper stocking shelves; customer reaching a shelf inside interiors.
Bespoke (won't be on Mixamo, hand-author later): crate-riffle at market stalls, till-operation.
Known limitations (honest)
- Near↔mid swap is a hard LOD switch at ~25 m (hysteresis 24/27 m). At that range a 128px impostor and the full rig subtend near-identical screen size, so it reads as seamless — but it is a switch, not a cross-fade (rig materials are shared across SkeletonUtils clones, so per-instance opacity fade isn't free). If a pop is ever visible after buildings land, the fix is a short dither/fade band.
- In
?noassetsmode, mid impostors use 8 generic placeholder variants while near placeholders are per-citizen coloured — so a citizen's colour can shift slightly crossing 25 m. Fallback-only; with the real fleet, near and mid are the same baked ped. - Loiter timing is dt-driven (cosmetic), so exact positions at time T aren't reproducible across runs; identity (who, which ped, height, speed, spawn beat) is fully seeded and asserted.