PROCITY/docs/LANES/LANE_F_NOTES.md
m3ultra a358c5d9ef Lane F R13 (v3.0-beta, the district): per-venue gig state + walla/applause + queue wiring; qa --strict 6/6
Wires A–E + D's district together and fires it. Seed 20261990 → "Boolarra Heads": 3 venues, 2 playing
tonight (The Exchange Hotel pub gig-pubrock + an RSL gig-covers, one dark), Screaming Utes $10 on the door.

- gig_state.js → PER-VENUE latches off the shared procity:segment spine (one per venueShopId); all three
  hard-won R12 laws kept. New runtime surface window.PROCITY.gigs.byVenue + onOf/openOf/gigOf/coverOf/
  bandNameOf/paidOf(id) + markPaidOf/venueShopIds; the R12 single-venue alias kept for one round.
- interior_mode.js: crowd-walla rides the interior tone layer under the live band bed while `on`; an
  applause sting fires once on the on->done edge. DELETE the GIG_BED bridge — debt #1, and it was a LIVE
  BUG: E renamed pubrock-live->gig-pubrock, so the bridge (gig-pubrock->pubrock-live) pointed at a dead
  key => the band would have mimed in silence. room.audio.gigKey (already the manifest key) feeds directly.
- index.html: per-venue cover charge (a night at two venues is two covers), concurrent per-venue setGig
  surges, and the outdoor VenueQueue lifecycle (D shipped the class + left the street-side seam to F:
  spawn at doors off B's queueZoneFor, drive each frame, dispose by done), exposed as queueCountOf(id).
- interior_scale_check.py: human-size assert now measures STATURE (crown-foot, platform-independent);
  world-crown ceiling-clip assert kept as its own line (debt #6).
- flags_check.py smoke_gigs -> THE DISTRICT: band->4, per-venue loop (every gigKey resolves across 3
  genres, crowd <= its own watchPoints, no-giants by stature), street budget at the busiest venue block,
  the queue assert; plus the R12 determinism/cover/noassets/flags-off arms.
- CITY_SPEC: pin the window.PROCITY.gigs runtime surface. gig_shot.py -> r13 interior + venue_night shots.

qa.sh --strict GREEN 6/6, 0 warn. Interior 63 draws/35k tris; street @ venue block 121 draws/51k tris;
band+crowd stature 1.61-1.81 m under a 4 m ceiling; flags-off fingerprint 0x3fa36874 unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:22:03 +10:00

526 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# LANE F — NOTES (integration runbook + QA gates)
*Lane F is integration + QA. It runs **after** AE land. This file is the durable technical spec:
the exact wiring edits, the contracts F needs from each lane, and the QA gate procedures. It is
written **before** AE finished so the wiring is a fast, mechanical pass the moment they do —
and so each lane can see the small hook F will ask of it and build it in up front.*
Status of this file: **living**. Update the ✅/⏳ markers as wiring lands. Snapshot of lane
readiness lives in [`F-progress.md`](../../F-progress.md); run `node tools/qa/scaffold_check.mjs`
for the live matrix.
> **v2.0 FROZEN (round 9).** Nine rounds: seed → walkable town → weather, a shopping crowd,
> follow-a-ped-into-a-shop presence, and real stock you can buy. Current gate reality + the
> final v2 flags table live in [`F-progress.md`](../../F-progress.md); `qa.sh --strict` GREEN 5/5
> (scaffold+determinism · consistency · citygen selfcheck · manifest --depot · v2 flags harness STRICT).
> CityPlan v2 contract frozen by Lane A in CITY_SPEC; validated live by selfcheck (1727/1727) + goldens.
---
## 0. Ground truth / environment
- Repo root on this machine: `/Users/m3ultra/Documents/procity` (the lane prompts say
`/Users/jing/Documents/PROCITY` — that path is from the authoring machine; **use the real path
above**). Reference repos are cloned alongside: `../90sDJsim`, `../thriftgod`. Ultra box
(existing 3D library / Blender / clip bank): `ssh johnking@100.91.239.7`.
- Serve: `cd web && python3 -m http.server 8130`. Node 26, Python 3.14 present.
- `web/package.json` has `"type":"module"` (Lane A) so `node web/js/**/*.js` runs as ESM.
- **F never edits another lane's internals.** F adds *wiring* — new files it owns, and small,
clearly-marked hook edits at the seams. Where a hook must live inside a lane's file, F first
looks for the hook that lane already exposed (see contracts below); the lane owns its file, F
owns the glue.
## 1. Files Lane F owns / creates
| path | purpose |
|---|---|
| `tools/qa.sh` | the QA gate runner (scaffold + citygen selfcheck + manifest validator) ✅ |
| `tools/qa/scaffold_check.mjs` | scaffold + PRNG determinism law + **live lane-readiness matrix** ✅ |
| `tools/shots.py` | Playwright tour-capture → `docs/shots/v1_tour/` + contact sheet (ported from 90sDJsim `tools/shots.py`) |
| `tools/soak.py` | Playwright 10-minute scripted-walk soak (chunk/heap/leak/error gate) |
| `web/js/citygen/index.js` | barrel so B's `import('./js/citygen/index.js')` resolves to A's `plan.js` ✅ (§3.1) |
| `web/js/world/interior_mode.js` | **interior bridge** — B's shell ↔ C's `buildInterior`; owns the `interior` mode branch ✅ (§3.2) |
| `docs/V2_IDEAS.md` | parked-scope catalogue ✅ |
| `docs/shots/` | reference shot tree (`laneA…E/`, `v1_tour/`, `before/`, `references/`) |
| `F-progress.md` | status report for fable |
The wiring is kept as **focused F-owned modules** (e.g. `interior_mode.js`) that the shell imports,
rather than logic sprinkled through B's files. Each seam edit inside `index.html` is one line,
commented `[Lane F integration]`, and reversible. That keeps ownership clean: B owns the shell +
mode machine, C/D own their libraries, F owns the glue modules and the marked seams that call them.
---
## 2. Contracts F depends on (verify these as lanes land)
Pulled from CITY_SPEC + each lane prompt. The scaffold check's readiness matrix tracks presence;
these are the **shapes** F wires against. If a lane ships a different shape, fix it here and in
the wiring, not by guessing.
> **⚠ Corrected against the REAL landed code (2026-07-14).** The bullets below are what the lanes
> actually shipped — several differ from the spec-derived guesses in an earlier draft (verified by
> reading each file + the Lane F self-audit workflow).
**Lane A — `web/js/citygen/plan.js`** *(landed, verified)*
- `generatePlan(citySeed) → CityPlan` (schema in CITY_SPEC §Layer 1). <3ms, deterministic, JSON-clean.
- `chunkIndex(plan) → { chunkSize, chunks }` where `chunks["cx,cz"] = { lots:[id…], shops:[…], edges:[…] }`
(a **plain object, not a callable**; a lot may bucket into several chunks). Also exports `chunkKey(cx,cz)`.
- `web/js/core/registry.js` exports `SHOP_TYPES` per type `{ label, facades:[<full filename>.jpg…],
sign, interior, fittings:[…], storeys:[min,max], hours:{open,close} }`. **`facades` are full
filenames** (`facade-timber-teal.jpg`), not bare keys — see the skins seam in §7.
- `web/map.html` — 2D plan viewer (verified rendering real towns). `web/js/citygen/index.js` **is the
barrel Lane F added** so the shell's `import('./js/citygen/index.js')` resolves (§3.1).
**Lane B — `web/index.html` + `web/js/world/*`** *(landed, verified live)*
- Mode state machine `MODE ∈ {map, street, interior}` (interior branch not yet used), one renderer.
- **`createChunkManager(plan, scene, ctx)`** — a **factory**, not a class; returns
`{ update(pos), warmup(pos), getColliders(x,z), setNight(bool), getDoorMeshes(), count, dispose() }`.
**No `onChunkBuilt/onChunkDisposed` lifecycle hooks** — so §3.3 must drive citizens off the street
graph, not chunk lifecycle (or F adds a hook; prefer graph-driven).
- Door pick is wired via HUD: `createHUD({ onEnterShop: (shopId,name)=>enterShop(...) })`; `enterShop()`
dispatches `procity:enterShop` and is otherwise **a stub** (`hud.showToast('interiors coming')`) —
this is the seam F completes in §3.2. Interior returns via `procity:exitShop`.
- HUD shows draws / tris / fps / seed / time-of-day (F reads these for the budget gate).
- Exposes `window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins }`
(F builds the `window.DBG` harness hook on top of this — §4).
**Lane C — `web/js/interiors/interiors.js`** *(landed, verified)*
- `buildInterior(shop, THREE, opts={registry,archetype,stockAdapter}) → { group, spawn:{x,z,ry},
exits:[…], places:[…], dims, recipe:{counterPos,…}, dispose() }`. Matches the wiring plan.
- Caller must attach lot dims: pass `{ ...shop, lot: plan.lots.find(l=>l.id===shop.lot) }`.
`dispose()` frees tracked GPU resources (soak gate asserts baseline).
**Lane D — `web/js/citizens/*`** *(landed, verified)*
- `sim.js` exports **`class CitizenSim`** — `new CitizenSim({ renderer, scene, camera, citySeed,
graph, fleet, group })`. It is **street-graph driven, not per-chunk**: there is no
`spawnRoster/despawn/setDaySegment` — build it once from the street graph and tick it in the loop.
- `keepers.js` exports **`class KeeperManager`** — `new KeeperManager({ camera, citySeed, fleet })`,
then `spawn(target, { x, z, ry, shopId, type }) → handle`, `remove(handle)`, `disposeAll()`,
`update(dt, playerPos)`. (Not `spawnFor/disposeFor`.)
**Lane E — `web/assets/manifest.json` + `pipeline/validate_manifest.py`**
- Manifest: `{depot, fittings:{id:{file,footprint,thumb}}, furniture:{…}, skins:{facade,sky,ground,wall,interior}}`.
- `python3 pipeline/validate_manifest.py` green (F's gate 5 runs it).
- B/C read the manifest for GLB upgrades; **primitive fallbacks must still fire under `?noassets=1`**.
---
## 3. Wiring checklist (LANE_F §Wiring — expanded to exact steps)
All of this lives in `web/js/world/wiring.js` (F-owned) + the minimal hooks each lane exposes.
### 3.1 Plan source swap (A → B) ✅ DONE (verified live)
- Lane B already reads `?seed=N` (default `20261990`), guards the citygen import, and surfaces the
town name + seed in the start panel and HUD. **But it imported `./js/citygen/index.js`, which did
not exist** (Lane A shipped `plan.js`), so it silently fell back to the 14-shop fixture.
- **Fix:** Lane F added `web/js/citygen/index.js` (barrel re-exporting `generatePlan/chunkIndex/chunkKey`
from `plan.js`). Verified live: shell logs "using Lane A generatePlan", HUD shows the real town
("Boolarra Heads", 523 shops), 31 chunks stream.
- Map key **M** is wired by Lane B to an in-shell minimap (not map.html). Fine for v1.
- **Gate:** plan determinism asserted green by `scaffold_check.mjs`. Map-PNG determinism pending the
shot harness + DBG hook (§4).
### 3.2 enterShop → interiors (B ↔ C) ✅ DONE (verified live, 2026-07-14)
Implemented as an **F-owned bridge module `web/js/world/interior_mode.js`** (`createInteriorMode`),
plus a thin marked seam in the shell. The bridge renders the interior into its **own dark
THREE.Scene** — the street scene is simply left frozen (not disposed → instant return, CITY_SPEC L3
"pause, not dispose"), which is cleaner than hiding B's ground+chunk meshes in a shared scene.
```
enterShop(shopId, name): // shell seam (was the toast stub)
shop = plan.shops.find(s => s.id === shopId) // id is numeric; matches door.shopId
lot = plan.lots.find(l => l.id === shop.lot) // attach {w,d} — Lane C sizes the room to it
interiorMode.enter({ ...shop, lot:{w,d} }, name); setMode('interior')
interiorMode.enter(shop): // F module
current = buildInterior(shop, THREE); scene.add(current.group) // Lane C
doorReturn = {x,y,z,ry of the player on the street}
camera → current.spawn (x, 1.6, z, ry); exitArmed = false
loop [MODE==='interior']: if interiorMode.update(dt,keys) leaveShop()
update: walk via current._debug.grid occ/bounds (mirrors interior_test.html) → renderer.render(ownScene)
exit arms once player steps >2m from the door, then fires within 1m of exits[].toStreet
leaveShop() / 'procity:exitShop' / Esc(unlock): // three exit routes, all → leaveShop
interiorMode.exit(): current.dispose() (frees GPU + unparents); camera → doorReturn
setMode('street') // pointer stayed locked → walking resumes at once
```
- **Verified live** (seed 20261990 "Boolarra Heads"): door → **Toy Shop "Numbat Playthings"**
renders (ivy wallpaper, stocked shelves, counter, exit doorway); exit state-machine correct
(no instant-exit at spawn; arms then fires at the door); **leak-free** — GPU memory 134 geo/37 tex
→ 217 loaded → **back to 134/37 after `dispose()`**. Swept **all 9 shop types** (toy/video/pawn/
opshop/book/record/stall/dept/milkbar), every one `ok+pathOK+leftClean`, worst build **8.4ms**
(budget 50), zero console errors.
- **Gate:** determinism (byte-identical placement per seed) is asserted by Lane C's own soak page;
memory-to-baseline after `dispose()` confirmed above. Real pointer-lock walk-in is the one bit the
automated browser can't drive — needs a human (or the DBG hook in §4).
### 3.3 Citizens + keepers (D)
**Keepers ✅ DONE (verified live).** Wired into the interior bridge (`interior_mode.js`), not the shell:
`new KeeperManager({ camera, citySeed: plan.citySeed })` — **no `fleet` passed → asset-free placeholder
actor** (rig-fleet upgrade is later, with §3.4). On `enter`, find the counter interactable
(`places.find(p => p.userData.keeperStand)` — Lane C tags `{x,z,ry}` there) and
`keepers.spawn(interior.group, { x, z, ry, shopId, type })`; tick `keepers.update(dt)` each interior
frame (playerPos defaults to the camera); `keepers.disposeAll()` on `exit`, before the room disposes.
- **Verified:** placeholder keeper (11-mesh humanoid) stands at the counter pose (record shop:
`{2.55,-2.46, ry 1.571}`), **turns to greet** a nearby player (0.21 rad) and eases back when they
leave (~0), disposes to 0. **Leak-free**: 5 enter/leave cycles over 5 shop types → after-leave
memory constant at 96 geo/16 tex, **net growth 0**; 0 console errors.
**CitizenSim (street peds) ✅ DONE (verified live).** Happy surprise: **`plan.streets` already IS the
graph** — `{ nodes:[{id,x,z}], edges:[{id,a,b,width,kind}] }` — so `graph: plan.streets` passes
straight in, **no adapter needed** (the runbook's earlier `streetGraphFrom` is unnecessary). In the
shell: `new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets })`,
`setPopulation(140)` (`?pop=` tunable), then in the **street** branch each frame:
`setTimeOfDay((clk.seg + clk.frac)/6)` + **`update(dt)`** — note `update` takes **only dt** and reads
`this.camera.position` internally (the runbook's `update(dt,pos)` was wrong). `visibilitychange →
setPaused`. No fleet → asset-free placeholder(near)/impostor(mid) tiers; sim self-manages NEAR/MID/FAR
LOD off camera distance (not chunk-driven — chunks.js has no lifecycle hook).
- **⚠ gotcha (fixed):** `getClock().hour` is a **display string** (`'12:30'`), so `hour/24` → NaN →
0 peds. Use the numeric day fraction `(seg + frac)/6` instead.
- **Verified:** 140 pop → **96 active midday** (density curve; `~7` at night), peds walk footpaths at
~1.5 m/s, deterministic per seed (Lane D property; F passes the same graph+seed). **Budget:** 1 near
placeholder ≈ **11 draws** (11 meshes), bounded by `NEAR_MAX=24`; but a hamlet never fills it —
worst across all 6 main/high edges was **near 3, ~191 composer draws** (budget 300). Coexists with
interiors+keepers (round-trip regression clean); 0 console errors.
- **Note for §3.4:** placeholder near-tier is the draw cost; the **rig-fleet upgrade** (shared meshes +
impostor promotion) is what lets `?pop=` rise well past 140. Until then 140 is the safe lively default.
### 3.4 Manifest + `?noassets` + rig-fleet upgrade ✅ DONE (R3, verified live)
Wired in the shell (all gated by `NOASSETS = ?noassets=1`):
- **Rig fleet:** `loadPedFleet('models/peds/')` (19 char GLBs + walk/idle clips exist locally) → passed
to `new CitizenSim({…,fleet})` and (via `createInteriorMode({…,fleet})`) to `KeeperManager`. Peds +
keeper **upgrade placeholder→shared GLB rigs** once `fleet.whenReady` resolves (verified:
`citizens.mode==='rig'`, `keeperKind==='rig'`, detailed characters on the street + behind counters).
- **Interior GLB:** `preloadManifest()` at boot + `buildInterior(shop,THREE,{useGLB:!NOASSETS})`
Lane C `glb.js` swaps fitting primitives for **depot GLBs** (`depot:<file>` → 3GOD, reachable).
Verified: bookshop fittings load real GLBs (+8 meshes, dense shelves). *Not all kinds map*`counter`
is intentionally primitive; `record_crate` is a known-failing GLB → fail-soft to primitive.
- **`?noassets=1`:** `fleet=null`, manifest not preloaded, `useGLB=false`. Verified **zero network**
(Performance API: 0 GLB, 0 depot, 0 manifest, 0 ped-model requests); 100% primitive, still legible.
- **Leak-free:** rig keeper + interior GLBs dispose clean — the persistent geometry is the intentional
**shared `loadGLB` cache** (cold entry +N geo, re-entries flat: proven cycle2/3 = 189→189). soak.py
pre-warms this cache so the gate is honest.
- **⚠ finding (D/E):** GLB **character models are triangle-heavy** (~45k each near rig) → busiest view
tris ~279k, soak peak ~1.0M, over the **200k** soft budget. Draws are fine (~200/300). **Decimate the
ped GLBs** (and heavy fittings) to hit the tri budget; **F holds `?pop=140`** until then (raising pop
adds near rigs = more tris). See Cross-lane requests §9.
### 3.5 Hours / closed shops ✅ DONE (R3, verified live)
- `currentHour()` parses `lighting.getClock().hour` (`'HH:MM'`→float); `isOpen(shop)` = `open ≤ H < close`.
Both exposed on `window.PROCITY`. **`enterShop` gates on it**: closed → `🔒 CLOSED · opens HH:00`, no build.
- Lane A ships exactly one **`openLate:true`** landmark/town — always the **video rental** (Video Regal
`[11,23]` etc.). Verified: **midday** toy shop `[10,16]` enters; **night (22:00)** toy shop blocked,
**only the video shop opens** (the night destination).
- **Cross-lane (B):** the facade *visual* closed-state (dark windows + CLOSED plate) + a "CLOSED" door
tooltip are Lane B's rendering — `window.PROCITY.isOpen(shop)` is the predicate. See §9.
### 3.5 Hours / closed shops (A data → B facade + C gate) ⏳
- `isOpen(shop, seg)` from `shop.hours:[open,close]` vs the current day segment (map 6 segments →
hour). Closed ⇒ B renders dark windows + CLOSED plate (facade state); enterShop → locked toast.
- Exactly one late-night shop per town (Lane A seeds a shop whose `hours` run late). **F asserts it
exists**: `plan.shops.some(s => s.hours[1] >= 22)` (tune threshold to A's convention).
- **Gate:** at night, ≥1 open shop; closed shops visibly closed + un-enterable.
---
## 4. QA gates — ALL GREEN ✅ (v1.0 criterion met, R4). `tools/qa.sh --strict` is the static umbrella.
Measured against the fully-committed R4 tree (seed 20261990, "Boolarra Heads"), verified live +
by a 6-auditor adversarial v1-readiness workflow (2026-07-14) → **GO**.
| # | gate | criterion | result (R4) |
|---|---|---|---|
| 1 | **Determinism** | seed → identical plan (deep-equal + golden fingerprint) + selfcheck | ✅ scaffold_check green; selfcheck **1301/1301**, fingerprint **0x3fa36874** (intended A2 shift, not drift); two node processes → identical `sha256(plan)` per seed; chunkIndex covers every lot |
| 2 | **Soak** | walk ≥30 chunks, ≥15 shops; geo/tex baseline after each interior; heap stable; **0** console errors | ✅ **37 chunks, 20 shops, heap 38→38 MB, 0 errors, leak-free** |
| 3 | **Budget** | busiest intersection: ≤300 draws, ≤200k tris (settled clean measure) | ✅ **draws 138 · tris ~31k** (baseline 372k; peds decimated 924k→46k ≤3k each, D2 sign-off) |
| 4 | **Asset-free** | `?noassets=1` full playthrough, zero crashes, zero network | ✅ **heap 13→13 MB, 0 errors, 0 asset requests** |
| 5 | **Selfchecks** | `selfcheck.js` + `validate_manifest.py --depot` green via qa.sh | ✅ **qa.sh --strict 4/4**; manifest `--depot`: **23 GLBs live, 0 err** |
| 6 | **Shots** | `docs/shots/v1_tour/` 10 beauty shots + contact sheet | ✅ **10 full-frame 1280×720, no letterbox**, 3 new poses resolve; incl. §3.5 **night closed-facade** (CLOSED plates + lit openLate video) + real **record/opshop interiors** (keeper + hero props) |
> Note: soak peak-draw/tri *warnings* are transient teleport/impostor-atlas-bake sampling spikes;
> gate 3's settled busiest-intersection measure is the authoritative budget check (draws 138 / tris 31k).
### DBG hook — LANDED (Lane B `web/js/world/dbg.js`, `?dbg=1`)
`window.DBG` exposes `ready / shot(name) / teleport(x,z,ry) / setSegment(seg) / enterShop(id) /
exitShop() / info()` and drives `tools/shots.py` + `tools/soak.py` (mirrors 90sDJsim's `window.DBG`).
Bookmarks: street_noon, arcade, market_square, milkbar_dusk, night_neon, crossroads_busy,
residential_collar, warehouse_fringe. **Interior tour shots** (record/opshop) are captured by
`enterShop(id)` directly (not via `shot()`, which forces street mode) — see `tools/shots.py`.
---
## 5. Order of operations once lanes land
1.`node tools/qa/scaffold_check.mjs` — A's plan determinism + registry confirmed present.
2. ✅ Wire §3.1 (plan swap) → generated seed boots on the shell (barrel + skins fix, verified live).
3. ✅ Wire §3.2 (interiors) → door opens, enter/walk/exit + dispose verified across all 9 types.
4. ✅ Wire §3.3 (citizens + keepers) → keeper greets at the counter; peds walk the graph, budget-safe.
5. ✅ Wire §3.4 (manifest + `?noassets` + rig-fleet) and §3.5 (hours) — all verified live (R3).
6. ◑ Gates: 2 & 4 **PASS** (soak/asset-free), 5 **GREEN** (`--depot`), 3 tris-blocked on D/E decimation,
6 shots need Lane B bookmark poses. `docs/shots/v1_tour/` captured.
7.`tools/qa.sh --strict` green + gate 3 tris ⇒ tag v1 (pending D/E ped-GLB decimation).
## 7. Live smoke-test findings (2026-07-14, real generated town, seed 20261990)
Booted the wired game in a browser and drove the camera via `window.PROCITY`. State:
`Boolarra Heads · 523 shops · 711 lots · 31 chunks live · 31 door meshes · 24.7k tris`.
-**Seed → real town** works after the barrel fix (§3.1). Contiguous strip, awnings, verandah
posts, doors, windows, gum trees, sky dome, 6-segment day cycle — all render.
-**Facade skins seam (FIXED).** `skins.js facadeMat` built `assets/gen/facade-<name>.jpg` but
Lane A's `facadeSkin` is the **full filename** (`facade-fibro-blue.jpg`) → every facade requested
`facade-facade-…jpg.jpg` (404 → flat colour). Lane F normalized the name at the `facadeMat`
chokepoint (strip a leading `facade-` + image extension; idempotent for bare keys). Verified:
facade JPEGs now return **200 OK**, town is textured. *Root cause is a cross-lane naming contract
ambiguity — registry stores filenames, skins.js expected bare keys. Worth a CITY_SPEC line so
ground/awning/interior skins don't hit the same trap.*
-**Budget:** ~**334 draw calls** at spawn (radius 3), over the CITY_SPEC ≤300. Tris fine (~25k).
Options for Lane B: drop default radius to 2, or merge more per chunk. Gate 3 will fail until shaved.
-**Interiors not wired** (§3.2), **citizens not wired** (§3.3), **manifest/`?noassets` not wired**
(§3.4) — the remaining integration passes.
## 8. Open questions for the lanes (surface, don't guess)
- **B:** will you expose the `window.DBG` hook (§4) and the `ChunkManager` lifecycle callbacks
(§3.3)? If not, F wires citizens via a MutationObserver-style scan — slower, please prefer callbacks.
- **A:** which field marks the "open late" shop, and what's the `hours` encoding (24h ints? segment idx)?
- **C:** does `places` tag the counter slot with a stable `kind:'counter'` so keepers find it?
- **E:** is `?noassets` honored inside your manifest-consuming code, or does F gate it at the shell?
- **D:** roster spawn/despawn API name + does it accept a chunk key directly?
---
## 9. Cross-lane requests (Lane F → others, R3) — the integration surfaced these; not silent-editing your files
**→ Lane D / Lane E — decimate the ped GLBs (blocks gate 3 tris).** The rig fleet (`web/models/peds/*.glb`)
is wired and works, but the character models are triangle-heavy: **~45k tris each near rig**, so the
busiest-intersection view hits **~279k tris** (soak peak ~1.0M) vs the **200k** CITY_SPEC budget. Draws are
fine (~200/300 — the shared-mesh sharing worked). Please **decimate/LOD the 19 ped GLBs** (target ≤~1015k
tris each) so gate 3 passes; then F can raise `?pop=` past 140. Same applies to any heavy interior fitting GLB.
**→ Lane B — three dbg.js / shell items:**
1. **shots.py bookmark poses (gate 6).** `dbg.js BOOKMARKS` defines `street_noon/arcade/market_square/
milkbar_dusk/night_neon`, but F's v1_tour also uses `warehouse_fringe / residential_collar /
crossroads_busy` — these fall back to `street_noon` (identical frames). Please add those 3 poses.
2. **shots letterbox.** In headless (1280×720), `DBG.shot()`→`composer.render()` renders into a square
sub-region even though renderer/canvas/camera are correctly sized — the EffectComposer render targets
don't rebuild on the initial resize. A real viewport toggle in shots.py didn't fix it; likely the
composer needs an explicit `setSize`/reset on first `shot()`. (In-game, real window sizes render fine.)
3. **§3.5 closed-facade visual.** F gates *enterability* by hours (`window.PROCITY.isOpen(shop)`); the
*visual* closed state — dark windows + a CLOSED plate, and a "CLOSED" door tooltip in `hud.js` — is
your facade/HUD render. Predicate is ready on `window.PROCITY`.
**Note:** F-owned harness bugs found + fixed this round (so the gates actually run): `shots.py`/`soak.py`
polled `window.DBG` before its async install (race → false "absent"); read `DBG.plan` (unexposed → use
`window.PROCITY.plan`); `enterShop({shopId})` vs the bare-id contract; `shop.lot` treated as an object
(it's a lot **id**); soak now pins midday (else §3.5 closes shops mid-walk) + pre-warms the shared GLB
cache (else the one-time upload reads as a leak) + budget peaks demoted to warnings (DBG.info samples spike
during streaming; gate 3's clean single-pass is authoritative).
---
## 10. Round 5 — v1.1 close-out + v2 flag seams (Lane F tracking)
Round 5 (v1.1 polish + v2 foundations behind flags). **v2 prime law:** default boot stays
v1-identical; `GOLDEN.hash 0x3fa36874` must not move; `qa.sh --strict` green at every commit.
### v1.1 tag — ⏳ WATCH (blocked on C1 + E1)
Tag `v1.1` once **Lane C** re-maps `counter_till` + `record_crate` (C1) and **Lane E** bakes the
hero props ≤8k (E1). F then: verify record/milkbar/opshop interiors (GLB counter+till correct, bins
upgraded, baked props look right), re-capture ONLY the 2 interior tour shots if improved, qa green →
`git tag v1.1` (local). *Nothing to tag yet — HEAD `039c179`, no round-5 lane commits.*
### v2 flag seams — table (F wires `web/index.html` + `tools/` as lanes document them)
Every flag **default-off + independent**. Wire only from the owning lane's NOTES call-site spec.
| flag | owner | what | F wiring | state |
|---|---|---|---|---|
| `?plansrc=osm` | A | second plan source (OSM fixture) behind the CityPlan contract | select in shell plan bootstrap | ❌ **A did not run R5** — task carries to R6 |
| `?winmap=1` | B | parallax interior-mapping window shader | none needed (self-contained in `buildings.js`) | ✅ landed `2a923b6` |
| `?dig=1` | C | dig.js crate-riffle on procedural stock | wired by F (`26d68cd`): `DIG_ON``interiorMode`, Esc/pointer-lock guards | ✅ landed `7131f53` + F2 |
| `?roster=stream` | D | chunk-streamed citizen roster | none needed (self-contained in `sim.js`) | ✅ landed `93a3168` |
*(table updated by Fable in the R5 review — F3 harness extensions below did NOT land in R5; they
are the top Lane F item for R6.)*
### v2 QA harness extensions (F3) — build as flags land
- **Determinism gate:** pin `plan-hash` + `first-interior-hash` per `(seed, plansrc)`. Synthetic
stays `0x3fa36874`; add the osm-fixture hash when A lands.
- **Flags-off regression check:** boot no-flags → assert plan hash + a settled draws/tris snapshot
within tolerance of the v1 baseline (catches a v2 flag leaking into the default path). *This is the
enforcement arm of the v2 prime law — build early, before flags land.*
- **v2 smoke:** boot each flag on, walk 2 chunks, enter 1 interior (+1 dig if C landed), 0 console
errors. Wire into `qa.sh` as a **non-strict (warn) gate** this round; strict next round.
### Marshal
Shared-tree arbiter this round: F owns `web/index.html` + `tools/`; if two lanes collide on a file,
F arbitrates. Cross-lane asks → the owning lane's NOTES, not silent edits.
---
## 12. Round 12 — v3.0-alpha gig layer (`?gigs=1`). F wires it last; findings below.
**What F built.** `web/js/world/gig_state.js` (new, F-owned) — the Friday-night state machine; the
`?gigs=1` seam + `custom_bands.json` bootstrap in `index.html`; the cover charge at the door; the band +
crowd + gig-bed wiring in `interior_mode.js`; `smoke_gigs` in `tools/flags_check.py` (6 gates) + the
v3 arm of the flags-off regression.
### The state machine (`gig_state.js`) — the two things that are load-bearing
1. **It listens, it doesn't poll.** `lighting.js` dispatches `procity:segment` on every segment change;
the latch rides that. A poll-only machine sees only the segments it is sampled on — driving
NIGHT→DAWN→MORNING→NIGHT with nothing reading in between (player inside the pub, audio muted, so
neither F's street loop nor B's audio rAF is reading) **skipped the night roll entirely**: the night
never advanced and the cover was never due again. Caught live, fixed, re-verified.
2. **`state` is a getter, not a field.** B's audio engine self-ticks on its own rAF and reads
`PROCITY.gigs.state` while the player is *inside* a shop — i.e. when F's street loop is not running.
A pushed field goes stale there and the spill lies about the gig.
3. **The night rolls on WHERE the clock lands, not on watching `done` go by.** First cut incremented the
night when leaving `'done'` forwards — which meant a clock *jump* straight from NIGHT to MORNING (never
touching DAWN) never rolled the night at all, and **a broke punter walked in on last night's paid
stamp**. `smoke_gigs` caught it; stepping with `[`/`]` always passes through DAWN, so only a jump
(DBG/shots/soak all jump) exposed it. Now: the latch clears wherever it clears, and landing in the
**daytime** (MORNING…ARVO) is what rolls the night; landing on the **doors** means we rewound out of
the gig — same night, stays paid.
`done` is real, not decorative: the segment→hour map tops out at NIGHT (22:00) so the gig owns the whole
night segment, and "after the gig" can only be DAWN (closing time — band packing up, crowd draining per
A's closing-time ruling). Latch clears on any non-DAWN segment so rewinding the clock (`[`) doesn't leave
`done` stuck on a midday town — it did exactly that on the first verify.
### ⚠ Finding 1 (Lane C ↔ Lane E) — the gig bed key never met. **F bridged it; needs a contract line.**
`LANE_C_PUB.md` asks E to name the live bed **`gig-pubrock`** (C emits `room.audio.gigKey = 'gig-' +
genreKey`). E shipped it as **`pubrock-live`** (`gig:true`, `types:['pub']`) — which is also the key B's
street spill hardcodes. Neither is wrong; they just never met. Unresolved, `AUDIO.music['gig-pubrock']`
is `undefined` and **the pub plays to a silent room** — and because the audio law fails soft, nothing
errors: you just get a band miming. F resolves it at the glue (`interior_mode.js GIG_BED` + a generic
`gig-<genre>``<genre>-live` fallback). **This is the R1 facade-skin filename trap again** (registry
stored filenames, `skins.js` expected bare keys). → **C/E/Fable: pick one and put it in CITY_SPEC as a
named contract**, so genre #2 doesn't rediscover this. F's smoke now asserts `gigKey` resolves.
### ⚠ Finding 2 (Lane D + Lane E) — E's instrument GLBs landed; nothing wires them.
E shipped 5 instrument GLBs this round (`electric_guitar`, `bass_guitar`, `drum_kit`, `guitar_amp`,
`mic_stand`, all in `manifest.fittings` with footprints + thumbs). D's band holds **primitives** and
published `spawn(..., { instrumentFor })` as the injection seam — D-progress says E's GLBs "drop in via
`opts.instrumentFor(role)` **when they land**". They have landed. Nobody owns the join. Not blocking
(asset law: a primitive band is still a gig, and it reads fine in the shot), but it is a dangling R12
deliverable. F did **not** do it silently: picking manifest ids and orienting/scaling instruments inside
D's rig is D's call, and the R12 no-drummer question below wants answering in the same pass. → **R13.**
### ⚠ Finding 3 (ROUND12 spec ↔ Lane C) — the band has no drummer.
ROUND12 §Lane D.1 specifies "guitarist/bassist strum-sway, **drummer seated bob**", and E was asked for
"drum kit (one merged kit)". C's `stage.bandPoses` are **guitar / vocal / bass** — a front-line trio; the
drum riser is set-dressing with no pose. D built to C's poses and flagged it. So the shipped alpha band is
3-across-the-front with a mic stand where the spec expected a kit. Cosmetic, non-blocking, deterministic —
but it is a **spec/implementation divergence that no gate can catch**. → **Fable/John: rule for R13** (add
a 4th `drums` pose on the riser, or amend the spec to a front-line trio).
### Gate lesson — "no giants" must measure STATURE, not world crown.
The R10 gate asserts head-bone world Y ∈ [1.4, 2.0]. The band stands on C's **0.32 m stage deck**, so a
perfectly human 1.73 m guitarist has a world crown of **2.05 m** and a naive world-Y check **fails a
correct band** (it did, first run). `smoke_gigs` measures `crown base.y` for the band/crowd and keeps
the world-crown assert only for "never clips the ceiling", which is the real R10 failure mode.
`tools/qa/interior_scale_check.py` is unchanged (keepers/browsers stand on the floor, base.y = 0) — but
**if anyone ever puts a keeper on a platform, that gate inherits this bug.**
### → Lane B (small, R13): no venue bookmark for shots
`dbg.js BOOKMARKS` has no venue pose, and `DBG.shot(name)` forces street mode **and re-poses/re-segments**
— so there is no way to drive a scripted "lit pub frontage at night, gig on" street shot (the alpha's other
money shot; your marquee + posters + spill are the whole street story of this round). F verified the
frontage lights correctly (`emissiveIntensity` 1.7 at `on`, 71 draws / 29.6k tris at the venue) but shot it
by hand. **Ask:** add a `venue_night` bookmark (pose at the pub frontage, `seg:5`) — then F's
`tools/qa/gig_shot.py` can capture the street side too. This is the same "3 missing bookmark poses" ask
from R3 §9, so it may be worth doing the set in one pass.
### Harness note — where the browser gates actually run
This session's box had **no Chromium and no Chrome**, so every Playwright gate (flags harness + scale
gate) is un-runnable locally until `tools/.venv/bin/python -m playwright install chromium`. The repo at
`/Volumes/m3ultra/Documents/procity` is a **mount of m3ultra's `~/Documents/procity`** — same tree, so
`ssh m3ultra@100.89.131.57` runs the real gates on the real files. **Gotcha:** node is not on the
non-interactive ssh PATH — `env PATH=/opt/homebrew/bin:$PATH tools/qa.sh --strict`, or every node gate
reports a bogus FAIL (`node: command not found`).
### R12 numbers (measured, seed 20261990 → "Boolarra Heads", venue = The Thornbury Hotel)
| thing | number |
|---|---|
| gig-night interior, rig fleet | **4250 draws** · ~40k tris (budget **≤350**) |
| gig-night interior, `?noassets=1` placeholders (**worst**) | **171 draws** (budget ≤350) |
| gig-night street at the venue | 69 draws · 29.6k tris (budget 300 / 200k) |
| band / crowd | **3** on the deck · **8** at 8 watch points · **3 dancing / 5 standing** |
| figure scale | band stature 1.681.77 m (crown ≤2.09 m under a 4.0 m ceiling); crowd 1.601.77 m |
| leak, 4 gig enter/exit cycles | geo 66→66 · tex 63→63 (**zero**) |
| tonight's covers (7 nights) | `10, 0, 0, 3, 2, 0, 9` — 3 free of 7 (~half, as seeded) |
| custom band names surfacing | **7 of John's 10** fill all 7 nights; `?noassets=1` → pure generator |
---
## 13. Round 13 — v3.0-beta THE DISTRICT (`?gigs=1`). F wires it last; findings below.
**What F built (all 7 tasks from ROUND13 §Lane F).** All committed Lane-F-only; verified green on m3ultra
(`qa.sh --strict`, the focused district gate, the scale gate). A single WARN — the queue — is a documented
handoff to D, not a hole (below).
1. **Per-venue state machine** (`gig_state.js`, rewritten). One latch PER VENUE keyed by `venueShopId`; a
town of 24 venues runs 24 independent latches off the SAME `procity:segment` spine. All three hard-won
R12 laws are preserved verbatim inside `createVenueLatch` (listens-not-polls · `state` is a getter ·
night rolls on WHERE the clock lands). New runtime surface on `window.PROCITY.gigs`: **`.byVenue`** →
`{ [id]: state }` (B's `venue.js`/`audio.js` read it), per-venue **`stateOf/onOf/openOf/gigOf/coverOf/
bandNameOf/paidOf(id)` + `markPaidOf(id)` + `.venueShopIds`**. The R12 single-venue alias
(`.state/.on/.venueShopId/.cover/.paid/…` → the **primary** venue = `gigs[0]`'s) is kept **one round** so
B's mid-round single-venue callers don't break — it retires next round. Contract pinned in CITY_SPEC §v3.
*Tonight* per venue = its night-0 gig (matches A's posters + B's frontage, all night-0-keyed); a
dark-tonight venue latches 'quiet' all session, which is true to the seeded week.
2. **Walla + applause** (`interior_mode.js`). While a venue's gig is `on`, the crowd bed
`ambience['crowd-walla']` rides the interior TONE layer under the live band bed (you hear the room, not
the fridge hum) — it reuses B's existing `playInterior({musicKey,toneKey})`, no new audio API. On the
`on → done` edge while the player is inside (crowd's last-song → the clock rolls NIGHT→DAWN), a one-shot
`sfx['applause']` sting fires ONCE (`applauded` latch, re-armed per enter). Fail-soft throughout (no-op
muted / `?noassets` / pre-gesture). Verified live: `iMusic=gig-pubrock`, `iTone=crowd-walla`, edge fires,
0 errors.
3. **Cover charge PER VENUE** (`index.html`). The door seam now keys off the shop being ENTERED
(`openOf/coverOf/paidOf/markPaidOf(shopId)`), so a night at two venues is two covers; each latch holds
its own runtime paid-stamp. Verified: paid ($10 debited once, re-entry free), broke-knockback, free night.
4. **Debt #1 CLOSED — and the R12 bridge was a LIVE BUG.** E renamed the pub bed `pubrock-live → gig-pubrock`
this round (the canonical `gig-<genreKey>`), so F's R12 `GIG_BED`/`gigBedKey` bridge — which rewrote
`gig-pubrock → pubrock-live` — was now pointing at a manifest key that **no longer exists**: the band
would have mimed in a silent room. DELETED. The room's `audio.gigKey` (already the manifest key) feeds the
engine directly, zero mapping. Verified across two genres live: `gig-pubrock` (pub), `gig-covers` (rsl).
5. **Gate hardening — stature into the shared gate** (`interior_scale_check.py`, debt #6). The human-size
assert now measures **stature = crown foot** (feet→crown span, platform-independent), with the
world-crown "never clips the ceiling" assert kept as its OWN line. The day a keeper stands on a platform,
the gate won't fail a correct figure. Floor figures foot≈0 ⇒ stature≈crown ⇒ backward-compatible.
Verified: 24 figs across 6 shops, statures 1.601.86 m.
6. **District smokes** (`flags_check.py smoke_gigs`, extended). band 3→**4** · a new **§2b DISTRICT** loop
that enters EVERY playing venue and asserts its `gigKey` resolves to a real manifest bar (debt #1 across 3
genres), per-venue crowd ≤ its own watchPoints, 4-piece, no-giants-by-stature · **§2c street budget** at
the busiest venue block (settled-floor sample) · queue probe (WARN-pending, below) · plus the R12
determinism / cover / noassets / flags-off arms. All green.
### ⚠ Cross-lane ask (Lane F → Lane D) — the QUEUE seam. F's smoke is READY; it WARN-skips until D exposes it.
D owns the outdoor queue (`web/js/citizens/queue.js` `VenueQueue`, charter item, landing this round). B has
already published the zone: **`window.PROCITY.venuePresentation.queueZoneFor(id) → {x,z,ry,len}`** and
`.queueZones`. F drives the surge per venue via `citizens.setGig(id, open)` (done). What F's smoke needs to
verify "queue ≤ cap and drains by done" is a read seam on the sim — **proposed contract:**
`window.PROCITY.citizens.queueInfo(venueShopId) → { count, cap }` (live count while doors/on; `0` once the
gig is `done`). Wire the queue's spawn to `queueZoneFor(id)` at `doors` and expose `queueInfo`, and F's smoke
flips WARN→assert with no further change. (If D picks a different accessor name, tell F and F adjusts the one
line in `smoke_gigs §2b`.)
### R13 numbers (measured, seed 20261990 → "Boolarra Heads", 3 venues)
| thing | number |
|---|---|
| district | **3 venues** — 2 playing tonight (pub #116 The Exchange Hotel · rsl #404), 1 dark (reads true) |
| live beds resolve | pub → `gig-pubrock` · rsl → `gig-covers` (both real manifest bars, debt #1 across genres) |
| band / crowd | **4-piece** each venue · pub crowd **8/8** watch points · **rsl crowd 12/12** (the cap stress case) |
| gig-night interior (rig) | **62 draws** · 34k tris (budget ≤350) · `?noassets` still a full 4-piece gig |
| street @ venue block, night | **99 draws** · 28k tris (budget 300 / 200k) — lit frontage + posters + queue zone in |
| figure scale | band+crowd stature **1.611.81 m**, tallest crown 2.12 m under the 4 m ceiling |
| cover, per venue | $10 debited once · re-entry free · broke knockback · free night (seed 1234) — all hold |
| flags-off prime law | fingerprint **0x3fa36874** unchanged · gig layer wholly absent · 0 errors |
**Status:** F's wiring is done, committed, and green. **Waiting on Lane D** for (a) the queue seam
(`queueInfo`) so the queue smoke goes live, and (b) the band polish (E's instrument GLBs in hand, drummer
facing/RY_FLIP) — the band already lands at 4, human-sized, 0 errors, so this is polish not a blocker. Once D
commits: re-run the gates, flip the queue assert, capture the district money shots, tag **v3.0-beta**.