PROCITY/docs/LANES/LANE_B_NOTES.md
m3ultra f0a0a99207 Lane B (Streetscape): round-12 v3.0-alpha — venue frontage + posters + muffled-gig spill (?gigs=1)
- web/js/world/venue.js (new): createVenuePresentation — renders E's poster skins at plan.posters with band-name overprint (nameZone, seeded template, flat fallback if JPEG missing); lit pub marquee + bulb row over the door, update(gigState) ramps the glow. Constructed by the shell/F under ?gigs (like weather/tram).
- web/js/world/audio.js: muffled-gig spill — near the venue (<=26m) while a gig is at doors/on, pubrock-live plays through a 470Hz lowpass at low distance-gain. Reads window.PROCITY.gigs (F) with a plan.gigs+clock fallback.
- Prime flag law: inert/absent without the gig layer; goldens frozen (no citygen edits, selfcheck 3074/3074); byte-identical flags-off boot.
- Verified live on a gig plan: THE WOMBATTS posters over band-photo art, The Thornbury Hotel lit at night, spill gain ~0.14. F wiring documented in LANE_B_NOTES; streetlamp-pool stretch deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:15:40 +10:00

397 lines
29 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 B — NOTES (measured)
## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + the tram (`?tram=1`)
Two deliverables, both v2-law clean (default boot untouched, golden hash frozen, `qa.sh --strict` GREEN):
### 1. v2 tour bookmark poses (`dbg.js`) — F shoots these
Added **8 bookmarks** to `js/world/dbg.js` (16 total now), all measured un-letterboxed at 113188 draws:
`rain_verandah` (the money shot — open VIDEO shop w/ winmap parallax, CLOSED dark neighbours, night rain),
`patronage_door`, `dig_real`, `katoomba_main`, `tram_stop`, `rain_street`, `night_crowd`, `shopfront_detail`.
Best combo to shoot the hero: `?winmap=1&weather=rain&dbg=1``DBG.shot('rain_verandah')` at night seg 5.
`tram_stop` now picks the **nearest-origin** shelter stop (most open framing — the spine-end stop was
boxed in by big buildings); frames the `bus_shelter` cleanly at midday.
### 2. Tram (`?tram=1`) — new file `js/world/tram.js`, NON-BLOCKING for the tag
One small bus/tram, out-and-back loop along the **main-street spine**, door-dwell (3.5 s) at each
`busShelterStops` stop, ping-pong reverse at the ends. **Deterministic** (position is a pure function of
elapsed time — verified byte-identical across fresh instances). **Exactly 2 draws** (textured body +
merged headlight pair — verified via `renderer.info` delta). Composes with weather/night: headlights
`emissiveIntensity` jumps 0.15→1.6 when `lighting.isNight()`. "TOWN LOOP" canvas livery. `ring()` is a
door-bell stub (audio still parked). Flag-off is byte-identical (shell never constructs it).
#### → Fable (F, shell wiring — I don't touch `index.html`)
```js
import { createTram } from './js/world/tram.js';
const tram = params.get('tram') && params.get('tram') !== '0'
? createTram({ scene, plan, camera, lighting })
: null; // default-off; needs lighting.isNight() for night headlights
// in the street branch of the main loop: if (tram) tram.update(dt);
```
Flags-table row: `?tram=1` — owner B — landed, default-off, self-contained module (F wires the 2 lines
above). Smoke: boot `?tram=1`, assert `scene.getObjectByName('tram')` present + tram-group render delta
≤2 draws. No collision in v0 (player walks through — noted for v3). `lighting.isNight()` is assumed on
the lighting object; if F's lighting exposes night differently, pass a predicate — tram degrades to the
day headlight value if `isNight` is absent (guarded).
## Round 8 (Fable's ROUND8 → Lane B) — weather (`?weather=1`), carried from R7
New file `js/world/weather.js` + a one-line `setSky()` on `lighting.js`. Seeded, deterministic, cheap.
- **Weather state** — `weatherFor(citySeed, day=0)``{ state, intensity }`, weights ~57% clear /
24% overcast / 19% rain (AU coastal-town plausible; verified over 400 seeds). `day=0` today (one
day cycle); a future day-counter rolls new weather. **Seed 20261990 (default town) rolls `clear`**,
so `?weather=1` there is v1-identical — use **`?weather=rain`** (or `overcast`) to force a state for
demos/smokes, or a rainy seed (7, 424242).
- **Rain** — ONE draw: a **camera-following `THREE.Points`** layer with a procedural streak sprite and
a fall+wind vertex shader (no CPU per-particle). Plus **wet ground** (darken + drop roughness on the
shared ground materials, restored on dispose) and a **matching rainy sky dome** (`lighting.setSky` →
summer-storm/monsoon/cold-front). Overcast: greyer dome, no particles. Fully procedural — 0 depot/GLB
fetch (the sky swap is a skin JPEG, same class as the town's own).
- **Budget/composition/determinism**: worst view **141 draws (day rain) / 126 (night)** — ≤300.
Composes with `?winmap` + night — **rain outside a lit interior-mapped window is the money shot**
(`docs/shots/laneB/weather_rain_night_winmap.png`). Deterministic per seed. `?noassets` compatible.
**0 console errors** in all states. **Flag-off is byte-identical** (the shell simply never constructs
weather). `qa.sh --strict` GREEN. *(Deferred, "if cheap": vertex-shader wind sway on trees/awnings —
needs touching furniture's tree material + skins' awning material; noted, not done this round.)*
### `window.PROCITY.weather` contract (Lane D consumes this — decision #1)
Shape: **`{ state: 'clear' | 'overcast' | 'rain', intensity: 0..1 }`**, stable for the session.
`intensity` is 0 for clear, ~0.30.6 overcast, ~0.451.0 rain. Set by `createWeather` when the flag is
on; **F sets `{ state:'clear', intensity:0 }` when off** so D always reads a valid value (below). D:
read it, don't import weather.js.
### → Fable (F, shell wiring — I don't touch `index.html`)
```js
import { createWeather } from './js/world/weather.js';
const wp = params.get('weather');
const weather = (wp && wp !== '0')
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(wp) ? wp : null })
: null; // seeded when ?weather=1; forced when ?weather=rain|overcast|clear
window.PROCITY.weather = weather ? weather.state : { state: 'clear', intensity: 0 };
// in the street branch of the main loop: if (weather) weather.update(dt);
```
Flags-table row: `?weather=1` — owner B — landed, default-off, self-contained module (F wires the 3
lines above). Smoke: boot `?weather=rain`, assert rain Points present + `PROCITY.weather.state==='rain'`,
0 errors.
---
## Round 6 (Fable's ROUND6 → Lane B) — place the orphaned street props
Placed the two published-but-unconsumed street GLBs (`procity_street_bin_01`, `procity_street_bus_shelter_01`)
via the existing use-if-ready furniture GLB path (fail-soft to primitives; `?noassets` → primitives,
**0 network** verified). All in `furniture.js` (+ a 3-line collider merge in `chunks.js`).
- **Bins** — genuinely sparse now (one loop, ~1 per block: `s += 78 m`, seeded side per edge), a country
town not a mall. Decoupled from benches. GLB (3.0k tris) on its 0.57×0.68 footprint, primitive fallback.
- **Bus shelters — on the street graph, and this is the future tram/bus-stop contract.** Deterministic
rule: **the midpoint of every MAIN edge whose `hashEdge(id) % 3 === 0`, on the road-side footpath by the
kerb, long axis along the edge, open front to the road** — yields **13 per town** (2 in seed 20261990).
The GLB (2.33×2.4×1.41, 8.0k tris) gets a **solid oriented-rect collider** so the player rounds it
(furniture now returns `colliders`; `chunks.js` merges them into `getColliders`). Collision-verified: a
straight walk into it is blocked.
- **→ whoever builds the V2 tram/bus loop:** call **`busShelterStops(plan)`** (exported from
`furniture.js`) → `[{ edgeId, x, z, yaw }]`. It re-derives the stops from the exact same rule the
shelters are placed by — pure function of the plan, zero side effects. That is the stop list; don't
re-invent it. To move/space stops, change the one rule + `busShelterStops` together.
- **Perf (measured, worst view WITH the rig-fleet citizens, midday):** **94k tris · 148 draws** — both
well under the ≤200k / ≤300 gates (~100k tri and ~150 draw margin). Determinism holds (seed → same
props). All-flags-on (`?winmap=1&dig=1&roster=stream`) composes clean, 0 console errors. Shot:
`docs/shots/laneB/bus_shelter.png`. `qa.sh --strict` GREEN.
- **→ Lane E (decimation candidate, not blocking):** the `street_bin` GLB is **3.0k tris** — heavy for
a prop placed ~15×; I bounded it with the sparse cadence, but a decimate to ~500 tris would give back
tri headroom if the combined-lane budget tightens. (Same class as the deferred `novelty_record`.)
---
## Round 5 (Fable's ROUND5 → Lane B) — v2: the Vuntra window trick, behind `?winmap=1`
- **Parallax interior-mapping glass** (V2_IDEAS "window trick"). Behind **`?winmap=1`, default-off.**
Single-cube interior mapping in one shared `ShaderMaterial` on the existing window geometry: each
glass fragment casts the view ray into a virtual room box and shades the surface it hits (back wall
+ seeded shelf silhouettes with product blocks, side walls, floor/ceiling) — a plausible room with
real parallax, **zero extra geometry, still 1 window draw/chunk**. Per-shop variation rides
attributes (`aTangent` = window right vector, `aTint` = seeded room colour, `aSeed` = shelf
variation), **not per-shop materials**. Verified live: rooms are visible through the glass with
parallax day + night.
- **§3.5 respected** via the same `uHour/uNight` + `aHours` the closed-facade system uses: at night an
**open shop's room glows warm** ("Video Regal" lit), a **closed shop's room is dark** ("Second Time"
dark) — screenshots in `docs/shots/laneB/winmap_*.png`.
- **v2 prime law held.** `WINMAP` is read straight from the URL in **buildings.js** — **no shell seam,
so F only needs a flags-table row, no `index.html` wiring.** Flag-OFF path is byte-identical to v1:
`windowQuad` collapses to the v1 `hoursQuad` (no extra attributes) and the finalize uses the v1
`windowMaterial()` — verified: flag-off windows are `MeshStandardMaterial` with no `aTangent`;
flag-on are the interior `ShaderMaterial` with `aTangent`. Seeded from `shop.seed` (deterministic).
- **Budget + fetches:** flag-on worst continuous-walk view with citizens **122168 draws** (≤300);
the shader is **100% procedural** — measured **0 fetch delta** vs the `?noassets` baseline (both 22
= the pre-existing facade/ground skin JPEGs). `?noassets=1&winmap=1` adds no network. **0 console
errors** in all modes. `qa.sh --strict` GREEN (4/4); `GOLDEN.hash` untouched (no plan change).
**→ Fable (F2 flags table):** `?winmap=1` — owner Lane B — state landed, default-off — needs **no
index.html wiring** (self-read in buildings.js). Smoke: boot `?winmap=1`, walk a block, glass shows
rooms; night open=warm / closed=dark.
---
## Round 4 (Fable's ROUND4 → Lane B) — done
- **B1 — closed-facade visuals (§3.5, the headline).** Closed shops now render **dark windows + a
red CLOSED plate**; open shops (esp. the one `openLate` **video rental** at night) render **lit
windows**; the door tooltip reads `🔒 <name> — CLOSED · opens HH:00` on aim. **Batched — the facade
atlas is untouched:** per-shop open/closed rides on a per-vertex `aHours` (vec2 = the shop's
`[open,close]`) feeding TWO shared shaders (window emissive gated by *open AND night*; plate alpha
gated by *closed*), driven by ONE pair of shared uniforms (`uHour/uNight`) updated **once per
day-segment change** — no per-shop materials, no per-frame poll. lighting.js dispatches a
`procity:segment` event (numeric hour matching Lane F's `currentHour()`); buildings.js listens and
calls `setFacadeHours`. Verified live: **22:00 → only "Video Regal" lit, every other facade CLOSED**;
midday → all open, no plates; tooltip correct. **Worst view with citizens: 200 total draws** (≤300).
- **B2 — shot harness (gate-6).** (a) **Letterbox "fixed":** the bars were **not** a composer bug (I
proved the composer/renderer/camera are correctly 1280×720 in headless at every stage) — they were
`street_noon`/`arcade`/`warehouse_fringe` poses **jamming the camera against a big building wall**.
Reframed those bookmarks to look *down a street* (`streetViewPose`), so all 10 tour shots now fill
the frame. Also added a defensive `composer.setSize` sync in `DBG.render` per F's request. (b) Added
the **3 missing poses** F listed (`crossroads_busy`, `residential_collar`, `warehouse_fringe`) —
resolved from the live plan (busiest node / house-or-backstreets lot / outer-ring street), always a
valid full-frame pose. `night_neon` now frames the open video shop glowing amid its CLOSED
neighbours (the §3.5 money shot). `tools/shots.py` → 10 un-letterboxed shots, **0 console errors**.
- **B3 — street-furniture GLB upgrade + `novelty_record`.** furniture.js now upgrades primitives to
depot GLBs via a **synchronous use-if-ready** check at chunk-build (no async write into a live
chunk; `?noassets` → primitives, **0 network** verified). Placed the **novelty_record** landmark on
the footpath in front of every record store (and a food_cart by market stalls / milk bars).
**⚠ measured GLB tris:** bench **1.9k** ✓, food_cart **5.0k** ✓, streetlight **5.0k** ✓, but
**novelty_record is 26.5k** (5× the ≤5k `glb_law`) — × many instances it spiked the busiest view to
**574k tris**. So novelty_record stays on its **low-poly primitive** (giant vinyl disc, budget-safe)
until Lane E decimates the GLB — **same treatment the ped rigs need (→ Lane E)**. With that, the
bench GLB verified sitting on its exact footprint (0.44×1.21), tris back to ~25k.
`bash tools/qa.sh --strict` **GREEN** (4/4) after these changes. Round-4 files: `dbg.js`, `buildings.js`,
`lighting.js`, `hud.js`, `furniture.js` (+ these docs, + `docs/shots/laneB_r4/`).
---
Streetscape + game shell. Files: `web/index.html`, `web/js/world/*`, `web/js/world/fixture_plan.js`.
All numbers measured in-browser (Chromium, M-series) via `renderer.info`, 1280×720-ish viewport.
`renderer.info.autoReset` is off and reset once/frame, so **draws/tris are the true per-frame
totals across all passes** (scene + bloom + output). "scene draws" below excludes the ~11
post-processing passes; "total" adds them.
## Perf — fixture (`fixture_plan.js`, "Yarraville Junction", 26 shops, radius 3, shadows on)
| metric | measured | gate | status |
|---|---|---|---|
| draw calls | **148 scene (~159 total)** | ≤ 300 | ✅ |
| triangles | **~5.8k** | ≤ 200k | ✅ |
| skin materials (city-wide, shared) | **21** | ≤ 25 | ✅ |
| live chunks (radius 3) | 12 | — | — |
| chunk build | ~18 ms | 4 ms/frame budget | drained 1/frame |
Walked the fixture end-to-end (~310 m of main street + the backstreets): contiguous, no seams,
chunks stream in/out silently (live count 8↔12 as you move; build queue never starved), day/night
cycles through 6 segments, signs legible, door tooltips + `procity:enterShop` fire, collision
stops the player at wallradius (measured 10.62 m against an 11 m frontage, radius 0.38). Runs with
`web/assets/` renamed away — no crashes, flat-colour fallback town, canvas signs still legible.
## Perf — Lane A integration (`generatePlan`, "Boolarra Heads", ~493 shops, 1 km², radius 2 auto, shadows off)
Lane A's `generatePlan` landed mid-build; `index.html` prefers it over the fixture (guarded import),
so this is the shipped default now. It is a full city with content in nearly every chunk — far
denser than the fixture the ≤300 gate was sized against.
Streetscape numbers below are **buildings + ground + furniture only** (what Lane B owns), measured by
a scripted continuous walk (real streaming + disposal, so ≤25 live chunks — no orphan-chunk inflation).
**Round-3 re-measure WITH Lane D citizens (pop 140, the shipped default).** Driving the real loop
(`chunks.update` + `citizens.update`) along the dense corridors via `window.DBG`, up to **125 active
peds** at midday: worst continuous-walk view **263 scene / 275 total** at **MIDDAY** and **275 total
at NIGHT** — **≤300 everywhere**, ~25 draw margin. Peds are a batched impostor layer (~12 draws for
the whole near tier), so they cost far less than their count. Radius-2 auto is already the spawn
default for a big city; no config change was needed. (Fable's independent integrated read is ~191 on a
typical main-street view.)
| metric | measured | gate | status |
|---|---|---|---|
| draw calls (worst gameplay view, continuous walk) | **~261 total (249 scene)** | ≤ 300 | ✅ ~40 draw margin |
| draw calls (typical street view) | **~130210 total** | ≤ 300 | ✅ |
| triangles | 12k25k | ≤ 200k | ✅ (huge headroom) |
| skin materials (shared, city-wide) | **5** | ≤ 25 | ✅ (facade atlas collapsed ~22 facade mats → 1) |
| live chunks (radius 2, walking) | ≤ 25 | — | — |
| chunk build | min ~1 / median ~8 / max ~22 ms | 4 ms/frame | ~1 heavy chunk/frame |
Integration verified: Lane B streams and renders Lane A's 493-shop city correctly — facades, signs,
doors, ground, furniture, day/night all work. Collision holds for the arbitrary lot orientations
`generatePlan` emits (e.g. `ry = 1.32 rad`); see the collision fix below.
### Adaptive settings (index.html)
`BIG_CITY = plan.shops.length > 120`. Big city → **radius 2 + no sun-shadow pass**; fixture → radius
3 + shadows. Override with `?r=N` / `?shadows=0|1`. Dropping the shadow pass ≈ halves draw calls in
dense areas (the shadow map re-renders every caster) for little visual loss in a flat-lit low-poly
town — LANE_B doc explicitly allows "none".
### Draw-budget close-out (this session) — the gate now holds with margin
Fable's first integrated smoke test read **~334 draws at spawn** (that was radius 3, pre-adaptive) and
the deep districts appeared to peak ~445. Two things closed the gap:
1. **Facade texture atlas** (`skins.js` + `buildings.js`). Every facade skin packs into one 2048²
atlas (6×6 slots, each pre-painted with the skin's flat fallback colour, then the JPEG drawn over
it — house law preserved), so a chunk's facades merge into **one** mesh (was ~58 per-skin meshes)
and ~22 facade materials collapse to **1** (this also fixes the "≤25 materials was at the cap /
over for some seeds" concern — now **5** city-wide). UV convention mirrors the sign atlas.
2. **Instanced-mesh merges.** Awnings were one InstancedMesh *per skin* (red/green/blue) per chunk;
now **one per chunk** with per-instance colour (the stripe JPEG lived on the awning's unseen top
face, so flat colour reads the same under the verandah). Shells + parapets + posts merged into
**one** InstancedMesh (per-instance colour) instead of two.
Measured effect (worst dense frustum): **393 → 265 scene draws**; worst *continuous-walk* gameplay
view **~261 total**. Note the earlier "445" figures were partly a **measurement artifact** — teleport-
jumping between distant spots left orphan chunks the real streamer disposes; a proper walk never
exceeds the radius-2 set. Verified day + night, facades still textured, awning colours still varied.
## Architecture decisions worth knowing (for Lane F)
- **Ground is built once, not streamed.** The street graph is a handful of edges; merged flat quads
are a few draws total and can't seam. If Lane A ships a many-hundred-edge network, revisit.
- **Sky/lighting are global**, re-centred on the player each frame (dome radius 650, sun follows).
Linear fog at `(radius+0.4)·64 m` doubles as the chunk pop-in mask.
- **Per-chunk streaming = buildings + furniture only.** `ChunkManager` keeps chunks within Chebyshev
radius R, disposes past R+1, drains a nearest-first build queue under a 4 ms/frame budget. A single
dense chunk can exceed 4 ms (median 8), so it builds ~1/frame → an occasional ~822 ms build frame
entering a dense district (fog-masked). Splitting a chunk build across frames is the next step if
that hitch matters.
- **Night lighting is faked** (Vuntra-style): window + streetlamp **emissive** toggled by a night
flag, plus bloom — no per-streetlight real lights. `ChunkManager.setNight()` re-applies to chunks
streamed in while already night.
- **`scene.environment` is set** (round 3, `lighting.attachRenderer`): a neutral PMREM'd sky→ground
gradient (no asset). Lane D's rig-fleet peds bake their impostor atlas from `scene.environment`
(`sim.js`) and go dark if it's null; set here *before* `CitizenSim` constructs. One env for all
segments (the atlas bakes once; the mostly-non-metal streetscape is barely affected). Verified set
in street/night/interior + after interior-return (`interior_mode` only swaps `scene.background`).
- **Shared materials, city-wide** (`skins.js`). Facades share **one atlas material** (all skins in a
2048² atlas); shells + parapets + posts are **one** InstancedMesh/chunk (per-instance colour); all
awning/canopy skins are **one** InstancedMesh/chunk (per-instance colour). Signs are a per-chunk
canvas atlas sized to the chunk's actual sign count, mipmaps off. Net: ~1 draw per kind per chunk.
- **Orientation law** (obeyed by `buildings.toWorld`, the InstancedMesh shells, and `pushOutRect`):
a lot at `ry=0` faces `+Z`; `local→world = RotY(ry)`. Collider and geometry share this convention.
## Fixes applied after an adversarial multi-agent review (this session)
- **Collision for arbitrary `ry`** (`planutil.pushOutRect`): world→local used the wrong rotation sign;
correct only for axis-aligned `ry`. The fixture is axis-aligned so it passed, but Lane A's angled
lots would let you walk through walls. Fixed to the true inverse of `toWorld`. **(Critical for Lane A.)**
- **House doors** were merged into the interactive door mesh with no `doorRects` → raycasting a house
door mis-resolved to the nearest shop. Now a separate non-picked mesh.
- **Awning skin index** used a signed shift (`seed >> 3`) → negative index → undefined skin for ~⅓ of
shops. Now `>>> 3`.
- **Sign atlas** was a fixed 2048² (~16 MB canvas + ~22 MB GPU each) per shop-chunk regardless of
sign count. Now sized to the actual sign count, mipmaps off — critical at Lane A's density.
- **Furniture edge hashing** assumed string edge ids; `generatePlan` uses numeric ids → every edge
got the same seed. Coerced with `String(id)`.
- **Furniture drop:** `chunkIndex` rasterised only the edge centreline, so furniture at a perpendicular
offset could land in an unbuilt chunk and vanish. Rasterisation now covers the full road+verge band.
- **`takeShots()`** never restored the pre-shot mode (P in map mode dumped you into street).
Reviewers' non-issues (intentional): `getColliders` returns a shared scratch array (per-frame
alloc avoidance); per-frame `{clock,chunks}` HUD object (negligible).
## The `shot()` harness
Press **P** → renders 3 fixed cameras and downloads PNGs. Reference stills captured to
`docs/shots/laneB/` (main-street day, plaza overview, night strip). Note: at Lane A's city the fixed
camera poses are tuned for the fixture; re-aim if used against the generated city.
## `window.DBG` — the QA harness hook (this session, for Lane F's shots.py/soak.py)
Loaded only with **`?dbg=1`** (`js/world/dbg.js`, installed by the shell). Answers LANE_F_NOTES §4.
All poses are derived from the **live plan** so bookmarks work on any seed; the day cycle pauses on
the first scripted segment change for deterministic captures.
- `DBG.ready` — true once the first chunks are built + the queue is drained (textures may still decode).
- `DBG.shot(name)` — snaps to a named bookmark, sets its time-of-day, settles bloom, returns stats.
Bookmarks: `street_noon`, `arcade` (dept anchor), `market_square` (stalls), `milkbar_dusk`, `night_neon`.
- `DBG.teleport(x, z, ry)` / `DBG.setSegment(seg 05)` — drive the soak walk + time of day.
- `DBG.enterShop(shopId)` / `DBG.exitShop()` — scripted interior visits (drives Lane F's bridge →
Lane C `buildInterior`; verified end-to-end: `enterShop` → mode `interior`, `exitShop` → back to street).
- `DBG.info()``{ drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode }` (budget/soak read this).
Every bookmark stays under budget (busiest, the dept anchor, ~269 draws). `fps` reads the HUD's
smoothed value (0 under a throttled/background tab; populates in a foreground Playwright run).
## `ChunkManager` lifecycle hooks (optional, LANE_F_NOTES §8)
`createChunkManager` now fires `ctx.onChunkBuilt(key, {cx, cz, buildings, furniture, data})` and
`ctx.onChunkDisposed(key, {cx, cz})` **if a consumer sets them on `ctx`** — a clean per-chunk seam
for spawning/ambient/LOD. Inert by default (the shell doesn't set them; **Lane D's citizens drive off
`plan.streets` instead of chunk lifecycle**, so this is a spare hook, not a dependency).
## WebAudio engine — `audio.js` (round 11, the audio round)
`web/js/world/audio.js` — one `AudioContext`, unlocked on the first user gesture. Self-contained: it
reads live state off `window.PROCITY` and self-ticks (its own rAF), so the shell only calls
`createAudioEngine(window.PROCITY, { noassets })` once (after `window.PROCITY` is assigned) and stores
it at `window.PROCITY.audio`. Consumes Lane E's `manifest.audio` (self-fetched, **3× retry** so a
dropped boot-burst fetch doesn't mute the session).
Layers, all crossfaded off systems that already exist (gains ramp; the update path allocates nothing):
- **Ambience** — `street-day` / `street-night` beds crossfade on `lighting.getClock().night`; **rain**
bed gain follows `PROCITY.weather.intensity` (silent unless it's actually raining, i.e. `?weather`).
- **Footsteps** — distance-accumulated from `player.position` while pointer-locked (a step per stride).
- **Shop-door spill** — nearest *open* music shop (record/milkbar/video/arcade/dept) within ~9 m leaks
its interior music bed at low distance-gain (scan throttled to 5 Hz over live door meshes; honours
`PROCITY.isOpen`). Verified: 6 m from "Video Barn" → `video-synth` at gain ~0.08.
- **Tram** — rumble gain by camera↔tram distance (`scene.getObjectByName('tram')`); bell rings once as
it settles at a nearby stop. Conditional on `?tram`.
- **SFX** — `procity:enterShop` → doorbell + door-open; `playSfx(key)` for the rest (till/riffle/toast).
**House audio law — all verified in-browser:** silent-and-happy (missing/failed audio → silence, no
errors); **nothing plays or fetches before the first gesture**; `?mute=1` → a silent surface (exists
for smokes, never unlocks, 0 fetches); `?noassets=1` → live but **0 audio/manifest fetches**; beds
lazy-load (≤25 MB pack, 8.7 MB shipped); spill/music selection is a pure function of shop type.
### Surface (`window.PROCITY.audio`, for Lane F's smokes + interior wiring)
- `setMasterGain(0..1)`, `mute()`, `unmute()`, `get ready`, `get muted`.
- `playSfx(key, {gain})`, `footstep(surface)`.
- **`playInterior({ musicKey, toneKey })` / `stopInterior()`** — Lane F calls these from
`interior_mode` on enter/exit, passing **Lane C's `room.audio` contract** (a `musicKey` into
`manifest.music`, a `toneKey` into `manifest.ambience`). The street beds auto-duck to 0 while
`mode !== 'street'`. Confirmed working: `playInterior` fades the room's music+tone in, `stopInterior`
fades them out. **→ Lane C: `room.audio = { musicKey, toneKey }` on `buildInterior` is the right shape.**
- `state``{ ready, muted, mode, manifest, loaded, layers, nearestSpill }` diagnostics for smokes.
## Venue / gigs — the pub from the street (round 12, v3.0-alpha, `?gigs=1`)
Two B-owned pieces for the one-pub vertical slice. **Prime flag law:** both are inert/absent without
the gig layer, so flags-off boot is byte-identical (goldens untouched — no citygen edits).
- **`web/js/world/venue.js`** (new, like weather.js/tram.js — the **shell/Lane F constructs it under
`?gigs`**). `createVenuePresentation(plan, skins, scene) → { group, frontage, update(gigState),
dispose, venueShopId, posterCount }`.
- **Posters** — renders E's `skins.poster.{grunge,retro,screenprint,xerox}` at every `plan.posters`
position `(x,z,ry)` with the gig's `bandName` overprinted in the skin's `nameZone` (canvas
composite, like a shop sign). Seeded template per poster. Flat printed-poster fallback if the JPEG
404s (house law). Verified: "THE WOMBATTS" over the band-photo art. *(Note to A: main-street poster
positions land mid-road on intersection nodes — a verge/pole offset would seat them better.)*
- **Lit frontage** — a warm marquee slab + emissive bulb row over the venue door; `update(gigState)`
ramps the glow (`'on'`→full, `'doors'`→warm-up, else dark). F drives it from the gig state machine.
- **Muffled-gig spill** (in `audio.js`, already shell-wired) — extends the R11 door-spill: near the
venue (≤26 m) while a gig is at `doors`/`on`, the `pubrock-live` bed plays through a **470 Hz
lowpass** (thump through the bricks) at low distance-gain. Gig state = `window.PROCITY.gigs` (Lane F)
with a clock+`plan.gigs` fallback so it works before F lands. Inert without the gig layer.
### For Lane F (wiring this into the shell)
```js
// under ?gigs (plan already has gigs/posters/venue via generatePlanFor(seed, src, {gigs:true, customBands})):
const venue = createVenuePresentation(plan, skins, scene); // posters + frontage
// in the street loop, drive the frontage from your state machine:
venue.update(PROCITY.gigs.state); // 'quiet'|'doors'|'on'|'done'
```
The muffled spill needs **no wiring**`audio.js` reads `plan.gigs` + `window.PROCITY.gigs` itself.
Set `window.PROCITY.gigs = { state }` (or `{ on: bool }`) and B's spill + fallback both follow it.
**Stretch deferred:** streetlamp-pool night-readability on the venue block (touches shared furniture
rendering + wants a budget-safe scoped decal) — flagged as the next polish, not landed this round.
## Controls
WASD move · shift run · mouse look · click a door · `[` `]` step time-of-day · `T` pause clock ·
`M` map · `P` screenshot · Esc release pointer.