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

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

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

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

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

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

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

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

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

62 KiB
Raw Blame History

PROCITY-D — progress (Lane D · Citizens)

Round 42 (§42.4) — RECAST, WIRED: five bodies swapped at their own index, and the crowd did not move. (2026-08-07)

Lane E retired five ped bodies on ruling 1 and sourced five replacements. This round wires them, and the whole job is one sentence with a measurement behind every clause: the town changed clothes and nothing else changed at all.

The swap — IN PLACE, at the same index, in web/js/citizens/rigs.js

Decision stated plainly: the rejects are replaced at their index, not removed. pickRig indexes PED_NAMES, and every citizen stores the result as pedIndex, which is in the identity signature and is the impostor atlas's subject index. Deleting a name shortens a pool, renumbers every slot after it and moves every seeded identity downstream — the R2 fleet-order bug through a different door, and it would move Lane F's goldens. Replacing at the index moves nothing.

pool slot fleet.all out in Mixamo tris
normal 6 6 man_elder_01 (robed wuxia elder) man_casual_04 Ch31 2 533
normal 9 9 man_soldier_ww2_01 (WW2 infantryman) person_casual_01 Ch21 2 956
djs 4 21 dj_phrtt_01 (unclothed body) man_smart_01 Ch23 2 617
comical 0 22 comical_luchador_01 (EL CHUPACABRA) woman_smart_02 Ch41 2 494
comical 1 23 comical_boy_01 (cartoon child) person_youth_01 Ch22 2 738

Pool lengths unmoved: normal 17 · djs 5 · comical 2 · fleet.all 24 (?classic=1: 17 / 19). comical is now a misnomer kept on purpose — both members are ordinary people (E's ruling-1 call), but the KEY is load-bearing for fleet.comical, pickRig's 8 % branch and two QA gates, so the name stays and the meaning is documented at the declaration.

dj_phrtt_01 is gone from the default boot, measured at the wire, not asserted from the source: a fresh default context fetches 32 ped GLBs and not one of them is a retired body; ?classic=1 fetches 21, likewise none. The GLB is still on disk and still on the depot — neither is mine to delete (see For Lane E / Lane F below).

Determinism — the crowd is not merely reproducible, it is BYTE-IDENTICAL to before the swap

result
two fresh contexts, same seed → identity signature (id:pedIndex:pvar:height:speed:edge:forward) byte-equal, 150 active citizens
…and posture signature byte-equal
CONTROL: seed 20261991 differs (141 citizens) — byte-equal is not constant
CONTROL: every clip GLB delayed 2 s identical — posture is (citySeed, id) alone
pre-swap tree vs post-swap tree, same seed, same bookmark 0 differing lines of 150
three-way (pre-swap · post-swap · post-bench-rewire) all byte-equal

What moved: which GLB each pedIndex points at, i.e. what the crowd looks like — intended. What did not move: every pedIndex value, every pool length, every pickRig mapping, every citizen's height/speed/edge/direction, every posture assignment, and the impostor atlas's subject order. The identity signature diff across the swap is empty, which is the strongest form of this claim available: not "same seed gives the same crowd", but "the same seed gives the same crowd it gave yesterday, in different clothes".

Draws — a total cannot answer this question; a difference can

bookmark pre-swap post-swap post-bench-rewire budget
street_noon 194 194 195 ≤300
crossroads_busy 108 108 107 ≤300
night_crowd 127 127 127 ≤300
market_square 94 96 96 ≤300
night_neon 111 111 111 ≤300
venue_night 141 141 141 ≤300
district_posters 235 235 235 ≤300
patronage_door 105 108 108 ≤300
interior (record) 57 57 58 ≤350

Stated honestly: those totals are not evidence about my change, and I am not going to pretend they are. This is a shared tree with four lanes landing work in the same hour, sampled while a wall-clock crowd walks through frame; ±1 appears run-to-run on an unchanged tree, and the +2 at market_square / +3 at patronage_door persisted across both post-swap runs — so they are somebody's change, and the decomposition below proves they are not the crowd's.

The measurement that does answer it (r42_cast.py arm 4): render the frame, hide citizens.group, render again. The difference is the crowd's own cost, in one frame, with nothing else changing.

bookmark draws crowd in crowd out crowd's cost near rigs mid expected
street_noon 194 194 189 5 4 37 5
crossroads_busy 107 107 97 10 9 30 10
night_crowd 127 127 124 3 2 10 3
night_neon 123 121 119 2 1 9 2
district_posters 187 187 184 3 2 17 3
venue_night 139 137 133 4 3 4 4
market_square 112 110 109 1 0 10 1
patronage_door 105 105 102 3 2 36 3

8/8: the whole crowd costs nearRigs × 1 + 1 — one draw per near rig (every ped GLB is one primitive and one material, measured off the bytes for all five new bodies) and exactly one for the entire instanced billboard layer whatever it holds. At market_square the crowd's total cost is 1 draw, so the +2 there cannot be mine. A one-for-one swap of one-draw peds has no mechanism by which to move the street number, and ruling 4's nine draws are untouched. Same LOD tiers, same NEAR_MAX = 24 cap, same single mid draw.

The contract, re-measured on today's tree (E's claims verified, not trusted)

Read straight off the GLB bytes — no browser, no three, no deps — and then replayed through rigs.js's own filter over all 54 shipped clips (8 base + Lane E's 46-clip library):

ped nodes/joints prim/mtl/img anims boneset bindable tracks worst clip
man_casual_01 (reference) 67 / 65 1 / 1 / 1 WebP 0 3 456 / 3 460 64
man_casual_04 67 / 65 1 / 1 / 1 WebP 0 IDENTICAL 3 456 / 3 460 64
person_casual_01 67 / 65 1 / 1 / 1 WebP 0 IDENTICAL 3 456 / 3 460 64
man_smart_01 67 / 65 1 / 1 / 1 WebP 0 IDENTICAL 3 456 / 3 460 64
woman_smart_02 67 / 65 1 / 1 / 1 WebP 0 IDENTICAL 3 456 / 3 460 64
person_youth_01 67 / 65 1 / 1 / 1 WebP 0 IDENTICAL 3 456 / 3 460 64

E's numbers reproduce exactly, including the facing check (toeheel ΔZ / span +0.100 … +0.136, all positive, front = local +Z). No Draco anywhere. I did not need to fix an asset.

And the control found something E's audit did not. The gate needs a subject that must FAIL, and one was already on disk: 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} — carry no mixamorig: prefix. A ped that shipped for 41 rounds had been silently dropping six tracks per clip on both hands. It is being replaced for period reasons; it should have been replaced for correctness reasons too.

Animation — every new body actually MOVES, not merely loads

Each replacement spawned by name through the real makeActor path, playing walk and then idle, 24 mixer steps at 1/30 s. The reference ped runs the identical course as the control.

