E: STOP — do not build the _lo variants. That work is unnecessary; the item is dead.
The oldest tri item on the books, killed under the wind-sway rule attached to the brief
("if the measured win is <20k tris in practice, KILL it with the measurement").
MEASURED, from the shipped depot GLBs: each of the five instruments is exactly 14,000
tris; band + amp on stage = 70,000. On paper a 3-4k _lo each "saves" ~52k, clearing the
20k bar easily.
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):
pub 9.7 x 9.7m, diagonal 13.7m, 12522 tris, 38 draws
band_room 8.9 x 9.6m, diagonal 13.0m, 11699 tris, 35 draws
rsl 12 x 9.4m, diagonal 15.2m, 15115 tris, 38 draws (the biggest)
VERDICT: practical win = 0 tris. A player can NEVER be more than 15.2m from the band —
corner-to-corner in the largest venue, and the stage sits at one end so the real max is
shorter. An LOD is a distance trade; inside a <=15.2m room the band is never small — it
is the subject of the gig and of F's tour money shot. Any sane threshold (15-25m) never
fires; an aggressive 8m one fires in half a room at a distance where a 3-4k _lo is
visibly worse with no perceptual cover. The win isn't "small", it's STRUCTURALLY ZERO —
the distance an LOD needs does not exist in this game. Off the backlog forever.
No budget motivates it either: a venue interior is ~12-15k tris, ~85k with the band —
inside the 200k stress ceiling E's R22 tri diet restored, at 35-38 draws vs <=350.
SIDE-OBSERVATION for E (not a proposal, no breach motivates it): all five instruments
are EXACTLY 14,000 tris — a decimator target, not a measurement of the objects. A mic
stand does not need 14k. If instrument tris ever do drive a real breach, the lever is a
flat re-decimation by object complexity (mic stand -> ~1k), which beats any LOD because
it needs no distance. Filed so the next tri hunt starts from it.
Spike 1 (ledger #1) pending E's lean.glb — look.glb has landed, lean.glb not yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1299 lines
90 KiB
Markdown
1299 lines
90 KiB
Markdown
# 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 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 → `_gigVenues` is empty → the neighbour loop is skipped entirely.** `?classic` and every
|
||
flags-off boot take the *identical code path and identical cost* to v2. **Byte-identical by construction**,
|
||
not by measurement luck.
|
||
|
||
Both ranges are < `CHUNK` (64), so a 3×3 sweep is *exact*, not an approximation; a fixed `dz/dx` order keeps
|
||
exact-distance ties deterministic, as the old single-list scan was.
|
||
|
||
### Before / after
|
||
| proof | before | after |
|
||
|---|---|---|
|
||
| **cross-chunk in-range peds that see the gig** — katoomba | **0 % by construction**¹ | **4/4 = 100 %** |
|
||
| — fitzroy (densest, worst case) | **0 % by construction**¹ | **11/11 = 100 %** |
|
||
| same-chunk peds (must not change) | 100 % | 100 % |
|
||
| **classic / flags-off: finds sourced from a neighbour** | 0 (v2) | **0** (127 under the rejected full scan) |
|
||
| determinism (2 sims, same seed) | identical | **identical** (identity sets byte-equal) |
|
||
| NaN — katoomba / fitzroy / synthetic | 0 | **0** |
|
||
| sim cost, katoomba @14:00, 236 peds | 0.056 ms/frame | **0.057 ms/frame (+2 %)** |
|
||
| lookup cost/call — no gig on | 0.58 µs | **0.58 µs (loop skipped)** |
|
||
| lookup cost/call — gig on | 0.58 µs | **1.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=1` takes
|
||
the identical code path to v2 (loop skipped), so the classic regression is untouched **by construction**.
|
||
Selfcheck green at 161300. No D-side blocker for `v5.0-alpha`.
|
||
- **Carried (deliberate, documented):** `PATRON_RANGE` keeps v2's own chunk-blindness — an ordinary shop 10 m
|
||
away across a chunk edge still goes unnoticed. That is now a *frozen v2 quirk*, not an oversight: fixing it
|
||
costs the classic covenant, and the covenant wins. Revisit only if the covenant is ever relaxed.
|
||
- **Still unowned (unchanged):** R20's main-street relocation half (heroes 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_RANGE` 34 m radius vs the single-chunk `_nearestOpenShop` lookup
|
||
(8.7 % of in-range peds chunk-blind; fix = chunk + 8 neighbours).
|
||
- **Still unowned (last flag before the retro):** R20's *main-street relocation* half — heroes are a
|
||
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:** `selfcheck` scans **every** `web/assets/towns/*.json` on disk, including E's *untracked* pack WIP.
|
||
I saw it transiently RED mid-round (`newcastle_real`: no open-late shop) purely from E's in-flight files, then
|
||
green again minutes later at 154141 checks. If you gate while E is mid-fetch you may catch a false red —
|
||
E's pack is non-gating, so check whether a failure is a pack town before believing it.
|
||
- **Still filed for v4.x (agreed, not a mid-beta change):** `GIG_RANGE` is a 34 m radius but `_nearestOpenShop`
|
||
searches only the ped's own 64 m chunk → 8.7 % of in-range peds are chunk-blind. Fix = chunk + 8 neighbours.
|
||
- **Unowned from R20:** the *main-street* relocation half (heroes 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.
|
||
- **`tonightRoster` populates 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:**
|
||
1. **It was measured at gig hours.** At 21:00 only **4 of 69** katoomba shops are open — retail is shut *by
|
||
design*. A high null rate at 21:00 is **correct night behaviour**, not sparsity. The gig IS the nightlife.
|
||
2. **It was sampled at each town's first venue** (`shops.find(s => s.venue)`). For katoomba that is the
|
||
**pub, which sits 1,000 m from the retail cluster with 1 neighbour** — I had parked the camera on the one
|
||
isolated venue in town. Measured there, the daytime null was 99.4 % with **0 visits**; measured on the
|
||
actual main street, the same town same seed gives **77.2 % and 64 visits**. The number was an artefact of
|
||
where I stood.
|
||
|
||
The gig-crowd table above still **stands** — it measures the *mechanism* (0 NaN, rosters populate,
|
||
venue-win %), which is location-independent. Only the sparsity interpretation was bad.
|
||
|
||
### The real density A/B — the main street, retail hours (14:00), same seed, like-for-like
|
||
Camera on the **densest retail cluster** (the actual main street), pre-widening cache (from git) vs E's
|
||
widened cache, 2,000 frames each:
|
||
|
||
| main street @14:00 | PRE (20 shops) | **POST (80 shops)** |
|
||
|---|---|---|
|
||
| hub density (shops within 160 m) | 8 | **25** |
|
||
| null % | 90.2 | **77.2** |
|
||
| **total visits** | 17 | **64** (3.8×) |
|
||
| **hero visits** (opshop/book/record/video/pawn/toy) | 17 | **19** |
|
||
| texture visits (milkbar/dept/stall) | 0 | 45 |
|
||
| hero share of visits | 100 % | **30 %** |
|
||
| hero share of shops | 84 % | 26 % |
|
||
| NaN | 0 | 0 |
|
||
|
||
**The widening lands John's directive, measured from the crowd side.** The secondhand heroes did **not**
|
||
lose footfall — they went **17 → 19 visits**. The 45 texture visits are added *on top*, not taken from them.
|
||
The street is **3.8× busier** while the heroes stay **over-indexed** (26 % of shops, 30 % of visits). That is
|
||
precisely "the player should notice the town feels more alive, not that the game changed."
|
||
|
||
### Filed, not fixed
|
||
- **Venue placement at density (A/F):** katoomba's 3 venues — rsl **13** shops within 160 m, band_room **10**,
|
||
**pub 1** (1,000 m from the cluster). A gig at the pub has almost no passing retail crowd. It still draws a
|
||
roster (peds stream everywhere), so it's a *quality* observation, not a break. Placement is A's/F's lane.
|
||
- **`GIG_RANGE` vs the chunk lookup (mine):** GIG_RANGE is a 34 m *radius*, but `_nearestOpenShop` searches
|
||
only the ped's own 64 m chunk, so an in-range ped across a chunk edge is blind to the gig — **8.7 %** of
|
||
in-range ped-samples (852/9,740). Real but small; a hot-path change mid-beta isn't worth it. Fix (chunk +
|
||
8 neighbours) is a v4.x candidate.
|
||
|
||
### FINAL — post A's absorb (`5d29863`), all five towns at density
|
||
A's absorb landed (capacity widened, goldens pinned, `selfcheck` **51866/51866**). Re-ran everything against
|
||
the absorbed graph. **The katoomba numbers barely moved** (null 77.2→78.1, visits 64→62, hero share 30→29 %)
|
||
— the pre-absorb read was already sound.
|
||
|
||
| town | cache→seated | hub | null % | visits | hero | texture | hero share | NaN | maxActive | rosters | sigs |
|
||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||
| katoomba_real | 80→72 (8) | 26 | 78.1 | 62 | 18 | 44 | 29 % | **0** | 217 | 15 | 262 |
|
||
| fremantle_real | 80→79 (1) | 29 | 71.1 | 116 | 23 | 93 | 20 % | **0** | 401 | 24 | 483 |
|
||
| newtown_real | 72→67 (5) | 12 | 85.9 | 44 | 6 | 38 | 14 % | **0** | 268 | 19 | 324 |
|
||
| bendigo_real | 36→35 (1) | 14 | 87.2 | 45 | 1 | 44 | 2 % | **0** | 358 | 21 | 432 |
|
||
| castlemaine_real | 24→23 (1) | 6 | 94.9 | 9 | 0 | 9 | 0 % | **0** | 295 | 20 | 359 |
|
||
|
||
**Sim verdict: zero changes needed at 4× density.** 0 NaN on all five, chunk rosters stream (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 `buildRealRoads` runs 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's `selfcheck` fixture (LANE_B_NOTES §18). On *real* Katoomba this
|
||
bites only the **9 of 85** tiny fragments that sit <10 m from the main network (20 within 30 m) — the
|
||
ones a snap-tolerance / endpoint-split pass could legitimately join. A **fidelity knob** (charter risk #4).
|
||
- **Reconciles with Lane B:** B verified real Katoomba's **126 intersections form correctly** (= my 27
|
||
deg-4 + 99 deg-3 junction nodes) and concluded connectivity is fine — true *for the main component*. My
|
||
add is the **global** measurement B didn't take: those 126 junctions live in a 480-node main component,
|
||
but the full graph is still 105 components with 42 % of street-metres on islands. Both true, compatible.
|
||
- **The sim degrades gracefully** — fragment peds just walk their island; **zero NaN, no crash**. This is
|
||
A's graph-construction call (connect-tolerance / mid-edge split), not a sim change. A D-side mitigation
|
||
(bias spawns toward the main component) is a **beta** item and needs A's connectivity decision first.
|
||
|
||
### Handshakes
|
||
- **D → A:** the fragmentation measurement above (105 components, 42% street-metres + 2 shops stranded,
|
||
root cause + the 9 close-fragments that could be joined). Your knob, your call.
|
||
- **D → F (gate):** the sim is clean on the real graph — no D-side gate concern. Bench-sit + loiter +
|
||
patronage + determinism all hold on `katoomba_real`. When you re-run the town matrix with the roads
|
||
dimension, the citizen path is green from my side.
|
||
- **Re-run note:** the whole audit is parameterized on `plan.streets`; it re-runs on any town's real
|
||
graph in one call (used it on both the crafted town and real Katoomba this round).
|
||
|
||
---
|
||
|
||
## ROUND 17 — v3.2 THE TAIL: the drummer's pelvic-lean (ledger #1)
|
||
|
||
*PROCITY-D, 2026-07-16. The R16 honest limit — the upright bent-leg sit read stiff — is fixed. Files
|
||
`rigs.js`, `band.js`. Byte-identical proof holds (my signature discipline).*
|
||
|
||
The sit clip can't carry a pelvic tilt (`Hips.quaternion` is unusable on these rigs — R16), so the R16
|
||
drummer sat bolt-upright. **Fix: a small constant forward torso lean applied POST-MIX.** `rigs.js`
|
||
`spawnRig({seated})` now caches the mid-spine bone (`seatBone`), and `seatedLean(spine)` rotates it forward
|
||
~0.34 rad each frame AFTER the mixer resets it (so it's a constant offset, never an accumulation). `band.js`
|
||
calls it for `m.seated` members only. The drummer now leans into the kit — verified **head 0.28 m forward
|
||
of the hips**, reads as a real drummer from the side (the R16 stiffness is gone).
|
||
|
||
**Byte-identical proof**: standing rigs get **no `seatBone`** (found only in `spawnRig`'s seated block) and
|
||
`seatedLean` is only called for seated figs. Verified with the lean live: streamed walkers **188/188** face
|
||
travel, keeper is a standing rig (stature **1.718**, `hasSeatBone:false`, `innerRotY:π`). No existing
|
||
consumer moves. Seated-only, parallel path — same discipline as R16.
|
||
|
||
---
|
||
|
||
## ROUND 17 — v3.2 THE TAIL: bench-sit loiter (ledger #2, the carried R16 stretch)
|
||
|
||
*PROCITY-D, 2026-07-16. The loiter hook that's waited since v1: street peds occasionally sit during a
|
||
window-shop stop. Files `rigs.js` (`makeActor` gains an opt-in `setSitting`), `sim.js` (the seeded
|
||
choice + the near-tier toggle). Byte-identical for every non-sitting ped — the load-bearing proof.*
|
||
|
||
### The finding I had to solve first
|
||
The R16 seated re-plant lives in `spawnRig` (drummer path) and is **scale-naive** — it drops `inner` by the
|
||
posed lowest-bone height, which only lands at y=0 for **unscaled** rigs. But the street crowd runs on
|
||
`makeActor` rigs that the sim scales **per-citizen** (`fig.scale = height/1.75`). So bench-sit is *not* the
|
||
one-line "reuse the existing seam" the brief hoped for — the re-plant had to become **scale-aware**.
|
||
|
||
### What shipped
|
||
- **`makeActor(rig, {…, sitClip})`** gains a third action `sitA`, **played only on `setSitting(true)`** — so
|
||
walkers, placeholders, and `?classic` (no `sitClip` → `sitA` null → `setSitting` no-ops) are byte-identical:
|
||
the sit action never advances the mixer and the plant never moves.
|
||
- **`setSitting(s)`** snaps in/out of the sit (no crossfade — a background ped popping to a sit is fine) and
|
||
does the **scale-aware re-plant**: after baking the pose it measures the posed lowest bone's *world* y and
|
||
drops `inner` by `(worldLowestY − figY) / fig.scale.y`, landing the posed soles on the footpath at any
|
||
citizen scale. Standing up restores the cached `bindPlantY` exactly. Tags `procitySeated`.
|
||
- **`sim.js`**: a **dedicated** rng stream `sitRng = rng(citySeed,'benchsit',id)` (independently keyed → does
|
||
**not** shift `turn`/`loit`/`patron`, so the walk/loiter pattern and the identity signature are unchanged).
|
||
At the window-shop loiter trigger the roll fires ALWAYS (deterministic) but only flips `c.sit` when a sit
|
||
clip is loaded (`?classic` inert at the source). The near-tier update toggles `setSitting(c.sit && loiter>0)`.
|
||
`RigPool.release` forces `setSitting(false)` so a pooled rig is never handed out mid-sit.
|
||
- No lean: bench-sitters sit **upright** (natural on a verandah step), so the drummer's `seatedLean` is not
|
||
applied here — only `setSitting`'s pose + re-plant. No bench-position binding — they sit where they loitered
|
||
(the footpath/verandah edge under the awnings).
|
||
|
||
### Byte-identical proof (in-shell, default boot, seed 20261990)
|
||
Forced a near rig through stand → sit → stand, bone-measured (posed skeleton; `Box3` is bind-pose for skinned
|
||
meshes and lies here):
|
||
|
||
| metric | standing | seated | +1 s sit-idle | stood back |
|
||
|---|---|---|---|---|
|
||
| head bone y | 1.466 | **1.070** | 1.071 | 1.466 |
|
||
| bone span | 1.667 | **1.299** (∈ seated gate [0.9,1.5]) | 1.285 | 1.667 |
|
||
| lowest bone y (feet) | 0.027 | **0.000** (planted) | 0.005 (holds) | 0.027 |
|
||
| `inner.position.y` | 0.929 | 0.548 | 0.548 | **0.929** (restored) |
|
||
| `procitySeated` | false | **true** | true | false |
|
||
|
||
Head drops 40 cm, feet re-plant to the footpath at citizen scale (0.978), the one-time plant **holds** through
|
||
the sit-idle, and standing up restores the rig **byte-for-byte**. `innerRotY:π` throughout (facing convention
|
||
intact). Draws **35 · tris 6k** at the seated block (seated peds are already-near rigs — ~0 draw delta).
|
||
|
||
- **Seeded path fires**: 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` — no `sit` term; plan golden
|
||
`0x3fa36874`, 15277/15277.
|
||
- **Classic covenant airtight at the source**: `?classic=1` → `fleetSitClip:false`, 31 loiters over the soak
|
||
but `everSat:0` (the gate never flips `c.sit`), zero fetch delta, `anySeatedNow:0`.
|
||
|
||
Both R17 ledger items (#1 pelvic-lean, #2 bench-sit) are delivered — the tail is drained.
|
||
|
||
---
|
||
|
||
## ROUND 16 — v3.1 THE FLIP: the drummer finally sits + backline unification
|
||
|
||
*PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's `backline[]`). Files `rigs.js`,
|
||
`band.js`. Verified against C's landed `stage.backline[]` + E's `sit.glb`; F's flip/gate pending (I built to
|
||
F's published seated-stature contract). **Byte-identical proof holds** (the load-bearing requirement).*
|
||
|
||
### The drummer sits (ledger #2) — and Fable's prescribed `_rotWithHips` is WRONG for these rigs
|
||
The R14 recon + F's brief prescribed a `_rotWithHips` variant (keep `Hips.quaternion` to preserve the
|
||
pelvic tilt). **In-shell it MANGLES the pose** — head folds to hip height, body laid flat — because the
|
||
fleet rigs' bind-pose Hips orientation differs from the Mixamo clip's, exactly the failure `_rotOnly`'s own
|
||
comment documents ("a different-orientation source lays it flat"). So the sit clip **drops `Hips.quaternion`
|
||
like every other clip** (rides the existing `_rotOnly`); the seated read comes from the leg-bone bends +
|
||
a new **seated foot-replant** in `spawnRig`. `_rotWithHips` is removed.
|
||
- **`spawnRig({seated})`** (OPT-IN): after the clip plays, tick the mixer once and re-plant the POSED
|
||
lowest bone to y=0 so the drummer's feet sit on the deck (recovers the hip descent). Standing spawns
|
||
skip it entirely.
|
||
- **The drummer**: uses `fleet.sitClip` when present (retiring the R14 `SEAT_DROP` sink — it sits on the
|
||
riser and the re-plant seats it), tags `fig.userData.procitySeated = true` (F's gate). Reads seated from
|
||
the **crowd POV** (the kit hides the lower body); measured stature **~1.1–1.3 m** (F's seated gate
|
||
[0.9,1.5] ✓). Falls back to the `SEAT_DROP` standing-sunk hack if `sitClip` is absent (?noassets, or
|
||
before F wires `{sit}`), so it never breaks a boot.
|
||
- **Honest limit**: without the pelvic tilt (Hips.quaternion unusable), it's an upright bent-leg sit — a
|
||
bit stiff from a hard side/overhead angle, hidden by the kit from the front. A cleaner pose is a manual
|
||
pelvic-lean pass (v3.2). It's a genuine improvement over the standing-sunk hack (real bent legs, feet on
|
||
the deck, passes the seated gate) — good enough for the flip.
|
||
|
||
### Byte-identical proof (keepers / browsers / walkers) — the load-bearing part
|
||
The changes are a PARALLEL/opt-in path: `_rotOnly` untouched (walk/idle unchanged), `spawnRig`'s re-plant
|
||
is `seated`-gated (default off), `fleet.sitClip` is additive, `makeActor` (walkers) untouched. Verified
|
||
in-shell with `fleet.sitClip` **null AND loaded**: streamed walkers **188/188** face travel, keeper is a
|
||
standing rig (stature **1.718**, `innerRotY:π`, `replanted:false` — the seated re-plant does NOT run). No
|
||
existing consumer moves.
|
||
|
||
### → Lane F: the `{sit}` load seam (classic-safe by construction)
|
||
`loadPedFleet(base, { sit })` — pass **`sit: <gig-layer active>`** (true on the default boot, **false under
|
||
`?classic=1`**). Gated because a raw `sit.glb` fetch would break the classic zero-fetch-delta covenant;
|
||
default is `sit:false`, and absent/off → `fleet.sitClip` null → the drummer falls back gracefully. My code
|
||
is committed graceful, so it lands safely before F's commit; the drummer only sits once F passes `sit:true`
|
||
(same commit as F's [0.9,1.5] seated-stature gate).
|
||
|
||
### Amp at C's `stage.backline[]` (ledger #5)
|
||
`_addBackline` now plants at **C's `stage.backline[0]`** (`{x,z,ry,y}`, up-stage, clear of every bandPose
|
||
+ watchPoint) instead of D's R15 hardcoded pose — C confirmed `backline[0]` == that pose, so nothing moves
|
||
on screen (pub 1.44, 0.32, −3.27, ry=π; **0.75 m bass clearance**). Since C dropped its primitive
|
||
ampStacks, D now owns the whole backline: **placeholder-persists** (a primitive amp shows immediately, the
|
||
`guitar_amp` GLB swaps in), and the primitive is the **`?noassets` fallback** (fleet-gated, no fetch).
|
||
Verified: GLB swaps in, primitive under `?noassets`, no crash, 0 fetch.
|
||
|
||
---
|
||
|
||
## ROUND 15 — v3.0 close: the amp one-liner (ledger #4)
|
||
|
||
*PROCITY-D, 2026-07-16. Sole task: move `band.js _addBackline` to C's §3 pose. Everything else (continuity,
|
||
ratified sit-clip deferral) is closed.* The R13 amp sat at `z = stage.z + 0.15` (down-stage) — 0.29 m from
|
||
the bass rig, interpenetrating the bassist (C's audit caught it). Moved up-stage-right to C's blessed pose
|
||
**`(stage.x + w·0.36, deckY, stage.z − d·0.22)`** = (1.44, 0.32, −3.27) at the pub: **0.75 m clear** of the
|
||
bass, behind the front line, flanking C's primitive ampStacks. Verified: draw/tri delta **0** (same GLB
|
||
moved — pub interior 50 draws · ~183k tris), `?noassets` unaffected (amp is fleet-gated dressing → `props 0`,
|
||
band placeholders, no crash).
|
||
|
||
---
|
||
|
||
## ROUND 14 — v3.0 release: identity continuity (the ped you followed is in the crowd)
|
||
|
||
*PROCITY-D, 2026-07-16. The last charter promise. Behind `?gigs=1`; files `sim.js`, `band.js`, `queue.js`.
|
||
**No golden moves** — both goldens are plan-gen hashes (independent of runtime crowd identity), and an
|
||
empty roster is byte-identical to R13. Verified in-shell against A's 3-venue plan.*
|
||
|
||
### The tonight roster (sim.js) — who came to the gig
|
||
A per-venue **Map<key,{pedIndex,height}>** (`_venueRoster`) of the identities who entered a gig venue
|
||
**tonight**, from two sources:
|
||
- **Surge occupants** — a streamed ped that patronage steers into a gig venue self-records in `_enter`
|
||
(gated `this._gigVenues.has(shopId)`), keyed by the ped's stable `c.id` so re-entry doesn't dup. Unlike
|
||
R9 occupancy (which clears on emerge), the roster **persists past the ped's dwell** — "who came tonight".
|
||
- **Queue admits** — F relays each `VenueQueue.admitOne()` via `citizens.recordVenueEntry(venueShopId, {pedIndex,height,key})`.
|
||
|
||
It's **bounded + per-night**: cleared automatically on the `setGig(id, false)` on→off transition (the gig
|
||
ends → the crowd disperses), so it survives interior exit/re-enter (gig stays on) but resets each night.
|
||
Pure bookkeeping — **no rng draw, no GPU** (draw/tri delta 0).
|
||
|
||
### The crowd consumes it (band.js) — the front slots become the roster
|
||
`GigCrew.spawn(roomGroup, { …, roster })`: crowd slot `i` fills from `roster[i]` (its `{pedIndex,height}`),
|
||
seeded strangers fill the remainder. This is the release-round's load-bearing safety: each crowd slot is
|
||
an **independently-keyed rng stream** (`gig`/`crowd:<gid>:<slot>`), and `_make`'s existing override branches
|
||
already skip a slot's own height+pickRig draws when identity is supplied — so a roster override touches
|
||
**only that slot**, never another slot / the sway-phase stream / the band / either golden. An **empty
|
||
roster is byte-identical to R13** (verified). Roster heights are the sim's 1.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)
|
||
```js
|
||
// 1. interior — pass the venue's roster into the crowd (interior_mode.js:239):
|
||
crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints,
|
||
gig: tonight, roster: citizens.tonightRoster(shop.id) });
|
||
// 2. street — relay each queue admit into the roster (index.html per-venue loop ~380-393):
|
||
q.spawn(scene, { queueZone, gigId, onAdmit: (id) => citizens.recordVenueEntry(venueShopId, id) });
|
||
// 3. nothing — the roster auto-clears on your existing setGig(id, false) at the gig's end.
|
||
```
|
||
Accessor for your smoke: `window.PROCITY.citizens.tonightRoster(venueShopId) → [{pedIndex, height}]`
|
||
(insertion-ordered, deduped, bounded). Assert **crowd ⊇ roster ∩ cap** via `crew.crewInfo.fromRoster` +
|
||
`member.pedIndex ∈ roster`. `admitOne()` now returns `{pedIndex,height,key}` (was `1`) — truthy, so your
|
||
existing `if (q.admitOne())` still holds. Also: `crew.spawn` is now null-`gig`-safe (quiet-night guard).
|
||
|
||
### Verified in-shell (seed 20261990, The Exchange Hotel #116)
|
||
| check | result |
|
||
|---|---|
|
||
| roster API — record / dedup-by-key / invalid-pedIndex-ignore / per-venue / clear-on-gig-off | all ✓ |
|
||
| **surge continuity** — real peds ducked in → roster → crowd | **8/8** (same pedIndex + height) |
|
||
| **queue continuity** — admits relayed via `onAdmit` → roster | **4/4** |
|
||
| consume — front slots = roster, tail = seeded strangers unchanged | ✓ |
|
||
| **determinism** — empty-roster crowd == R13 baseline (pedIndex + stature per slot) | byte-identical ✓ |
|
||
| **soak** — 7 nights × 3 venues, enter/exit + re-spawn | leak **geo Δ0 / tex Δ0**, roster **bounded** (≤ cap, cleared per night), **no identity churn** |
|
||
| budget | interior gig **64 draws ≤350**, **0 new rigs** (continuity is bookkeeping) |
|
||
| `?noassets=1&gigs=1` | crowd = placeholders using roster heights, **no crash, 0 fetch** |
|
||
|
||
### Debt #2 (the drummer sit clip) — INVESTIGATED, deferred to v3.1 (real blockers, not a punt)
|
||
The clip **does exist** — `~/Documents/mixamo-fetch/out/` is NOT on m3ultra (Fable's path is stale; the
|
||
bank lives on the tailnet source box `johnking@100.91.239.7`). `Sitting_Idle.fbx` there converts cleanly
|
||
via Blender to a 394 KB **mesh-free `sit.glb`** that binds (65/65 bones; the fleet's `_canon` keeps the
|
||
colon, `mixamorig:Hips`, so raw Mixamo naming is already canonical). **But wiring it is a v3.1 change, not
|
||
a release-week one, for two real reasons:**
|
||
1. **`_rotOnly` breaks a seated pose** — it drops `Hips.quaternion` (pelvic tilt) + all positions (hip
|
||
descent), giving a "levitating L-sit". Correct wiring needs a parallel `_rotWithHips` filter + a
|
||
post-pose foot re-plant in `spawnRig` — a change to the **shared** rig stack (keepers/browsers/band/crowd).
|
||
2. **A genuinely seated figure is ~1.2 m crown-above-feet**, which **fails F's no-giants stature floor
|
||
[1.4,2.0]** (`flags_check.py:695`) — it needs an F smoke exemption for the seated band member.
|
||
Both land a qa-red change pending an F handshake, in the freeze round, for a polish item — the drummer
|
||
already reads seated from the crowd POV (money shot) via the `SEAT_DROP` hack. **Rides Fable's existing
|
||
v3.1 item "bench-sit loiter from the same `sit` clip".** Recipe (for v3.1): scp Sitting_Idle.fbx → Blender
|
||
`--background --python` import/export GLB (no bone rename needed) → `web/models/peds/sit.glb` → `_rotWithHips`
|
||
loader + `spawnRig` re-plant + F stature exemption. See LANE_D_NOTES wishlist #1.
|
||
|
||
---
|
||
|
||
## ROUND 13 — v3.0-beta the district: the RY fix, instruments/drummer, the queue, multi-venue surge
|
||
|
||
*PROCITY-D, 2026-07-15. Behind `?gigs=1`; verified in-shell against A's landed 3-venue plan (pub #116
|
||
`pubrock` / rsl #404 `covers` / band_room #490 `grunge`, 14 gigs / 14 posters) and C's landed 4-pose
|
||
stage (guitar/vocal/bass + a seated `drums` on the riser). Files: `rigs.js`, `band.js`, new `queue.js`,
|
||
`sim.js`. `keepers.js` / `placeholder.js` / `impostor.js` UNCHANGED — the RY fix at the source corrects
|
||
them for free.*
|
||
|
||
### Debt #4 — the RY audit: fixed once at the source (⚠ this changes the flags-off street VISUAL)
|
||
**Finding (confirmed in-scene):** the imported ped GLBs' visual front is local **+Z**, but *everything
|
||
downstream assumes −Z* — `sim.js` heading math (`facing = atan2(-dx,-dz)`), C's pose `ry`
|
||
(counter.stand / bandPoses / watchPoints / browse points), the placeholder (`placeholder.js:61` builds
|
||
toes to −Z *on purpose*, "like the GLB rigs"), and the impostor bake/pick convention. So the GLB was the
|
||
lone outlier: **keepers faced the back wall, browsers faced away, and every streamed rig walker moon-walked
|
||
(faced anti-travel)** — all subtly, for ~12 rounds, because near+mid were consistently-wrong-together
|
||
(seamless) and peds are mostly seen from behind. R12's band carried a local `RY_FLIP=π` that patched only
|
||
the gig.
|
||
|
||
**Fix:** one line in `rigs.js buildFigure` — `inner.rotation.y = Math.PI` — normalises every rig's front
|
||
to −Z, matching the rest of the system. Then `RY_FLIP` is deleted from `band.js` (band/crowd use C's `ry`
|
||
verbatim). Because the impostor atlas bakes this same `buildFigure`/`spawnRig` output and the layer's
|
||
yaw-pick already uses the −Z convention, near↔mid stays seamless (both flip together). It's **purely
|
||
visual** — a Y-rotation leaves every bone's world-Y untouched, so head-height / feet-plant, the no-giants
|
||
gate, and all seeded identity/determinism are byte-unchanged (**no golden move**).
|
||
|
||
**Verified:** keeper now faces the shop floor (was the back wall); **223/223** gig-mode + **188/188**
|
||
flags-off streamed walkers face their edge-travel direction (avg dot **1.000**; was −1.0); band faces the
|
||
crowd + crowd faces the stage with `RY_FLIP` gone; `?noassets` placeholder band (which the old flip left
|
||
facing the backdrop) is now correct too.
|
||
|
||
- **→ Lane C (pose contract, please land in LANE_C_PUB.md):** *"Rig visual front is canonical **local −Z**
|
||
(`rigs.js buildFigure` normalises the GLB's native +Z). All pose `ry` (counter.stand, bandPoses,
|
||
watchPoints, browse points) are the −Z-front heading the rig should face — no lane compensates; the R12
|
||
`RY_FLIP` is gone."* Your existing `ry` values were already correct under this — nothing to change.
|
||
- **→ Lane F / Fable (flags-off flag):** the fix is in the shared rig stack, so the **default (flags-off)
|
||
street crowd now faces travel** instead of moon-walking. Determinism + the golden plan hash are
|
||
untouched (visual only), but if any regression snapshots *pixels* it will move — it's a bug fix, re-pin
|
||
if needed (improvement, not a leak).
|
||
|
||
### Debt #2 — instrument GLBs wired (placeholder-persists, fail-soft, fleet-gated)
|
||
`band.js` keeps its primitive instruments as the immediate/`?noassets` fallback, then swaps E's manifest
|
||
GLBs in async when they resolve (same pattern as `interiors/glb.js`). Role → fitting: `guitar`→
|
||
`electric_guitar`, `bass`→`bass_guitar`, `vocal`→`mic_stand` (planted), `drums`→`drum_kit` (planted at the
|
||
riser), plus one `guitar_amp` as backline dressing. Manifest is **self-fetched** (promise-cached) so band.js
|
||
stays decoupled from C's glb.js, and the whole upgrade is **gated on `fleet.ready`** → under `?noassets`
|
||
(fleet null) it never fetches (proven: fresh-tab network shows **0** `procity_fit_*` / `manifest`
|
||
requests). Disposal-guarded by a generation counter; GLB clones share the cached geo/mats (detach-only,
|
||
never dispose — only the `ownGeo`-tagged primitives are freed). Orientation lives in one `GLB_FIT` table
|
||
(guitars need a +Z roll to sling; the kit is scaled 0.66 to fit C's tight 0.73 m vocal↔drums gap).
|
||
|
||
### Debt #3 — the drummer (4-piece, Fable's ruling)
|
||
`bandPoses.forEach` already picks up C's 4th `drums` pose (`seated:true`, riser `y`). The drummer sinks
|
||
`SEAT_DROP` (0.52 m) so the kit hides the straight legs and it reads seated **from the front — the crowd
|
||
POV / money shot**; from a hard side angle the sink shows (a standing rig faked as seated). A true seated
|
||
pose is a clip job — see the wishlist. Build is **defensive**: if John had vetoed to a trio, C simply
|
||
omits the pose and the band is 3 with zero D change.
|
||
|
||
### The outdoor queue (charter item — new `web/js/citizens/queue.js`)
|
||
`VenueQueue`: a seeded 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):
|
||
```js
|
||
const q = new VenueQueue({ citySeed: plan.citySeed, fleet });
|
||
q.spawn(streetGroup, { door, queueZone, gigId }); // at 'doors' (+ 'on' overflow); door = {x,z,ry}, ry = facing the entrance
|
||
q.admitOne(); // on each admitted punter (cover-charge / entry hook)
|
||
q.update(dt); // each street frame — idle + lerp + slow auto-drain
|
||
q.disposeAll(); // at 'done' / venue out of view
|
||
```
|
||
**→ Lane B:** publish `venue.queueZone = { x, z, ry, len }` — `(x,z)` = head by the door, `ry` = the
|
||
heading a punter faces (toward the entrance), `len` = line length; the line trails single-file opposite the
|
||
facing, so pick `ry` to run it along the verandah.
|
||
|
||
### Multi-venue surge (`sim.js`)
|
||
`setGig(venueShopId, on)` widened from a single `_gigVenue` to a `_gigVenues` **Set** so several concurrent
|
||
gigs each pull their own block; `_nearestOpenShop` now prefers the nearest gig venue in `GIG_RANGE` across
|
||
all of them. F's R12 single-venue call is a subset — still works. Non-gig patronage path is byte-identical.
|
||
|
||
### Measured budgets (seed 20261990, "Boolarra Heads", The Exchange Hotel #116)
|
||
| thing | number | budget |
|
||
|---|---|---|
|
||
| gig-night interior, rig fleet + GLB instruments | **56 draws** · 183k tris | ≤350 draws ✓ |
|
||
| — of which instruments (5 GLBs @ ~14k, E's ≤15k target) | ~70k tris | (no interior tri cap) |
|
||
| band / crowd | **4** on the deck (3 front + seated drummer) · **8** at 8 watch points | crowd ≤ watchPoints ✓ |
|
||
| outdoor queue | seeded **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:
|
||
```js
|
||
citizens.occupancyOf(shopId) → { count, occupants: [{ seed, enteredAt, pedIndex }] }
|
||
```
|
||
`count` = peds patronage currently has inside (pre-cap); `pedIndex` lets a browser BE the exact ped who
|
||
ducked in off the street (identity continuity). Occupancy is cleaned on emerge, on chunk-drop, and on
|
||
stream toggle. **Deterministic**: same seed + same update sequence → identical occupied shops AND
|
||
occupants (verified: 17 shops, seed:pedIndex byte-equal across two runs).
|
||
- **Consistency (D2)**: an `inside` ped is hidden on the street (not in the rendered set — verified 0
|
||
inside peds rendered) and re-emerges at the door. No double-presence.
|
||
|
||
### Browser rigs ✅ (KeeperManager, the leak-proven pattern)
|
||
`keepers.js` gained `browse:true` (faces the shelf via `ry`, **no player-greet**) + `pedIndex`
|
||
(pick the exact fleet ped) + `seedKey` (distinct browsers per shop). Each browser is a **merged ped
|
||
= ~1 draw**. Validated over **12 enter/exit cycles**: browser count == `min(occupancy, 3)` **12/12**,
|
||
worst interior **61 draws ≤350** (C's headroom holds), **leak-free (0 GPU delta after warmup)**,
|
||
disposed on every exit (`keepers.disposeAll()` in interior_mode).
|
||
|
||
### → Lane F: the handoff in `web/js/world/interior_mode.js` (F owns it) — verified working
|
||
Two-line wiring at interior build (F already has `keepers` + `current.browsePoints` from C):
|
||
```js
|
||
// on enter(shop), after buildInterior:
|
||
const occ = citizens.occupancyOf(shop.id);
|
||
const pts = (current.browsePoints || []).slice(0, Math.min(occ.count, 3)); // cap 3
|
||
pts.forEach((pt, i) => keepers.spawn(current.group, {
|
||
x: pt.x, z: pt.z, ry: pt.ry, shopId: shop.id, browse: true,
|
||
pedIndex: occ.occupants[i] ? occ.occupants[i].pedIndex : null,
|
||
seedKey: `${shop.id}#${i}`,
|
||
}));
|
||
// exit() already calls keepers.disposeAll() → browsers freed with the room. Leak-proven.
|
||
```
|
||
And **`web/index.html`**: `citizens.setShops(...)` door points must carry `shopId: s.id` (add it to the
|
||
R8 recipe — one field). Everything else (patronage, weather) is unchanged.
|
||
|
||
**Closing-time (pending A's ruling):** my dwell model already handles it sensibly — `_openAt(hours)`
|
||
stops *new* entries once a shop closes, and existing occupants clear within their short seeded dwell
|
||
(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)
|
||
1. **Shop door points** — build once from the plan, feed the sim:
|
||
```js
|
||
const CH = 64, shopsByChunk = new Map();
|
||
for (const s of plan.shops) {
|
||
const l = plan.lots.find(x => x.id === s.lot); if (!l) continue;
|
||
const ry = l.ry || 0, fx = -Math.sin(ry), fz = -Math.cos(ry); // facade normal → the street
|
||
const x = l.x + fx * (l.d / 2 + 0.6), z = l.z + fz * (l.d / 2 + 0.6); // door at the shopfront
|
||
const k = citizens.chunkKeyAt(x, z);
|
||
(shopsByChunk.get(k) || shopsByChunk.set(k, []).get(k)).push({ x, z, hours: s.hours });
|
||
}
|
||
if (!rosterV1) citizens.setShops(shopsByChunk); // patronage needs door points; inert without
|
||
if (params.get('patronage') === '0') citizens.setPatronage(false); // off-switch
|
||
```
|
||
(PATRON_RANGE 18m tolerates door-point imprecision; if a door reads wrong, nudge the `+0.6`.)
|
||
2. **Weather** — in the street branch each frame: `citizens.setWeather(window.PROCITY.weather);`
|
||
(B's contract is always a valid `{state,intensity}`; F sets `{clear,0}` when `?weather` is off.)
|
||
3. `setNightLivelyChunks` (openLate block) — already wired in F1; unchanged.
|
||
|
||
**Baseline note (flip protocol):** patronage default-on may move the flags-off draw baseline a hair —
|
||
in the direction of *fewer* draws (inside peds hidden), and the default spawn view has ~0 near-rigs so
|
||
it's near-nil. If F1's regression trips, re-pin per the flip protocol (it's an improvement, not a leak).
|
||
|
||
---
|
||
|
||
## ROUND 7 — MERGE VALIDATED · **GO for the roster flip** (→ Lane F: flip on this) · escape hatch ready
|
||
|
||
**Verdict: GO. Lane E's R7 ped-merge closed the one gap the R6 memo named. Full-density streamed
|
||
roster (perChunk 16) is now under budget in a real town. Flip it default-on.**
|
||
`qa.sh --strict` GREEN. All numbers in-shell (real town "Boolarra Heads", seed 20261990).
|
||
|
||
### Merge validation (E1 — `pipeline/merge_ped.py`, `447188a`) ✅
|
||
- **Every one of the 19 peds is now 1 mesh / 1 material** (was 8 meshes / 2 materials) → **~1 draw per
|
||
near-rig**; fleet **116→24 draws at the 24-cap** (E's own count: 92→19). Bonus: GPU memory dropped
|
||
too (geometries 205→160, textures 141→90 in-shell).
|
||
- Rigs **bind + animate** (walk/idle) — mixer 0.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** (`pickRig` unchanged).
|
||
- **Impostor bake clean** (19 subjects, 16×5 atlas); **determinism holds** (507 chunk-keyed identities
|
||
re-derive from seed).
|
||
|
||
### The flip numbers (full density, `perChunk 16` — no reduced-density concession)
|
||
| metric | pre-merge (R6) | **post-merge (R7)** | budget |
|
||
|---|---|---|---|
|
||
| worst continuous-walk street view **draws** | 356 ✗ | **241** ✅ | ≤300 |
|
||
| worst view **tris** | ~66k | **65,899** ✅ | ≤200k |
|
||
| worst-view near-rigs | (throttled to 8) | **17** (full) | cap 24 |
|
||
| draws / near-rig | ~7 | **~1** | — |
|
||
| 30-chunk soak (3 walks = 726 builds/disposes) | leak-free | **leak-free, 0 GPU delta** | baseline |
|
||
| heap over soak | 50→77→54 | 78→63 (GC, stable) | stable |
|
||
|
||
### Full-density shipping defaults (set this round)
|
||
`enableStream` default is now **`perChunk 16`, `radius 2`** (was 8). Near-cap stays 24 (now 24 draws,
|
||
not 168). `NIGHT_LIVELY_FLOOR 0.5` for the open-late block (R6). No other knobs change.
|
||
|
||
### → Lane F: the flip wiring in `web/index.html` (F owns the shell — decision #1, flip on my word)
|
||
Invert the R5/R6 flag: **stream is the default; `?roster=v1` is the escape hatch.**
|
||
```js
|
||
const rosterV1 = params.get('roster') === 'v1'; // escape hatch → the old fixed roster
|
||
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
|
||
graph: plan.streets, fleet,
|
||
chunkStream: rosterV1 ? null : { radius: 2 } }); // default-on
|
||
```
|
||
Hours-aware (optional, recommended): after construction, `citizens.setNightLivelyChunks(keys)` where
|
||
`keys` = the openLate shop's lot→chunk + 1-ring (`sim.chunkKeyAt(x,z)`; openLate = `hours[1] >= 22`).
|
||
Everything else (`setTimeOfDay`/`setExposure`/`update`/`setPaused`) is unchanged. The
|
||
hook-driven window (`onChunkBuilt`/`onChunkDisposed` → `sim.onChunkBuilt/onChunkDisposed`) is still the
|
||
preferred precise option; poll works with zero wiring.
|
||
|
||
**Verified in my harness (test page + runtime-enabled shell):** no-flag boot → `streamMode:true`,
|
||
perChunk 16, determinism ✓; `?roster=v1` → `streamMode:false`, fixed roster, determinism ✓. The
|
||
in-shell no-flag-URL boot completes once F lands the two-line flip above — the roster itself is proven
|
||
to run correctly in the shell at the shipping default (the soak + flip numbers above are in-shell).
|
||
|
||
---
|
||
|
||
## ROUND 6 — streamed-roster "default-on readiness" memo (→ Fable + Lane F, for the R7 flip call)
|
||
|
||
**Verdict: functionally ready (deterministic, leak-free, hours-aware, composes) — but full-density
|
||
default-on is gated on one optimisation (ped sub-mesh merge). Ships budget-safe TODAY at reduced
|
||
density.** Flag stays default-off this round. `qa.sh --strict` GREEN (sim.js doesn't touch its gates).
|
||
All numbers below are in-shell (real town "Boolarra Heads", seed 20261990), stream runtime-enabled
|
||
(F wires `?roster=stream` in F2 — call-site in the R5 section below).
|
||
|
||
### Soak (gate-2 + far-field, `?roster=stream`) ✅
|
||
- **Leak-free**: 3 full street-graph walks = **242 chunk builds / 242 disposes → 0 GPU geometry / 0
|
||
texture delta** (renderer.info constant 205/141); **16 shop enter/exit cycles → 0 delta**. JS heap
|
||
oscillates 50→77→**54** MB (GC recovers — not monotonic, no leak).
|
||
- **Determinism**: 188 live chunk-keyed identities re-derive byte-for-byte from the seed, in-shell.
|
||
- **Flag-off byte-identical**: shell citizen-0 signature `0:13:7:1.786:1.493:3:-1` (R4 golden), still
|
||
exact after this round's changes. 0 console errors across the soak.
|
||
|
||
### Hours-aware density ✅ (implemented this round)
|
||
Each chunk thins per-frame by the same `densityAt(tod)` curve v1 uses. New: `setNightLivelyChunks(keys)`
|
||
gives the open-late block a night floor (`NIGHT_LIVELY_FLOOR=0.5`). Measured: the open-late **video**
|
||
shop (hours 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 `perChunk` | worst-view draws | worst near | density (within 70m, median) |
|
||
|---|---|---|---|
|
||
| 16 | **356** ✗ | 15 | ~26 |
|
||
| 12 | 314 ✗ | 10 | ~18 |
|
||
| **8 (new default)** | **291** ✓ | 10 | ~13 |
|
||
|
||
So it ships **budget-safe at `perChunk=8` today** (~13 median within 70m — constant + camera-following
|
||
vs v1's uniform ~13 that *thins as the town scales*; and it concentrates **~10 near-tier rigs vs v1's
|
||
0–1** — the visible upgrade). Full density (`perChunk 16`, ~2× the crowd) needs the budget headroom.
|
||
|
||
### Recommendation for the R7 default-on flip
|
||
1. **Land the ped sub-mesh merge** (merge each ped's 8 sub-meshes by material → 2 → ~2 draws/rig).
|
||
Then `perChunk 16` worst-view ≈ 260 draws — full density under budget. It's a `rigs.js` fleet-load
|
||
optimisation and it is **F1-safe in practice**: v1's default/spawn view has ~0 near-rigs, so the
|
||
flags-off draw snapshot is unaffected — the merge only cuts draws where near-rigs cluster (the
|
||
stream path). Also helps v1 street draws + interior keepers. ~1 session; risk = skinned-mesh merge
|
||
(verify animation). **This is the single thing standing between stream and default-on.**
|
||
2. Until then: default-on is safe at `perChunk=8` (reduced density) — flip is a judgement call on
|
||
whether ~10 near-rigs + constant far-field beats v1 today, or wait for the merge for the full crowd.
|
||
|
||
*Known non-blockers: near↔mid is a hard LOD switch (documented, imperceptible at 25m); open-shop
|
||
spawn-bias beyond the night floor is v2-later (needs per-chunk shop data — the sim is graph-only).*
|
||
|
||
---
|
||
|
||
## ROUND 5 — chunk-streamed roster (v2, behind `?roster=stream`) ✅ → Lane F wiring below
|
||
|
||
**As-built implementation of the R3 design note (further down).** Default-off; v1 path byte-identical.
|
||
`tools/qa.sh --strict` GREEN (sim.js doesn't touch the citygen/manifest gates).
|
||
|
||
**What it does.** v1 spreads one fixed roster over the whole town, so big-town streets are uniformly
|
||
sparse. Stream generates + ticks citizens **per 64m chunk**, keyed so identity is a pure function of
|
||
`(seed, chunkKey, i)` — independent of town size / visit order — and windows them to the live chunks
|
||
around the camera. Density becomes constant per unit street and follows the player. Everything
|
||
downstream (LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer) is unchanged and stays
|
||
**global across live chunks**.
|
||
|
||
**Measured (test page, 9×9 fixture grid, ~±208m):**
|
||
- **Constant far-field density**: citizens within 70m ≈ 58 at spawn, 129 at 150m out, 83 elsewhere —
|
||
vs v1 (200 pop) which is uniformly ~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 signature
|
||
`0:13:7:1.786:1.493:3:-1` — **exact match** to the R4 golden capture. No perturbation.
|
||
|
||
### → Lane F: wiring `?roster=stream` in `web/index.html` (F owns the shell)
|
||
Minimum (poll-driven, zero Lane B changes — works today):
|
||
```js
|
||
const streamOpt = params.get('roster') === 'stream' ? { radius: 2, perChunk: 16 } : null;
|
||
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
|
||
graph: plan.streets, fleet, chunkStream: streamOpt });
|
||
```
|
||
Preferred (hook-driven — the sub-roster window then matches Lane B's built-chunk window exactly; the
|
||
sim auto-stops polling on the first hook call). Wire onto the chunks ctx **only when the flag is on**:
|
||
```js
|
||
if (streamOpt) {
|
||
chunks.ctx.onChunkBuilt = (key) => citizens.onChunkBuilt(key); // Lane B calls this (chunks.js:41)
|
||
chunks.ctx.onChunkDisposed = (key) => citizens.onChunkDisposed(key); // chunks.js:47
|
||
}
|
||
```
|
||
(`chunks` already exposes `ctx`; if not, expose it or pass the two callbacks into `createWorld`.)
|
||
Nothing else changes — `setTimeOfDay`/`setExposure`/`update`/`setPaused` are identical. Flag-off must
|
||
not construct with `chunkStream`, so the v1 path (golden identities) is untouched. The 64m chunk key
|
||
(`${cx},${cz}`, `Math.floor(v/64)`) matches Lane B's `planutil.js` exactly, so B's keys line up with ours.
|
||
|
||
*Hours-aware density is in (task 3): each chunk is thinned per-frame by the same `densityAt(tod)` curve
|
||
v1 uses, so streamed streets empty at night too. Open-shop spawn-bias weighting is left for v2-later
|
||
(needs shop data in the chunk; the sim is graph-only today) — noted, not built.*
|
||
|
||
---
|
||
|
||
## ROUND 4 — in-shell verify + decimation validation (→ Fable, → Lane F)
|
||
|
||
**All three R4 tasks done. `tools/qa.sh --strict` GREEN. Sign-off for Lane F below.**
|
||
|
||
### D1 — in-shell rig/keeper verification (`web/index.html`) ✅
|
||
Verified in the real game (not the test page), via `window.PROCITY`:
|
||
- **Placeholder→rig upgrade** fires for both peds (`citizens.mode` → `'rig'`, 19 fleet, pool active)
|
||
and keepers (keeper spawns at the counter `keeperStand` pose as a **rig**).
|
||
- **Leak-free**: baseline `renderer.info.memory` = {geometries 213, textures 198}; after **12
|
||
enter/exit shop cycles** it returned to **exactly** 213/198 (Δ geom 0, Δ tex 0). Interiors peak
|
||
~303 geoms inside, drop back every exit. Confirms the R-review dispose fix (free the keeper's
|
||
Skeleton only, never the fleet-shared geo/mats) holds in-shell alongside Lane C's interior dispose.
|
||
- **Determinism in-shell**: seed 20261990 → byte-identical identity signature across a full reload.
|
||
- **`?noassets=1`**: `fleet===null`, mode stays `placeholder`, peds + keeper are placeholder actors,
|
||
**zero ped GLB fetches** — town fully playable asset-free.
|
||
|
||
### D2 — decimated ped validation (critical path — E1 landed `518678d`) ✅ SIGN-OFF FOR F
|
||
Validated Lane E's decimated fleet (`web/models/peds/`, ref `lane-e/round4-peds`):
|
||
- **Per-ped tris: 1564–2805, avg 2450 — all ≤3k** (matches E's `_peds_decim.json`).
|
||
- **Rigs bind + animate**: skinning preserved, the shared `walk.glb`/`idle.glb` retarget unchanged
|
||
(skeleton/65 joints intact), near actors animate, mixer 0.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 via
|
||
`renderer.toneMappingExposure`, which both three and OutputPass read). The call at
|
||
`web/index.html:245` is harmless — keep or drop it, your call.
|
||
|
||
*(Round-1/3 notes below remain valid; the tri-budget finding #1 is superseded by D2's decimation.)*
|
||
|
||
## What shipped
|
||
|
||
| file | role |
|
||
|---|---|
|
||
| `web/js/citizens/rigs.js` | ported rig stack: fleet loader, `_canon`/`canonRig`/`_rotOnly`, `buildFigure` (head-bone height-normalise + feet plant), `spawnRig` (single clip), `makeActor` (walk↔idle crossfade), `pickRig` |
|
||
| `web/js/citizens/placeholder.js` | seeded low-poly box humanoid (POLY lock) — walks/idles, planted at y=0, hot-swaps to a rig |
|
||
| `web/js/citizens/impostor.js` | 4-yaw sprite-atlas baker + instanced billboard layer (mid tier, 1 draw call) |
|
||
| `web/js/citizens/sim.js` | deterministic roster, footpath lanes, near/mid/far LOD, rig pool, staggered mixer budget, time-of-day density |
|
||
| `web/js/citizens/keepers.js` | one keeper per shop at the counter slot, idle + greet head/body-turn |
|
||
| `web/citizens_test.html` | standalone harness: fixture 3×3 street graph, sliders, tier debug, determinism check |
|
||
| `web/models/peds/*` | 19 rigged GLBs + `walk.glb` + `idle.glb`, **byte-identical** copies from 90sDJsim (checksummed) |
|
||
|
||
## The fleet (copied, never edited)
|
||
|
||
19 rigged peds (17 normal + 2 comical) + 2 clip-only GLBs, ~37 MB total, copied from
|
||
`johnking@100.91.239.7:~/Documents/90sDJsim/web/world/models/peds/` (also present locally at
|
||
`~/Documents/90sDJsim/…`). SHA-verified byte-identical (house law: canonical source stays upstream).
|
||
|
||
- Skeletons canonicalise cleanly: `mixamorig1…`→`mixamorig…`, so **one shared walk clip drives all 19**
|
||
and one shared idle clip too. Verified in-scene — a single `walk.glb` animates every ped.
|
||
- `walk.glb`/`idle.glb` position tracks + `Hips.quaternion` are stripped (`_rotOnly`) — without this
|
||
the different-scale root track inflates peds to giants. Confirmed: no giants/ants across all 19.
|
||
|
||
## Measured budgets (M3 Ultra, `citizens_test.html`)
|
||
|
||
Preview note: the in-app browser reports `visibilityState:'hidden'`, which throttles `requestAnimationFrame`,
|
||
so the on-screen fps counter reads low (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)
|
||
|
||
1. **The inherited peds are ~49.7k tris each — too heavy for the CITY_SPEC 200k-tri "typical view".**
|
||
24 near rigs ≈ **1.2M tris**; a realistic 16-rig street view already measured **1.02M tris**.
|
||
Framerate holds on M-series (GPU eats it), but this leaves *nothing* for Lane B buildings.
|
||
**Recommendation:** decimate the ped fleet to ~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.
|
||
|
||
2. **Draw calls scale with near rigs (~6 calls/ped — each GLB has several sub-meshes/materials).**
|
||
16 rigs ≈ 144 calls; 24 rigs ≈ ~190. Under the 300-call street budget *today*, but tight once
|
||
Lane B adds shells. **Recommendation:** merge each ped's sub-meshes by material on load
|
||
(`BufferGeometryUtils.mergeGeometries`) → ~1–2 calls/ped. Cheap win, deferred (not blocking).
|
||
|
||
3. **Peds are metallic-PBR (`metalness≈0.5`) and render black without a `scene.environment`.**
|
||
The test scene builds a neutral PMREM; the impostor bake is fed the same env. **Lane B's shell must
|
||
set `scene.environment`** (a sky PMREM) or every citizen — near and mid — goes dark. This is wired:
|
||
`CitizenSim` reads `scene.environment` and passes it to the impostor baker.
|
||
|
||
4. **Impostor material is `toneMapped:false` and does ACES + sRGB itself** (matching
|
||
`ACESFilmicToneMapping`, exposure `/0.6`). **The shell DOES animate `renderer.toneMappingExposure`**
|
||
(Lane B `lighting.js` sets it per day segment), so mid billboards drift brighter/darker than the near
|
||
rigs across the day unless matched. **Round-3 fix:** `CitizenSim.setExposure(e)` passthrough added —
|
||
see the Lane F hook below. (For a non-ACES curve the in-shader ACES in `impostor.js IMP_FRAG` would
|
||
also need to match; today both are ACESFilmic so exposure is the only variable.)
|
||
|
||
*Hardened by a 4-dimension adversarial code review (see D-progress.md): 6 confirmed defects fixed —
|
||
non-deterministic fleet order, shared-geometry disposal, impostor under-exposure, unrestored
|
||
viewport, missing modelMatrix, frozen exposure.*
|
||
|
||
## Integration hooks for Lane F
|
||
|
||
- `new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet })` — `graph = { nodes:[{id,x,z}],
|
||
edges:[{id,a,b,width,kind}] }`. Swap the fixture graph for Lane A's `CityPlan.streets`. Footpath
|
||
lanes are derived from edge `width` + a 0.9 m margin; pedestrians keep to the right of travel.
|
||
- `sim.setPopulation(n)` (slider / density) · `sim.setTimeOfDay(t01)` (drive from the shell's day
|
||
segment) · `sim.setPaused(bool)` (wire to `visibilitychange`) · `sim.update(dt)` each frame.
|
||
- **[Round-3] `sim.setExposure(e)` — one line, needed for dusk/night.** The shell already calls
|
||
`citizens.setTimeOfDay(...)` in the street loop (`index.html:225`); add right after it:
|
||
```js
|
||
citizens.setExposure(renderer.toneMappingExposure); // [Lane F] keep mid billboards matched to day/night exposure
|
||
```
|
||
Cheap (sets one uniform), safe to call every frame, and survives the placeholder→rig atlas re-bake
|
||
(stored internally). **Verified** in `citizens_test.html`: uniform tracks 0.55↔2.3 and persists
|
||
across the fleet upgrade. Without it, mid impostors read too bright at night / too dark at noon vs
|
||
the near rigs. *(This is a Lane-F seam edit in `index.html`; flagged here per the cross-lane note rule.)*
|
||
- **[Round-3] Keeper fleet-upgrade (enables F §3.4).** Keepers today are placeholder-only because F
|
||
builds `new KeeperManager({ camera, citySeed })` with no fleet. To upgrade them to shared GLB rigs,
|
||
pass the loaded fleet: `new KeeperManager({ camera, citySeed, fleet })`. `keepers.js` already picks a
|
||
rig when `fleet.ready` and falls back to a placeholder otherwise — no other change. **Dispose is
|
||
leak-free**: verified 15 enter/exit cycles with a rig keeper → `renderer.info.memory` geo/tex return
|
||
exactly to baseline (`_disposeInner` frees the clone's skeleton bone-texture only; shared fleet
|
||
geo/mats are preserved for siblings). Safe to spawn/`remove()` per interior enter/exit.
|
||
- **Chunk streaming (v1.5):** roster is currently whole-graph — fine for v1, but big towns look sparse
|
||
(see the design note **"Chunk-streamed roster"** below for the why + the full plan).
|
||
- **Keepers:** `keepers.spawn(roomGroup, { x, z, ry, shopId, type })` — feed `x,z,ry` from Lane C's
|
||
interior counter `places`; call on interior build, `keepers.remove(handle)` on dispose.
|
||
`keepers.update(dt, playerPos)` each interior frame.
|
||
- `?noassets=1` verified: full placeholder town, mixers=0, zero crashes.
|
||
|
||
## Chunk-streamed roster — design note (✅ IMPLEMENTED in R5, behind `?roster=stream`)
|
||
|
||
*Round-3 task 3 (design), built in Round 5 — see the "ROUND 5" section at the top for the as-built
|
||
result + Lane F wiring. This section is the original spec; the implementation followed it: hook-driven
|
||
(`onChunkBuilt`/`onChunkDisposed`) with a poll fallback, chunk-local identity, global near-cap, owner
|
||
despawn, constructor opt-in (`chunkStream`). Kept for the rationale.*
|
||
|
||
**The problem (measured).** `setPopulation(N)` builds one global roster and spreads it uniformly over
|
||
*all* edges (`identityOf` picks `edge = rng()*edgeCount` across the whole graph). On the 12-edge test
|
||
fixture that fills the view. On a real town — "Boolarra Heads" has hundreds of edges — those same N
|
||
citizens scatter town-wide, so almost all sit beyond the 70 m cull from any one camera (I measured a
|
||
27-node generated town: pop 200 → **4 near / 6 mid / 190 far**). Raising N to fill the near streets
|
||
wastes roster + advance cost on citizens nobody can see, and still can't guarantee local density.
|
||
|
||
**The fix.** Generate + tick citizens **per chunk**, keyed so identity is independent of town size and
|
||
visit order, and stream them in lockstep with Lane B's chunk window (the chunks already building/
|
||
disposing around the player). Density becomes *constant per unit street*, and total roster is bounded
|
||
by the live-chunk window, not the town.
|
||
|
||
**Design:**
|
||
|
||
1. **Chunk-local identity.** Replace the global running `id` with a per-chunk key:
|
||
`identityOf(citySeed, chunkKey, i)` for `i in [0, perChunkCount)`, and choose the citizen's home
|
||
edge from **that chunk's** edges (`chunkIndex(plan).chunks[chunkKey].edges`), not the global list.
|
||
Then "same seed + same chunk → same people" holds regardless of what else exists or the order chunks
|
||
were visited — the determinism property that whole-graph keying quietly loses at scale. `perChunkCount`
|
||
is seeded per chunk and scaled by district (main-street chunks busier than residential) × `densityAt(tod)`.
|
||
2. **Windowing — poll-driven (works today, no Lane B change).** Each frame derive the active chunk set
|
||
from the camera: `chunkKey(⌊camX/chunkSize⌋, ⌊camZ/chunkSize⌋)` + neighbours within a radius R (R≥1
|
||
so a walker never steps into an unloaded chunk before its owner unloads). Diff vs the live set →
|
||
build sub-rosters for newly-active chunks, dispose for newly-inactive. This mirrors B's own window and
|
||
needs nothing from B. *Alternative — hook-driven:* `chunks.js` exposes an optional `ctx.onChunkBuilt`
|
||
(see `chunks.js:40` — "inert unless a consumer sets it"); if B also adds `onChunkDisposed`, drive
|
||
spawn/despawn off those instead of polling. Prefer this once B commits the pair; poll until then.
|
||
3. **Storage.** Swap the single `this.roster` array for `this.chunkRosters = Map<chunkKey, Citizen[]>`.
|
||
The per-frame `update()` iterates the union of live-chunk citizens. **Everything downstream is
|
||
unchanged** — LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer already operate
|
||
per-citizen and are chunk-agnostic; the near-cap + mixer budget stay **global** across live chunks
|
||
(nearest-first selection already does the right thing on the merged set).
|
||
4. **Ownership + hand-off.** A citizen is owned by its spawn chunk. It walks freely (the graph is
|
||
continuous); because R≥1 keeps neighbours loaded, crossing a chunk boundary is seamless. It despawns
|
||
only when **its owner chunk** unloads — even if it's momentarily standing in a still-loaded neighbour
|
||
(acceptable: that's behind the player, past the cull). No re-keying on cross, so no identity churn.
|
||
5. **Budget.** Live window ≈ (2R+1)² chunks × `perChunkCount`. Tune `perChunkCount` (~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:
|
||
1. `sit` — bench-sit at benches/verandah steps (sim loiter has the hook; needs the clip). **R13: the gig
|
||
drummer also wants this (or a bespoke `drum`/`play-drums` loop)** — the 4th band member is currently a
|
||
standing rig sunk `SEAT_DROP` behind the kit, which reads seated only from the front. A real seated clip
|
||
retires the sink hack (band.js `SEAT_DROP` + the `m.seated` branch in `update`).
|
||
2. `lean` — loiter against a shopfront (window-shopping stops).
|
||
3. `look-around` / `idle-look` — window-shopping variety + a better keeper greet than the body-turn.
|
||
4. `talk` / `gesture` — pairs chatting on the footpath (spawn in seeded 2-groups).
|
||
5. `carry` / laden walk — shoppers with bags after a purchase (content-phase tie-in).
|
||
6. `browse` / `reach` — keeper stocking shelves; customer reaching a shelf inside interiors.
|
||
|
||
Bespoke (won't be on Mixamo, hand-author later): crate-riffle at market stalls, till-operation.
|
||
|
||
## Known limitations (honest)
|
||
|
||
- Near↔mid swap is a hard LOD switch at ~25 m (hysteresis 24/27 m). At that range a 128px impostor
|
||
and the full rig subtend near-identical screen size, so it reads as seamless — but it is a switch,
|
||
not a cross-fade (rig materials are shared across SkeletonUtils clones, so per-instance opacity fade
|
||
isn't free). If a pop is ever visible after buildings land, the fix is a short dither/fade band.
|
||
- In `?noassets` mode, mid impostors use 8 generic placeholder variants while near placeholders are
|
||
per-citizen coloured — so a citizen's colour can shift slightly crossing 25 m. Fallback-only; with
|
||
the real fleet, near and mid are the same baked ped.
|
||
- Loiter *timing* is dt-driven (cosmetic), so exact positions at time T aren't reproducible across
|
||
runs; **identity** (who, which ped, height, speed, spawn beat) is fully seeded and asserted.
|