body walk: tracks · mixer Δt · bone travel · stature range · head-above-hips idle
man_casual_04 64 · 0.8 s · 83.4 · 1.6871.728 m · +0.586 m 64 · 0.8 s · 47.2 · 1.7611.799 m · +0.583 m
person_casual_01 64 · 0.8 s · 81.9 · 1.6631.722 m · +0.580 m 64 · 0.8 s · 45.4 · 1.7541.783 m · +0.578 m
man_smart_01 64 · 0.8 s · 84.3 · 1.6671.722 m · +0.541 m 64 · 0.8 s · 46.9 · 1.7481.786 m · +0.539 m
woman_smart_02 64 · 0.8 s · 83.6 · 1.6521.721 m · +0.567 m 64 · 0.8 s · 45.8 · 1.7461.775 m · +0.565 m
person_youth_01 64 · 0.8 s · 83.9 · 1.6621.731 m · +0.534 m 64 · 0.8 s · 47.0 · 1.7361.765 m · +0.529 m
man_casual_01 (control) 64 · 0.8 s · 85.9 · 1.6781.727 m · +0.577 m 64 · 0.8 s · 47.8 · 1.7641.805 m · +0.575 m

64 bound tracks each — the reference's own score. The two traps E's clip work guarded against are checked directly: the flat-body / R16 fold (head never closer than +0.53 m above the hips through either clip; a folded body reads ~0) and the stature collapse (never below 0.94 × nominal). Bone travel is ~83 for walk and ~46 for idle on all six — the skeleton is genuinely moving, and moving the same amount as the ped that has shipped since R2.

No-giants gate — green, and now spanning three bind scales

tools/qa/interior_scale_check.py: PASS — 29 interior figs across 6 shops all human-sized [1.4, 2.0] m and under-ceiling. The R36 gated-tail leg now spans every native bind scale the roster contains and rigs.js's span-normalise absorbs all of them: woman_dj_01 0.986u→1.63m · woman_dj_02 0.987u→1.69m · woman_dj_03 0.984u→1.76m · dj_techno_01 0.986u→1.85m · man_smart_01 **1.764u**→1.82m (the legacy roster is ~173180 u, the centimetre convention). One line changed in that gate, and it is declared here because the file is not on my §42.4 editable list: its DJ_BODIES array named dj_phrtt_01 literally, so the swap would have turned it red with "NOT IN FLEET". dj_phrtt_01man_smart_01 — the same PED_NAMES.djs slot, the same fleet.all[21], so the leg still covers exactly indices 1721. No threshold, rule or assertion was touched.

Impostor vs rig at the swap boundary

R2's break was a systematic 1.67× — a double-tone-mapped impostor material, every ped at once — so an absolute tolerance is the wrong instrument. 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 and 1.000 was never the contract. Method: stage the subject in the live shell at a fixed spot, once as a near rig posed at the atlas's own bake phase and once as a single instance of the LIVE atlas; difference each against an empty frame; count only pixels inside the billboard quad's projected box. Contact strip: docs/shots/laneD/r42_impostor_boundary.jpg.

billboard/rig luminance
the 19 bodies this round did not touch (control population) 0.470 2.100, median 0.972
worst-matched body in the whole town man_dj_streetwear_01 0.470 — shipped long before this round
the five new bodies 0.960 · 0.673 · 1.171 · 1.323 · 1.098 — every one inside the untouched roster's own spread
systematic shift (new median ÷ untouched median) 1.13× (R2's break was 1.67× on the whole fleet)

Verdict: the swap does not widen the near↔mid seam. Filed for a later round, not faked here: the seam itself is loose (a 4.5× spread across the roster) because the atlas is baked under fixed studio light and displayed under the sun — a real improvement is baking under the scene's own key, and that is a system, which ruling 5 forbids this round.

Lane B's §42.5 landed mid-round and broke my bench code. The mirror is retired.

B fixed a 41-round-old defect: benches were placed with yaw = atan2(ux, uz) — local +Z down the street — while both files' comments said "facing the road". My R41 benchStationsFor() was a faithful copy of that wrong line, so the fix made it 90° stale and _seatPose would have put sitters up to 0.38 m off the plank facing along the street.

Fixed by deleting the copy, not by rotating it. sim.js now imports benchStops(plan) from world/furniture.js and benchStationsFor(e) is a thin adapter that wraps the sim edge back into the two-node plan shape resolveEdges() wants. It has no arithmetic of its own; the rule exists once in the repo, in the function B's placer also calls. The three constants the mirror carried (FURN_FOOT, BENCH_S0, BENCH_STEP) are gone from sim.js.

measured
bench front vs direction-to-road, 40 stations 0.0° (min = median = max) — was 90.0°
bench front vs the street's own direction 90.0°
stations matched to real instanced geometry (r41_citizens.py arm 6) 14/14 within 2 cm · 14/14 on yaw
CONTROL: the same stations offset 2 m 0/14 — "coincides" is a measurement
live sitters, off the bench origin +0.06 m in front · ±0.38 m along the plank · 0.0° between look direction and the road
identity + posture signatures across the rewire byte-identical (station COUNT unchanged ⇒ same randoms drawn)

_seatPose needed no edit: it was written in the bench's own local frame (+Z front, +X plank), so the corrected yaw carries straight through. The import is static on purpose — the bench check draws three seeded randoms per crossed station, so a dynamically-imported station list would draw zero until it landed and "same seed → same crowd" would start depending on the network. chunks.js already imports furniture.js on every boot (classic and ?noassets included), so the game path gains no fetch, no module and no behaviour. The standalone citizens_test.html does gain one fail-soft depot fetch; smoked green, 507 active citizens, 0 console errors.

The browse smoke (F's filing) — fixed, and r41_shots.py exits 0

The arm scanned up to 8 occupied shops at ~3.1 s each, kept the id of the best, exited, and re-entered it 1625 s later — choosing a subject out of a live simulation and then going back for it. The fix is not a longer wait or a retry: the frame, the measurements and the JPEG are now taken inside the winning room, the moment a candidate beats the incumbent, and the harness never leaves. The scan keeps its original "best of several, not first that works" behaviour. The winning shop's occupancy at entry and at the end of the scan is printed into the sidecar on every run, so the decay the old path walked into stays legible instead of being a story in a comment.

Result: tools/qa/r41_shots.py rc 0Echo Toys & Games, 3 browse points, 1 browser in frame playing browse_open_cabinet, keeper on idle_weight_shift, 61 draws of the ≤350 interior law, both figures human-sized. It can go strict.

The diagnosis, corrected by measuring it. F filed this as deterministically red. It is not, on today's tree. I ran the R41 arm verbatim, inside its own harness, after the same street arm and the same 60 s soak, and it came back {browsePoints: 3, browsers: 1} on the re-entry — green. Two isolated repros agreed (one found 2 browsers still there after a 16.4 s scan, occupancy 2 → 2). So the correct claim is not "it was always red and now it is green"; it is that the arm was flaky by construction — it chooses a subject out of a live simulation and then goes back for it 1625 s later, and whether the occupants are still there is not something the harness controls. That is exactly the property that cannot go --strict. Removing the window removes the flake; the residual failure mode ("no occupied shop has browse points with browsers at all") is real, deterministic, and worth failing on.

Gates

tools/qa/r42_cast.py            PASS — contract · roster · determinism · draws · animation · impostor
tools/qa/r41_citizens.py        PASS — boot ledger, lazy loading, determinism, draw table,
                                       asset-free, bench binding (14/14 + 14/14 yaw), venue clips
tools/qa/interior_scale_check.py PASS — 29 figs / 6 shops, all human-sized, under ceiling
tools/qa/r41_shots.py           rc 0 — street + browse interior, browse arm now shot in place
tools/qa/r42_shots.py           rc 0 — r42_street_crowd.jpg + r42_pub_crowd.jpg, cast named in both
console errors                  0 on default · 0 on ?classic=1 · 0 on the impostor arm

Shots

  • docs/shots/laneD/r42_pub_crowd.jpgthe before/after, shot on Lane F's own §41.6 hero recipe (?gigs=1, segment 5, The Exchange Hotel, Lane C's room.spawn line, back 5.2 m, eye 1.62 m) so it is frame-comparable with docs/shots/laneF_r41/pub_furnished.jpg. BEFORE: a purple EL CHUPACABRA luchador filling the foreground, a second on the stage, and an unclothed figure at the stage edge. AFTER: 13 figures wearing 11 distinct bodies, none of them retired.
  • docs/shots/laneD/r42_street_crowd.jpg — the recast roster on the footpath, with the corrected bench pose.
  • docs/shots/laneD/r42_impostor_boundary.jpg — near rig ‖ mid billboard for the five new bodies plus the reference, with each pair's measured luminance ratio.
  • Both crowd sidecars carry the R10 human-sized line and a CAST line naming every body in frame and asserting none is retired — because "the luchador is gone" is a claim about the cast and a JPEG cannot carry it alone.

For Lane E / Lane F

  • dj_phrtt_01.glb is still in web/models/peds/ and still on the depot (_normalized/, _published.json). It is no longer reachable from the game — nothing names it — but the file is not mine to delete. E: withdraw the depot copy. F: the §42.6 period-law name gate should read PED_NAMES, not the directory listing, or it will go red on files that no longer ship.
  • comical_boy_01 was a real contract break, not only a period one (3 132/3 460, six unprefixed thumb bones). If any other Mixamo asset was converted through the same path, check its boneset.
  • The near↔mid impostor seam is loose town-wide (0.4702.100 across the roster; worst is man_dj_streetwear_01, not a new body). Filed for R43: bake the atlas under the scene's own key.

Files touched

web/js/citizens/rigs.js (the swap + the law at the declaration) · web/js/citizens/sim.js (bench mirror retired → benchStops import) · web/js/citizens/keepers.js (publish pedIndex on the keeper handle — informational, draws no randoms, so the room's cast is auditable at runtime the way band.js members already were) · tools/qa/r42_cast.py (new) · tools/qa/r42_shots.py (new) · tools/qa/r41_shots.py (browse arm) · tools/qa/interior_scale_check.py (one array entry, declared above) · D-progress.md · docs/LANES/LANE_D_NOTES.md · docs/shots/laneD/**. No git command was run. I did not touch web/models/**, web/assets/**, pipeline/**, web/js/world/**, web/js/interiors/**, web/index.html or tools/qa.sh.


Round 41 (§41.3) — the citizens come alive: Lane E's 46 clips wired, 20 of them now in play. (2026-08-07)

The measurement that is the round. A census of the live crowd at the retail heart, 12 samples × 146 active citizens, same seed, same pose, two arms — where ?clips=0 is a new flag that turns the motion library off and nothing else (?classic=1 also changes the ped pool, the fog, the game and half the shell, so it cannot be the control for this):

state of the street crowd ?clips=0 = R40 default = R41
walking 99.3% 78.1%
sitting on an actual Lane B bench 9.5%
leaning on a shopfront 7.1%
stopped at a window in their own seeded idle 0.4% 5.1%
R17 free sit · R29 glance 0.2% · 0.2% 0.1% · 0.1%
distinct animation clips in play across the crowd 4 (walk/idle/sit/look — and 99.3% of the crowd is on walk.glb alone) 20

Lane E's input was verified before anything was wired: python3 pipeline/clips_verify.py → GREEN, 0 errors / 0 warnings, 46 clips / 6 groups / 3 498 124 B, every group zero-draw.

What each clip category drives

group clips used drives
idles.glb (boot) 10 of 10 the per-citizen deterministic idle — the resting action of every near-tier actor, plus the seeded shopkeeper idle
locomotion.glb (boot) 1 of 6 walk_shopping_bag — the walk pool is ['@walk','@walk','walk_shopping_bag'], so 1 in 3 citizens carries a bag (measured 33.7% over 1 000 ids)
sitlean.glb (lazy: first sit/lean intent) 8 of 8 4 sits bound to Lane B's benches, 4 leans bound to shopfront walls
browse.glb (lazy: first interior with browsers) 5 of 8 a real BROWSE state at Lane C's browse points, seeded per (shopId, slot)
venue.glb (lazy: gig night, from the street) 5 of 6 gig-crowd widening + the publican pouring + the record-shop keeper in headphones
social.glb 0 of 8 — never fetched needs a two-person state machine + partner pairing. 896 KB for a state D does not have. → R42

Unused and why: the four turn_* + walk_to_stand (the sim turns instantly at a node — a turn state is R42 and is the only thing that makes those five mean anything), browse_hold_turn_l/r (172°/147° loop seams, one-shot turns), venue_clap_seated (no seated venue slot).

Boot fetch count and memory — measured in fresh headless contexts

clip 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 +1 venue.glb 2 602 476 B 5 / 38
?clips=0 · ?classic=1 · ?noassets=1 0 0 0 / 0

Six fetches at boot was what the round warned about. The answer is two GLBs at boot, three on first demand and one never, each promise-cached (the bank does not grow across 6 interior enter/exit cycles). Heap delta (library ON OFF), forced GC, fresh contexts: +3.34 MB and +6.87 MB across two runs — the spread is GC timing; the floor is resident clip bytes plus parsed AnimationClip overhead. mixerMs unchanged: median 0.1 ms both arms.

Determinism — same seed, same postures, byte-equal

  • 150 active citizens, two fresh browser contexts → byte-equal posture signature.
  • CONTROL: seed 20261991 gives a different signature — "byte-equal" is not "constant".
  • CONTROL that matters for lazy loading: with every clip GLB delayed 2 s by a Playwright route, the signature is identical. Posture is a pure function of (citySeed, id) (postures.js posturesFor), decided once at citizen creation and never re-rolled, so loading can change when a posture appears, never which one.
  • One level down: fleet.clipsRequested is published synchronously by loadPedFleet before the dynamic import starts, and every roll that can move a citizen is gated on that rather than on "has it landed", so the number of randoms drawn per boot cannot depend on the network. Six freshly-keyed streams (posture, benchstop, leanstop, browse-pose, keeper-pose, gig-pose) collide with none of the 12 pre-R41 keys — gated, with the control that all 18 produce different draws on the same id.
  • 1 000 citizens × two module instances in node: identical (sha256 08ecfe041b384460).

Draw table — 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

Ruling 4 gave the street no headroom and the round spent none. The clips are skeleton-only (0 tris, 0 meshes, 0 materials), so the only mechanism by which they could move a draw count is putting a ped somewhere else — bounded by the unchanged near-cap of 24 rigs, each already one draw. Stated, not hidden: 193 is the worst of these five bookmarks on this seed, not the town's global worst (B's R40 street number is 291/300 at a night pose these bookmarks do not reach). The delta is the claim, and it is 0.

?noassets=1 and ?classic=1 — the fallback survives, proven

clip fetches bank town console
?noassets=1 0 (no GLB, no manifest, not even clipbank.js) none 20 chunks, 165 citizens walking 0 errors
?classic=1 0 none 18 chunks, 139 citizens walking 0 errors
?clips=0 0 none full town 0 errors

The library rides clips = dance, the R36 djs = dance trick again: the shell already passes dance: !CLASSIC, so no index.html edit was needed and the zero-fetch-delta covenant holds — including on the JS surface, because clipbank.js is reached by a dynamic import() and is not fetched when the gate is off. 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.

Leak

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, and _disposeInner still disposes only the clone's own Skeleton.

The two measurements that changed the design

  1. The idle pool was invisible. Wired only to the R17/R29 window-shop loiter — which fires at a graph NODE, and edges are long — a census found 0.8% of citizens stopped at any instant. Nine of ten new idles were assigned, deterministic, and never seen: the brief's headline failing while the gate went green. Fix: a window pause on the R8 patronage stride check (the moment the sim already asks "is there a shop beside me"). Stopped share 0.8% → 5.1%, and all ten idles now appear in a 12-sample census.
  2. The lean was never firing. Also wired to the node loiter at first — i.e. at intersections, where there is rarely a shopfront to lean on. Measured: 0 leans in a 9 s run. Moved to the same stride check. Now 7.1%.

The bench is a real bench

R17's bench-sit sat a ped down at whatever node it stopped at, upright, on air. R41 binds it to Lane B's actual furniture: benchStationsFor(edge) mirrors furniture.js:203-209 exactly, a ped can only take a bench on its own footpath side, and the 1.6 m plank seats two (a measured need — the first soak put two citizens on one station to the centimetre). Because that is a mirror of another lane's file, it is gated, not trusted: every derived station inside the streamed window must coincide with real instanced geometry within 2 cm, yaw included — today 14/14 and 14/14, against a scene of 2 435 instances. CONTROL: the same stations offset 2 m match 0/14. Filed to Lane B: one benchStops(plan) export next to the existing busShelterStops(plan) retires the mirror.

Gig — widened, not redesigned

The v3 dance pick (gigdance over fleet.danceClips) is untouched. A second, independently keyed gig-pose roll decides whether a crowd slot takes a venue clip instead — northern soul for a dancer, clap/cheer/headphones for a stander. Measured on a live gig: 8 crowd slots, 6 swapped, 2 keeping their R13 pick verbatim, 0 pending. A slot that does not swap, and every boot where venue.glb is not resident, is byte-identical to R13.

Gates added

gate what it proves
node tools/qa/r41_postures.mjs manifest resolution · determinism · stream isolation · pool spread · loop safety. Zero deps, ~40 ms, five controls.
tools/.venv/bin/python tools/qa/r41_citizens.py 7 arms in fresh contexts: boot ledger · lazy loading · determinism incl. the 2 s forced stall · draw table both arms · ?noassets/?classic · bench binding + control · leak · gig widening.
tools/.venv/bin/python tools/qa/r41_shots.py the two acceptance shots, camera chosen by measurement and occlusion-raycast, every figure measured for stature.

→ F: three lines for qa.sh. All exit 0/1, all deterministic.

Shots

docs/shots/laneD/r41_street_postures.jpg — seed 20261990, midday, camera (11.6, 58.5) yaw 1.890. 8 near-tier rigs in frame, 3 distinct clips (@walk, walk_shopping_bag, sit_hands_thighs): a hi-vis worker sitting on a real Lane B bench with his feet on the footpath, a comical ped walking centre-frame, and six more walkers up and down the strip. 126 draws of a 300 budget.

docs/shots/laneD/r41_browse_interior.jpg — The Op Shop (opshop), 3 browse points. A browser at the clothes rack on browse_hold_idle — the ped who ducked in off the street, holding something instead of standing to attention — and the keeper behind the counter on idle_happy_1, his own seeded idle. 69 draws of a 350 budget.

Both carry a .txt sidecar with the human-sized line: stature measured off the posed skeleton for every figure, as a ratio of that citizen's own nominal height. Today: sits 7677%, browsers/keepers 99101%, walkers 95100% of nominal. No giant (>2.0 m), no fold (<55% of nominal).

Goldens

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

Files touched (only these)

web/js/citizens/postures.js (new) · web/js/citizens/clipbank.js (new) · web/js/citizens/rigs.js · web/js/citizens/sim.js · web/js/citizens/keepers.js · web/js/citizens/band.js · tools/qa/r41_postures.mjs (new) · tools/qa/r41_citizens.py (new) · tools/qa/r41_shots.py (new) · docs/shots/laneD/r41_*.{jpg,txt} (new) · D-progress.md · docs/LANES/LANE_D_NOTES.md. No git command was run. Nothing under web/models/, web/assets/, web/js/world/, web/js/interiors/, web/index.html or tools/qa.sh was touched.


Round 40 (§40.5) — the door points and the footpath: measured, kerb-clamped, gated. (2026-08-04)

Verification first, per the brief. New gate tools/qa/door_footpath_check.mjs re-derives every door point EXACTLY as the shell does (lot + front-normal·(d/2+0.6), web/index.html:433-437) and classifies it against the plan's own geometry (registry's vergeBand corridor law + lots + blocks). All numbers below are from today's runs on today's tree.

town R39 (shipped) today, B's fix only — footpath today — walkable front after D's clamp — footpath after D's clamp — walkable front
synthetic@20261990 0/493 447/493 · 90.7% 473 · 95.9% 447 · 90.7% 473/493 · 95.9%
katoomba_real 0/72 71/72 · 98.6% 71 · 98.6% 72 · 100% 72/72 · 100%
fitzroy_real 0/139 138/139 · 99.3% 138 · 99.3% 139 · 100% 139/139 · 100%
bowral_real 0/30 28/30 · 93.3% 28 · 93.3% 30 · 100% 30/30 · 100%

Verdict: B's two-character R39 fix did the heavy lifting (0% → ~9199%) but did NOT clear the ≥95% gate alone — bowral read 93.3% and the synthetic 90.7% strict. Two residue species, and they wanted different answers:

  1. Kerb misses (fixed by D). On real towns plan_osm sets lots back at max(KERB, roadWidth/2 + FOOTPATH), so on narrow corridors the facade sits past the corridor edge and the shell's +0.6 nudge lands the door point 0.85 m outside the verge band — 1/72 katoomba, 1/139 fitzroy, 2/30 bowral. Fix: web/js/citizens/door_snap.js (new, pure, no THREE, no rng) — clamp a door point into the footpath band of its nearest footpath-bearing street, applied at intake in sim.setShops. Registry's published vergeBand over the sim's own edges; graph-only stays graph-only. Capped at 6 m so a plaza front is never dragged to a street it doesn't front; margin 0.45 m off the band edges. Transition-checked corpus-wide: fixes 4, breaks 0 (a gate arm asserts breaks = 0 forever). Live-shell verified: booted the game, diffed sim.shopsByChunk against the raw derivation in the running page — 493 points, 2 moved, max 5.41 m, exactly matching the node run.
  2. Plaza fronts (a definition, not a defect). The synthetic's 36-shop strict-footpath residue is market stalls fronting the market square (use:"stall", block 25 kind:"market") and arcade shops — their front IS the square, they can never be in a street's verge band, and dragging their door 5073 m to a street they don't front (measured: that's what an uncapped snap does) would re-break the front-door truth. The gate therefore scores walkable front ground — footpath, or the open ground of a market/arcade block (outside every lot, off every carriageway) — and reports the strict R39-style footpath number alongside for continuity. Peds never reach stalls anyway (5073 m from every walkable edge vs PATRON_RANGE 18) — their door points are inert data today.

Remaining synthetic residue (20/493, under the floor, documented not fixed): stall-vs-stall front overlaps (6), stalls at the square's edge clipping a carriageway (8), 3 shop + 1 anchor corner cases, 2 shops fronting into a neighbour lot. All are derivation artefacts of the fixed d/2+0.6 nudge in web/index.html:433-437 (F's shell, B's R39 hands) — if a later round wants them, the recommended fix is deriving from buildShopfront's own doorRect instead of the fixed nudge; measured detail in LANE_D_NOTES §40. Out of D's graph-only reach and under the gate's floor.

Gate arms (all green, 12/12): rate ≥95% on all four towns · CONTROL — the back point (the exact R8R39 defect) must score under the floor on every town or the classifier is vacuous (it reads 0.413.3%) · FIX-ONLY — the clamp breaks 0 previously-passing points. Deterministic: two runs byte-identical JSON. Goldens untouched: selfcheck 157,647/157,647, fingerprint 0x5f76e76 (the clamp is runtime pose in D's sim; citygen never touched).

The picture: docs/shots/laneD/r40_front_duckin.jpg — a rigged ped mid-stride ON the footpath, frozen 3.2 m from the VIDEO BARN front door it is ducking into (staged via the sim's own _beginVisit + a pause at the threshold; steering, door point and entry are the shipped code).

→ Fable/Lane F (one line, as briefed): please wire node tools/qa/door_footpath_check.mjs into tools/qa.sh (rc 0/1, ~2 s, zero deps — same shape as the other node gates). Not editing qa.sh myself; F owns the runner.

Files touched: web/js/citizens/door_snap.js (new) · web/js/citizens/sim.js (import + setShops clamp) · tools/qa/door_footpath_check.mjs (new gate) · docs/shots/laneD/r40_front_duckin.jpg (new) · this file · LANE_D_NOTES §40.

Round 12 (v3.0-alpha) — the band and the crowd, behind ?gigs=1. (2026-07-15)

Friday night at The Thornbury Hotel: a band on C's stage and a mixed crowd at C's watch points. New web/js/citizens/band.js (GigCrew) + a gig surge in sim.js. Verified against A's real plan.gigs + C's real venue interior (F wires the ?gigs=1 shell seam last, so I drove A+C+D directly in a standalone scene). Deterministic from citySeed; asset-free-safe (placeholder crew).

Band trio (task 1) — 3 rigs on stage.bandPoses at deckY, distinct fleet peds (seeded shuffle per gig — a band with two identical members reads wrong), each with a primitive instrument that rides the chest: guitar (strum-sway), mic stand (vocal sway), bass (strum-sway). E's GLBs drop in via opts.instrumentFor(role) when they land. NB C's bandPoses roles are guitar/vocal/bass (a front-line trio) — there is no drummer pose (C's drum riser is set-dressing); flagging in case a drummer was expected.

Crowd (task 2) — up to 8 rigs at watchPoints on the same gate-protected spawnRig path. dance:false → subtle weight-shift idle; dance:true → bounce + sway + step, seeded phase so the crowd never metronomes. Verified 3/8 dancing (C's ~⅓).

Gig surge (task 3)sim.setGig(venueShopId, on): while on, patronage pulls peds from further (GIG_RANGE 34 m) and ducks in harder (GIG_SURGE 0.55) at the venue; occupants drain at close per A's closing-time ruling (no special handling — _openAt + the dwell timer do it). Measured a mock venue climb 1 → peak 5 occupants; identity signature unchanged; gig-off byte-identical (guarded by _gigVenue==null, so the frozen goldens don't move — prime flag law).

Verified (standalone, fresh browser): band 3 · crowd 8 · dancing 3 · all 11 figures human-sized (heights 1.601.83 m, crown < 4.0 m ceiling — the R10 no-giants gate holds for the gig rigs) · band faces audience + crowd faces stage · deterministic (identical twice) · leak-free (8 spawn/dispose cycles, geo/tex delta 0). Budget: the crew adds ~40 draws (11 rigs + 3 instruments); the pub interior itself is the bulk — F measures the full gig-night ≤350 in the shell.

Figures human-sized relative to doors/fittings: yes — measured (1.601.83 m under a 4.0 m ceiling) and shown in docs/shots/laneD/r12_gig_night.jpg (distinct band with instruments, mixed crowd watching).

⚠ Cross-lane flag for C/F — facing convention. C's watchPoints/bandPoses ry point the fleet mesh 180° from where spawnRig plants it (verified in-scene: ry=π faced the band at the backdrop; the watch-point ry faced the crowd at the door). The fleet mesh's visual front is its local +Z, not the Z the sim/keeper comments assume. band.js compensates with a RY_FLIP = π on spawn so band→audience and crowd→stage. If C changes the gig-pose ry convention (or the fleet is re-fronted), remove RY_FLIP. Worth a look at whether keepers/browsers are subtly back-to-front too.

For Lane F (wiring the seam):

import { GigCrew } from './js/citizens/band.js';
const crew = new GigCrew({ citySeed: plan.citySeed, fleet });
// on gig doors/on, inside the venue interior:
crew.spawn(room.group, { stage: room.stage, watchPoints: room.watchPoints, gig: tonight });
// each interior frame: crew.update(dt);   on gig-off / room exit: crew.disposeAll();
citizens.setGig(gig.venueShopId, on);      // street surge while doors/on; setGig(null,false) to end

Round 10 (blocker fix) — interior giants → human-sized. qa --strict GREEN. (2026-07-15)

Fixed the R9 ship-blocker: interior rig figures were ~2× too tall (keeper crown at 3.83 m in a 3.4 m room, clipping the ceiling). Measured live, not from notes.

  • Root cause (rigs.js buildFigure): it normalised scale by height / headY, where headY is the head bone's world Y in bind pose. But the Mixamo skeleton's ORIGIN is the hips (~mid-body), so headY is head-above-hips (~half the standing height) — not the feet→crown height. Result: every rig ~1.832.0× too tall. (NB Fable's "bind-pose 82 vs posed 180" hypothesis was close on the numbers but the head does NOT move when posed — bind crown 3.209 m vs idle-posed 3.205 m. The 82 is head-above-hips; the 175 is the true feet→crown span. So it's a hips-relative measure, not animation.)
  • Fix (one logic line): normalise by the feet→crown span = headY - minY (both already computed). Now buildFigure(rig, h) lands the crown at exactly h above planted feet. Verified all 19 fleet rigs → crown == 1.75 m (feet 0); live keeper in "Second Time Redfern" 3.83 m → 1.82 m. This one change fixes keepers, browser rigs, near-tier street rigs AND the impostor bake (all ride buildFigure) — street peds looked fine only because they're mostly impostors, which are sized by requested height and so masked the bug.
  • Regression guard (required) — new tools/qa/interior_scale_check.py (Playwright): enters a sample of open shops, injects browser rigs at every browse point, and asserts EVERY interior fig's crown ∈ [1.4, 2.0] m AND < room dims.H AND feet planted. Wired into tools/qa.sh --strict (gate 6, run_gate ⇒ a giant fails qa). Proven bidirectional: PASS on the fix (24 figs / 6 shops), FAIL on a reintroduced giant (crowns 3.44.05 m, all 6 shops red). qa.sh --strict = 6 passed, 0 failed.
  • Determinism + leak re-verified: same seed → same keeper height + ped (shop 18 → 1.823 m, idx 10, identical twice; buildFigure crown stable & == requested); 15 spawn/dispose cycles → geo/tex delta 0.
  • Re-shot laneD/r9_browsers_in_shop.jpglaneD/r10_browsers_fixed.jpg (kept r9 as the documented before-state).

Figures human-sized relative to doors/fittings: yes — verified live (measured crowns 1.61.86 m across 6 shop types under 3.44.0 m ceilings) and visible in r10_browsers_fixed.jpg (a hi-vis worker and a hoodie browser both well under the ceiling, in proportion to the shelves and door).

Files touched (mine only): web/js/citizens/rigs.js (fix), tools/qa/interior_scale_check.py (new gate), tools/qa.sh (+gate block, authorized by ROUND10 §Lane D), docs/shots/laneD/r10_*.jpg, docs.

Round 9 (v2.0 ship) — interior presence: occupancy truth + browser rigs. qa GREEN.

Built the D side of the one cross-lane seam (C browse points → D occupancy+browsers → F handoff). Follow a ped into a shop and find them browsing. Full numbers + F wiring in LANE_D_NOTES.md.

  • Occupancy truth (D1): patronage records who's inside which shop by shopId. New citizens.occupancyOf(shopId) → {count, occupants:[{seed,enteredAt,pedIndex}]}. Cleaned on emerge/chunk-drop/stream-toggle. Deterministic (17 shops + occupants byte-equal across two runs).
  • Browser rigs: keepers.js gained browse (faces shelf, no greet) + pedIndex (the browser IS the ped who ducked in) + seedKey. Validated 12 enter/exit cycles: count == min(occupancy,3) 12/12, worst 61 draws ≤350, leak-free (0 GPU delta), disposed every exit. Merged peds = ~1 draw each.
  • Consistency (D2): inside peds hidden on the street (0 rendered), re-emerge at the door.
  • F handoff (verified by running F's exact wiring): enter → occupancyOfbrowsePoints.slice(0, min(count,3))keepers.spawn({...browse:true, pedIndex}). web/index.html setShops door points need shopId: s.id added. Closing-time handled by the dwell model (pending A's explicit ruling).
  • Evidence: docs/shots/laneD/r9_browsers_in_shop.jpg. v1 path untouched.

Changed web/js/citizens/sim.js (occupancy) + keepers.js (browser mode) + citizens_test.html (shopId in synthetic shops).


Round 8 (the crowd comes alive) — shop patronage v0 + weather reaction. qa GREEN.

Also did the R7 post-flip shell verification (F's flip landed) + pushed the tags to origin (main 1a20501→900b274; v1.0/v1.1/v2.0-alpha/v2.0-beta all on origin).

  • Post-flip verify (real shell): no-flag boot → streamMode true, determinism (188 ids), leak-free (0 delta), setNightLivelyChunks wired; ?roster=v1 → fixed roster, golden identity byte-identical.
  • Shop patronage v0 (D1) : streamed peds duck into open shops they pass (going→inside→emerge, hidden while inside), hours-aware — noon variety (up to ~38 inside, 7 types), night video-only (the open-late shop draws the crowd). Deterministic (150 chunk-keyed ids), leak-free (0 GPU delta over the churn), budget-neutral (draws 1, inside peds hidden). Behind ?patronage=0, default-on for stream.
  • Weather reaction (D2) : reads B's PROCITY.weather (no import). Rain → density 56% (4060% target), walk speed +~14%, more sheltering (inside 6→25); overcast 10%; clear = v1.
  • v1 path untouched (no patron fields, golden identity). Full numbers + F's setShops/setWeather/ ?patronage=0 wiring in LANE_D_NOTES.md.

Only web/js/citizens/sim.js + web/citizens_test.html (synthetic-shop demo + flags) changed.


Round 7 (the flip) — MERGE VALIDATED, GO given to F, defaults + escape hatch ready. qa GREEN.

Validated Lane E's ped-merge (the gate my R6 memo named) and handed F the GO for the roster flip. Full go/no-go numbers + F's two-line wiring at the top of LANE_D_NOTES.md.

  • Merge validated (D1): all 19 peds now 1 mesh / 1 material (was 8/2) → ~1 draw/near-rig (fleet 116→24 at the cap); bind+animate/no-T-pose, silhouettes+atlas materials crisp (hi-vis/suit/ kid), impostor bake clean, identity variety + determinism preserved. GPU memory dropped too.
  • The flip numbers (full density, perChunk 16): worst street view 241 draws ≤300 (was 356 unmerged), 65,899 tris ≤200k, 17 near-rigs; 30-chunk soak (726 builds/disposes) leak-free, 0 GPU delta, heap stable. → GO.
  • Full-density defaults (D2): enableStream default flipped 8→16 (the merge affords it); near-cap stays 24 (now 24 draws not 168).
  • Post-flip + escape hatch (D3): test page + shell now default to stream; ?roster=v1 restores the fixed roster (verified: streamMode false, determinism ✓). No-flag boot → streamMode true, determinism ✓. F's web/index.html flip wiring (invert to default-on + ?roster=v1 escape) documented; the in-shell no-flag-URL boot completes when F lands F1 (roster proven running in-shell at the shipping default via the in-shell soak above).

Only web/js/citizens/sim.js (perChunk default) + web/citizens_test.html (default-on + escape hatch) changed. Committed atomically.


Round 6 (harden streamed roster toward default-on) — DONE. qa GREEN. Flag still default-off.

Hardened ?roster=stream and wrote the default-on readiness memo (top of LANE_D_NOTES.md) so Fable + F can decide the R7 flip on evidence. In-shell (real town), stream runtime-enabled:

  • Soak green: 242 chunk builds/disposes over 3 street-graph walks → 0 GPU geom/tex delta; 16 shop enter/exit → 0 delta; heap oscillates 50→77→54 MB (GC recovers, no leak); 188 chunk-keyed identities re-derive from seed; 0 console errors; flag-off byte-identical (citizen-0 = R4 golden).
  • Hours-aware density (new): added setNightLivelyChunks + a night floor so the open-late video block stays lively at night (31→16) while ordinary streets go near-empty (65→6). F call-site documented.
  • Composition with ?dig=1: roster is street-tier, fully inert while interior/dig is open (frame loop street-branch-only); enter/exit record shops leak-free with stream on. No shared state with any v2 flag → all-on combo safe.
  • Perf/budget — the one gap: each decimated ped = 8 sub-meshes / 2 materials = ~7 draws/near-rig, so full-density stream (perChunk 16) hits 356 draws (over the 300 street budget). Set the default to perChunk 8 (291 draws, ≤300, budget-safe today) and documented the clean unlock — merge ped sub-meshes by material (8→2 → ~2 draws/rig → perChunk 16 fits at ~260) — as the single thing gating full-density default-on in R7 (F1-safe: v1 spawn view has ~0 near-rigs).

Only web/js/citizens/sim.js (night floor + default) + web/citizens_test.html (default perChunk) changed. Committed atomically; flag default-off; v1 path untouched.


Round 5 (v2 foundations) — DONE. Chunk-streamed roster behind ?roster=stream. qa GREEN.

Implemented the R3 design note (was design-only): the chunk-streamed roster, default-off, v1-identical when off. Full numbers + Lane F wiring in docs/LANES/LANE_D_NOTES.md (top).

  • Chunk-local identity keyed (seed, chunkKey, i) → same seed + chunk → same residents regardless of town size / visit order; windowed spawn/despawn hook-driven (Lane B's onChunkBuilt/ onChunkDisposed, sim auto-detects) with a camera-poll fallback; global near-cap across live chunks; owner-chunk despawn (no cross-boundary re-keying); v1 path untouched (chunkStream opt-in).
  • Proven on a big 9×9 fixture grid: (a) constant far-field density — ~58129 citizens within 70m at spawn AND 150m out, vs v1's uniform ~1215 (fixed roster spread thin); (b) deterministic — 507 chunk-keyed identities re-derive from seed; (c) leak-free — 2 full grid walks (~380 chunk cycles) → 0 geom/tex delta, pool capped 30, chunks bounded 25; (d) budget — same near-cap + 1 impostor draw as v1, 2.31 ms/frame; (e) flag-off byte-identical — shell citizen-0 signature exactly matches the R4 golden. Hours-aware density included (per-chunk tod thinning).
  • Only web/js/citizens/sim.js + web/citizens_test.html (bigger fixture + stream toggle/HUD/ determinism) changed. Flag + F call-site documented in LANE_D_NOTES; F wires web/index.html. Evidence: docs/shots/laneD/r5_stream_tiers.jpg.

Round 4 (v1-tag round) — DONE. qa.sh --strict GREEN.

All three Lane-D R4 tasks complete; details + numbers in docs/LANES/LANE_D_NOTES.md (top).

  • D1 — in-shell verify In the real game (web/index.html): peds + keepers upgrade placeholder→rig; 12 enter/exit shops leak-free (renderer.info.memory back to exactly baseline, Δ0); determinism holds across reload; ?noassets=1 fully placeholder, zero ped fetches.
  • D2 — decimated peds (critical path; E1 landed) Validated E's fleet: all 19 peds ≤3k tris (15642805, avg 2450), rigs bind + animate (skinning/skeleton preserved, walk/idle retarget unchanged), 0 broken meshes under eviction churn, silhouettes + identity variety intact, impostor atlas bakes clean, determinism + leak re-pass. Gate-3 sign-off for Lane F: near fleet now ≈59.6k tris at the 24-cap (was ~1.2M); whole test view 88.9k (was ~1.5M).
  • D3 — exposure sync Verifying it found + fixed a real bug: the impostor material self-tone-mapped (toneMapped:false + in-shader ACES), which is correct on my direct-canvas test page but double-tone-mapped under the shell's EffectComposer/OutputPass (three only tone-maps materials on the canvas path). Reworked it to a standard toneMapped:true material that outputs linear and uses three's own tone-map/colorspace chunks → tone-maps identically to the near rigs on both paths. Verified near+mid match at noon and correct night render in-shell (docs/shots/laneD/r4_shell_*.jpg). citizens.setExposure() is now a harmless no-op (exposure is global); Lane F can keep or drop the call at web/index.html:245.

Only web/js/citizens/impostor.js changed this round (+ my NOTES/progress/R4 shots). Committed atomically; web/index.html, dbg.js, and other lanes' in-flight files left untouched.


(Round 1) — initial Lane D build

Status: complete and verified in-browser. 2026-07-14, by PROCITY-D (Opus 4.8). Standalone test page runs, all Lane-D acceptance gates pass. One cross-cutting finding for Lane E (ped poly-count) is flagged below and in docs/LANES/LANE_D_NOTES.md.

Re-verify after session resume (2026-07-14, PROCITY-D)

Prior session hit the token limit; resumed on a fresh account. Confirmed nothing rotted — all 5 citizen modules intact on disk with every adversarial-review fix present. Re-ran the gates live in the browser on this machine:

  • perf @ pop 200: max mixer 0.2 ms, sim CPU 0.148 ms/frame, near capped at 24
  • determinism: ✓ 200 identities match seed 20261990 (live vs two fresh recomputes) ✓
  • no T-pose / one shared walk clip / keeper at counter
  • ?noassets: placeholder town, 0.00 ms mixer, 2.4k tris, crash-free ✓
  • Integration contract PROVEN: built Lane A's real generatePlan(1234) → "North Cressy Reach" (27 nodes / 22 edges) and ran new CitizenSim({ …, graph: plan.streets }) with zero adapter — 0 dangling edges, 0 non-finite citizen coords, no errors. plan.streets is exactly the {nodes, edges} shape the sim consumes. Lane F §3.3 is a one-line construction (see below).

Round 3 — support round (2026-07-14, PROCITY-D) — all 3 tasks done

Per docs/LANES/ROUND3_INSTRUCTIONS.md §Lane D. Committed Lane D landed as 9eb1acc first; then:

  1. Keeper GLB-rig upgrade + leak-free dispose (task 1). Gated on F's §3.4 (keepers are still placeholder-only in the shell — F builds KeeperManager with no fleet). Verified my side of the contract in citizens_test.html: a fleet-backed keeper spawns as a rig, and 15 enter/exit spawn→greet→remove() cycles leave renderer.info.memory exactly at baseline (geo 140→140, tex 175→175) → leak-free. Enablement for F is one arg (fleet); documented in LANE_D_NOTES.
  2. ImpostorLayer.setExposure() wired to day/night (task 2). Added a clean CitizenSim.setExposure(e) passthrough (sim.js) that also survives the placeholder→rig atlas re-bake (stored + re-applied). Confirmed the shell does animate renderer.toneMappingExposure per segment (lighting.js), so this is a real fix, not defensive. Verified: uniform tracks 0.55↔2.3 and persists across the upgrade. Left F the exact one-line call site (index.html:225, after setTimeOfDay) in LANE_D_NOTES — it's an F-owned seam edit, so I documented rather than edited it.
  3. Chunk-streamed roster design note (task 3). Written in LANE_D_NOTES ("Chunk-streamed roster — v1.5 design note"): chunk-local identity keying, poll- vs hook-driven windowing (with the chunks.js reality), global near-cap across live chunks, ownership/hand-off, budget, and an opt-in migration that leaves v1 untouched. Design only, no implementation as instructed.

Post-edit re-verify: no console errors, determinism ✓ (200 match seed), near capped 24, mixer 0.3 ms. Round-3 code touches only sim.js (my file); the rest is docs. No sibling files edited.


What I built (owns web/js/citizens/*, web/citizens_test.html, web/models/*)

file lines-ish what
web/js/citizens/rigs.js ~185 the ported crown-jewel rig stack — fleet loader, _canon/canonRig/_rotOnly, buildFigure (head-bone height-normalise + feet plant), spawnRig, makeActor (walk↔idle crossfade for the near tier), pickRig
web/js/citizens/placeholder.js ~150 seeded low-poly box humanoid (the POLY lock) — walks/idles, so the town is populated from frame one and in ?noassets mode
web/js/citizens/impostor.js ~210 mid-tier LOD: bakes each ped to a 4-yaw sprite atlas on an offscreen RT, one InstancedMesh of camera-facing billboards → 1 draw call for the whole mid crowd
web/js/citizens/sim.js ~330 the simulation — deterministic roster, footpath lanes off the street graph, near/mid/far tiers with hysteresis + hard near-cap, a reusable rig pool, staggered mixer budget, time-of-day density
web/js/citizens/keepers.js ~95 one keeper per shop at the counter, idle clip + greet turn toward the player
web/citizens_test.html ~310 standalone harness (fixture 3×3 street graph, no Lane A/B imports)
web/models/peds/* 21 GLBs, 37 MB 19 rigged peds + walk + idle, byte-identical copies from 90sDJsim (SHA-verified)
docs/LANES/LANE_D_NOTES.md measured budgets + clip wishlist
docs/shots/laneD/*.jpg 5 shots near / mid / main-street / tier-debug / noassets

Everything is plain ES modules, three from web/vendor/, all randomness via core/prng.js (Math.random() used nowhere in identity/placement). Loaders come from core/loaders.js.

Acceptance — every gate met

  • 200-citizen fixture holds 60fps — full frame CPU ~2.6 ms (≈380 fps of headroom on the M3 Ultra; GPU trivial). Preview's throttled rAF shows 4784 fps, but timed frames prove the margin.
  • Mixer update < 2 ms/frame — measured 0.4 ms max at 200 citizens (staggered: nearest 8 every frame, ≤4 more per frame).
  • No T-pose ever — fixed a real bug: freshly-promoted rigs were showing bind-pose for a frame; actors now evaluate their mixer at acquire time. Verified across the fleet in 01_near_tier.jpg.
  • No giants/ants — head-bone height normalisation verified across all 19 peds (1.551.95 m).
  • One shared walk clip animates every ped — the mixorig canonicalisation binds a single walk.glb to all 19; confirmed in-scene.
  • Same seed → same crowd — determinism button asserts the live roster equals a fresh seed recompute; holds while citizens walk (identity ≠ live position).
  • Screenshots into docs/shots/laneD/ (near, mid, busy main street, + tier-debug & noassets).
  • docs/LANES/LANE_D_NOTES.md — measured budgets + the mixamo-fetch clip wishlist.

Plus the LOD design points from the lane brief: near<25m rig+mixer walking footpath lanes & turning at nodes on a seeded choice with loiter stops; mid 2570m instanced impostors baked from the fleet (auto-in-sync); far culled; hysteresis so nobody pops; time-of-day density; ?noassets placeholder town; keepers at counters. All exercised in the test page.

Decisions worth your eyes

  1. Rig pool, not per-spawn clone. Near actors are pooled per ped-type and reused across citizens (SkeletonUtils.clone + mixer are the expensive bits) — bounded at 30 instances, cap 24 active, ≤3 new clones/frame to avoid hitches. Height is applied as an outer-group scale so pooled rigs are height-agnostic and reusable.
  2. Impostor colour management. The mid atlas is baked linear (no tone-map) and the billboard shader applies ACES + sRGB itself, so mid impostors match the ACES-tonemapped near rigs exactly — fixed an early bug where they rendered ~2× too dark.
  3. Figure origin at the feet. Both rigs and placeholders present a fig Group whose origin is the ground point, so the sim moves/rotates walkers without re-planting them each frame.

⚠️ One thing I need a ruling on (Lane E territory)

The inherited 90sDJsim peds are ~49.7k tris each. 24 near rigs ≈ 1.2M tris — far past the CITY_SPEC "≤200k tris typical view". It runs fine on Apple Silicon today, but it leaves no tri budget for Lane B's buildings. The rig stack is indifferent to poly count — this is an asset-pipeline decimation job (Lane E): at ~6k tris/ped the near cap fits budget. Until then NEAR_MAX in sim.js is the throttle. Same story, smaller, for draw calls (each ped is ~6 sub-meshes → merge on load). Details + numbers in the NOTES. Not blocking Lane D; flagging so it's on the integration radar.

Adversarial review pass (6 real bugs found + fixed)

After the build passed in-browser, I ran a 4-dimension multi-agent code review with an adversarial verify stage (14 agents). It confirmed 6 genuine defects (4 others refuted); all 6 are fixed and re-verified:

  1. [HIGH] Non-deterministic fleet orderfleet.normal/all were filled in GLB load-completion order, so pickRig's index (→ every identity) could differ across reloads/machines, silently breaking "same seed → same crowd". Fixed: fill fixed slots by PED_NAMES index, compact after.
  2. [HIGH] Shared-resource disposal_disposeInner disposed geometry/materials that SkeletonUtils.clone shares across all clones + the base rig; pool eviction would tear down GPU buffers still in use by sibling citizens. Fixed: dispose only the clone's own Skeleton (its bone texture) — never the shared geo/mats. Stress-tested (24 teleports forcing eviction churn) → 0 broken meshes.
  3. [HIGH] Impostor under-exposure — the in-shader ACES omitted three's /0.6 exposure normalisation, making mid billboards ~1.67× darker than the identical near rig at the swap boundary. Fixed: color *= uExposure / 0.6.
  4. [HIGH] Renderer viewport not restored — the atlas bake set per-cell viewport/scissor but only restored scissor-test; the async re-bake left the viewport on a 128px cell → the main scene could render into a corner. Fixed: save/restore viewport + scissor rect.
  5. [MED] Billboard ignored modelMatrix — impostors would desync from rigs if the citizens group carries a transform (Lane B chunk offsets). Fixed: fold modelMatrix into the billboard base.
  6. [LOW] Frozen tone-map exposure — added ImpostorLayer.setExposure() for day/night.

Also caught during re-verification: three injects its own RRTAndODTFit/ACES functions into any tone-mapped ShaderMaterial, colliding with mine. Fixed with toneMapped:false (I tone-map in-shader) + imp-prefixed helper names. Console is clean; determinism still passes; 60fps holds.

For Lane F integration (hooks are ready)

  • Street graph — verified, no adapter. plan.streets (Lane A) is byte-shape-compatible with the graph the sim wants. In the street branch of the shell:
    const sim = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets, fleet });
    // per frame (street mode): sim.update(dt);   // LOD is by sim.camera.position — pass the render camera
    
    Note update(dt) takes only dt (it reads this.camera.position for LOD, which is what you want — LOD by what the viewer sees). Lane F §3.3's sim.update(dt, player.position) sig is loose; the 1-arg form is correct as long as the constructed camera is the render camera.
  • Density on big towns: a flat setPopulation(N) spreads citizens uniformly across all edges, so on a large plan most sit far-culled from any one viewpoint (correct + cheap, but streets read empty if N is small). For v1 pick N to suit town size; the durable fix is chunk-streamed rosters (below).
  • Roster identity identityOf(citySeed, edgeCount, id) is chunk-ready — key by chunkKey+i and stream rosters with Lane B's chunk build/dispose.
  • Keepers pull x,z,ry from Lane C's counter places; sim.setTimeOfDay from the shell day segment; sim.setPaused from visibilitychange; Lane B must set scene.environment (PBR peds go dark without it — already wired to be read from the scene).

Try it

cd web && python3 -m http.server 8130
# open http://localhost:8130/citizens_test.html          (full fleet)
# open http://localhost:8130/citizens_test.html?noassets=1  (placeholder town)
# open http://localhost:8130/citizens_test.html?seed=1234   (any seed)

Sliders: population 0200, time-of-day. Buttons: tier colours (green=near, yellow=mid, pink=far), determinism check. Drag=look, wheel=zoom, WASD=move focus.