PROCITY/docs/LANES/LANE_B_NOTES.md
m3ultra 94e56ed5fa Lane B round 20 (v4.0-beta): the tram ruling — route by shop adjacency + per-town fence (ledger #3/#4)
Verified fresh Chromium across 9 town configs (synthetic, 3 fixtures, 5 real).

TRAM RULING (ledger #3): spinePolyline now walks from each main-subgraph dead-end, greedily taking the
unused main edge fronting the most shops (lot.frontEdge), keeping the best-scoring path (ties -> longer).
SYNTHETIC + FIXTURES BYTE-IDENTICAL by construction AND measurement (a synthetic main node has <=2 mains so
there's never a choice; its two dead-ends tie exactly and strict > keeps the pre-R20 start). Old vs new
logic on the same plan = identical polylines (Boolarra: 7 pts, 6 edges, 810m, 172 shops, 2 stops).

The alpha tram fronted 0 shops on 4 of 5 real towns (it was a highway bus). New: katoomba 0->3, newtown
0->8, fremantle 0->2, bendigo 0->1, castlemaine 1->6. Where the best main chain still fronts <5 shops the
tram is FENCED per-town (Fable's fallback). RUNS: synthetic, all fixtures, newtown (8 shops/1924m/16 stops),
castlemaine (6/2184m/10 stops). FENCED: katoomba (3), fremantle (2), bendigo (1) -- highway, not high street.
The fence keys off the REAL-ROADS signal (edges>200), NEVER shop count alone: the marched fixtures front 0
shops on their mains (bare spine, avenues carry shops) so a naive fence would have killed their v2 tram.
routeInfo{fenced,reason,shopsFronted,routeMetres} exposed for F's smoke.

BUG FOUND+FIXED: the stop projection accepted every shelter in the town with no distance gate -- harmless on
synthetic (2 shelters on the spine), nonsense on real graphs: Newtown made 149 phantom stops (~9 min of dwell
at points the tram never passes). Added a 30m gate (a shelter sits halfRoad+1.7 <=~16m off its centreline, so
genuine stops survive): newtown 149->16, castlemaine 37->10; synthetic 2 / fixtures 3 unchanged.

GROUND POLISH (ledger #4): measured, NO fix needed. Real Katoomba has 325 deg-3+ junctions, 28 under 30 deg,
worst 4.3 deg (near-parallel). At grazing eye level the worst reads clean -- no z-fight, no gaps. Layers are
already y-separated (road 0.0/foot 0.02/kerb 0.06) and road-on-road overlap is invisible by design (OVERLAP=1.5
overruns nodes on purpose; one skin, one y, one merged draw on uniform asphalt). Cosmetic only: footpath/kerb
double up on the sliver between <10deg near-parallel roads -- v4.0 candidate, not a defect.

Selector polish (ledger #5): BLOCKED -- E's towns/index.json not landed (build_towns.py WIP); REAL_TOWNS
hardcode stands, one-line swap when E lands.

-> A (finding, measured): only a slice of shops front a MAIN edge on real towns -- katoomba 7/69, fremantle
   4/79, bendigo 7/35, newtown 32/64, castlemaine 11/22. The real high street classifies as 'side', which is
   WHY three towns need the fence. If the main/side classifier promoted shop-dense ways to main, the fence
   would lift. Note onMain also gates gigs.js pickVenues pub placement.
-> F: tram verdict above; selector gating (HUD vs ?dbg) is your call, one line either way.

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

63 KiB
Raw Blame History

LANE B — NOTES (measured)

Round 20 (Fable's ROUND20 → Lane B) — the tram ruling + ground polish (ledger #3/#4, v4.0-beta)

1. The tram ruling (ledger #3) — route by SHOP ADJACENCY, fence per-town

tram.js spinePolyline now walks from each dead-end of the main subgraph, greedily taking the unused main edge that fronts the most shops (lot.frontEdge), and keeps the best-scoring path (ties → longer). Component logic isn't needed — a walk can't leave its component.

Synthetic + fixtures are byte-identical, by construction and by measurement. A synthetic main node has ≤2 mains so there's never a choice, and its two dead-ends tie exactly ⇒ strict > keeps the pre-R20 start ⇒ the same route. Verified by running the old and new logic against the same plan: identical polylines (Boolarra Heads: 7 points, 6 edges, 810 m, 172 shops fronted, 2 stops — unchanged).

town shops shops fronting mains OLD route NEW route stops tram
synthetic 493 172 172 172 (810 m) 2 RUNS (unchanged)
fixture melbourne / katoomba 95 / 19 0 / 0 0 0 (378 m) 3 RUNS (unchanged)
fixture silverton 12 — (0 mains) 0 no-op (unchanged)
REAL newtown 64 32 0 8 (1924 m) 16 RUNS
REAL castlemaine 22 11 1 6 (2184 m) 10 RUNS
REAL katoomba 69 7 0 3 (3784 m) FENCED
REAL fremantle 79 4 0 2 (3054 m) FENCED
REAL bendigo 35 7 0 1 (4376 m) FENCED

The alpha tram fronted 0 shops on 4 of 5 real towns — it really was a highway bus. Shop-adjacency improves every town, but on katoomba/fremantle/bendigo the best main chain still fronts <5 shops, so per Fable's fallback the tram is fenced per-town.

The fence keys off the REAL-ROADS signal (edges > 200), never shop count alone — because the marched fixtures legitimately front 0 shops on their mains (the marched spine is bare; the avenues carry the shops), so a naive shop-count fence would have killed their v2 tram. Synthetic (22 edges) and fixtures (15) are an order of magnitude under the threshold and can never be fenced. Threshold: shopsFronted < 5.

2. Bug found + fixed en route: phantom tram stops on real graphs

The stop projection mapped every shelter in the town onto the route with no distance gate — harmless on synthetic (2 shelters, both on the spine), nonsense on a real graph: Newtown produced 149 stops (~9 min of pure dwell at points the tram never passes). Added a 30 m gate (a shelter sits halfRoad+1.7 ≈ ≤16 m off its own centreline, so every genuine stop survives): newtown 149 → 16, castlemaine 37 → 10; synthetic 2 and fixtures 3 unchanged.

3. Ground polish at real intersections (ledger #4) — measured, NO fix needed

Real Katoomba: 325 degree-3+ junctions, 28 under 30°, worst 4.3° (near-parallel). Viewed at grazing eye level (the worst case for z-fighting) the worst junction reads clean — no z-fight, no gaps: continuous tarmac, correct footpath/kerb. Why it holds: the layers are already y-separated (road 0.0 / footpath 0.02 / kerb 0.06) so nothing co-plane-fights across layers, and road-on-road overlap at junctions is invisible by designOVERLAP = 1.5 makes strips overrun their nodes on purpose, and they share one skin, one y and one merged draw, so a UV mismatch can't read on uniform asphalt.

  • Cosmetic only (v4.0 candidate, not a defect): at ultra-acute (<10°) junctions the footpath/kerb strips of the two near-parallel roads double up on the sliver between them. Reads busy, not broken.

4. Selector polish (ledger #5) — BLOCKED on E

E's web/assets/towns/index.json hasn't landed (build_towns.py still WIP), so REAL_TOWNS stays hardcoded this round. It's a one-line swap to fetch('assets/towns/index.json') when E lands.

→ Lane F (B→F tram verdict + selector): tram runs on synthetic, all fixtures, newtown and castlemaine; fenced on katoomba, fremantle, bendigo (routeInfo.fenced + reason are exposed for your smoke). Selector gating (always-on HUD vs ?dbg) is your call — one line either way.

→ Lane A (finding, with measurements): only a small slice of shops front a main edge on real towns — katoomba 7/69, fremantle 4/79, bendigo 7/35, newtown 32/64, castlemaine 11/22. The real high street is coming out classified side, which is why the tram must be fenced on three towns. If your main/side classifier promoted shop-dense ways to main, the fence would lift and the tram would find the actual high street. Worth a look beyond the tram: onMain also gates gigs.js pickVenues pub placement.


Round 19 (Fable's ROUND19 → Lane B) — the town selector (ledger #4, beta pull-forward, non-blocking)

Shipped the in-game town picker; verified fresh Chromium. No index.html seam — it lives in hud.js, which already owns its DOM overlay.

hud.js town selector

A compact <select> in the HUD overlay (top-left, styled to match, pointer-events:auto so it's usable when the pointer is free — Esc first). 9 options: ▸ Synthetic — procedural + the 3 OSM fixtures (osmTownKeys() minus _real: Melbourne/Katoomba/Silverton) + the 5 real-road caches (Katoomba, Newtown, Fremantle, Bendigo, Castlemaine — hardcoded REAL_TOWNS, since there's no towns manifest yet; beta derives it from an index). Selecting navigates: synthetic → clears plansrc/town; a town → ?plansrc=osm&town=<key>, preserving all other params (seed, flags). Current town pre-selected; an unknown current town is added so it still shows.

  • Verified end-to-end: picked katoomba_real through the dropdown → it navigated + booted real Katoomba (966 edges / 323 intersections / 3 venues), selector then reads "Katoomba · real roads". 0 page errors.
  • Lives inside #pc-hud, so hud.setVisible(false) hides it for F's money shots (no extra work).

→ Lane F (B→F "selector seam"): there is NO seam — the selector is self-contained in hud.js (already constructed by createHUD). Nothing to wire in index.html. If you want it gated (e.g. ?dbg only) that's a one-line setVisible/guard call, your choice; I left it always-in-HUD as a modest debug-grade feature.

Corner-strips spot-check (R18 failure-item #3) — ground fills real intersections OK

Overhead look at a degree-4 real intersection (58 exist on the current graph): the merged road strips overlap-fill the junction (carriageway continuous, lane markings + zebra crossing render, no gross gap). Acceptable for the alpha; a finer acute-angle / z-fight pass is a beta detail. ground.js is road-agnostic enough for real topology.

Budget note → F (measured against A's UNCOMMITTED WIP — caveat, not a final finding)

F's BIG_CITY one-liner (e6fcb7b) is live and working — real Katoomba now streams big-city (radius 2 + shadows off), chunks 50 → 26. But against the current working tree the venue block is still ~237k tris at night — and that tree carries A's uncommitted R19 finalization WIP (plan_osm.js modified; the graph grew 799 → 966 edges / 323 intersections = junction-joining in, but the far-island CULL not yet applied). Fable's ruling calls the cull "half of B's tris problem", so the ≤200k gate needs F's fix AND A's committed cull together — re-measure the busiest real block after A pins (that's the deferred "tris post-cull" re-measure; it genuinely waits on A's commit, per the R18/C lesson that the dev server serves other lanes' uncommitted edits). Not filing 237k as a defect — it's mid-finalization.

The uncommitted docs/shots/v4_alpha/katoomba_mainstreet.png

Present in the tree since before this R19 session (not a deliberate save of mine — my audit screenshots are browser-tool captures, uncommitted). Likely a prior B-audit capture. F should reshoot deterministically via the tour harness for the official money shot regardless; treat its provenance as unverified.


Round 18 (Fable's ROUND18 → Lane B) — irregular-geometry audit (ledger #4, v4.0-alpha REAL ROADS)

The deliverable is the failure list. Audited katoomba_real on E's real roads (schema v2, 484 OSM ways → a 799-edge / 872-node graph, 126 real intersections (99 deg-3, 27 deg-4), edges 3.5 m1303 m (28 short <12 m), span ~3945 × 4944 m). E's roads cache landed mid-audit; verified fresh Chromium, cache-busted (the shell force-caches town JSON).

Verdict: Lane B's streetscape is ROAD-AGNOSTIC — it consumes the CityPlan graph generically and survives

Real Katoomba boots with 0 page errors; every B system builds and renders on the irregular graph — ground strips, furniture (39 chunks / 148 trees), town-wide pools, tram, chunk streaming (chunks.count scales), buildings, wind. The real main street reads well (road + footpath + streetlamps + a working gig poster + citizens; docs/shots — the Screaming Utes bill renders on real bones). Like C's interiors, the streetscape needed no code change to eat real geometry. The real risks are elsewhere (below).

FINDING → F (gate-blocking, proven one-line fix): the BIG_CITY adaptive heuristic misfires on real roads

index.html sets BIG_CITY = plan.shops.length > 120 → radius + shadow-pass. Real-roads towns invert the assumption: Katoomba has 20 shops (< 120) but a 799-edge / 5 km graph, so it stays small-town mode (radius 3 + sun-shadow ON) and streams 5052 chunks → the busiest venue block hits ~280k tris (clean load, settled) — BREACHES the 200k gate (draws 150, still < 300). Proven fix — boot with ?r=2&shadows=0 (= what BIG_CITY mode would give): venue block drops to 84k tris day / 104k night, 20 chunks, 72 draws — comfortably under. Recommend: extend the heuristic to the graph, e.g. BIG_CITY = plan.shops.length > 120 || (plan.streets?.edges?.length||0) > 200 (catches Katoomba's 799; leaves synthetic Boolarra's 22 untouched). One line, index.html; the concept is B's adaptive-streaming (LANE_B_NOTES R3) extended from shop-count to edge-count. This is the charter's mega-strip/density risk in Lane B's budget.

Tram (spine assumption — Fable's flagged risk): degrades gracefully, no crash — fence-ready, not fenced

spinePolyline walks one main chain from a degree-1 node. On real Katoomba (232 main edges) it runs a 3527 m / 49-edge chain = 21% of mains (the longest walkable chain — plausibly the real main road); the other mains form other chains with no tram. No crash, deterministic, ≤2 draws as ever. Arbitrary- but-functional for the alpha. A smarter route (pick the shop-adjacent chain) or a hard fence (tram off on real-roads towns) is a v4.0-beta call — no clean plan.mode marker is exposed to key a fence off, so I did not add untested fencing this round (alpha philosophy: surface + document). Fence-ready via the same edges.length > 200 signal if beta wants it.

Furniture / pools scale to real geometry (no break)

Town-wide pools = ~4989 instances on Katoomba (all in the ONE InstancedMesh → still ~1 draw; built upfront, fogged beyond the streaming radius). Lamp cadence = ~4906 town-wide. 28 short edges (<12 m) get 0 lamps (the for(s=8; s<len-4; …) loop — graceful sparsity, not a crash). No fix needed.

Correction — the "disconnected graph" I first saw was a SYNTHETIC-FIXTURE artifact, NOT real Katoomba

Stress-testing A's selfcheck roads fixture first (before E's real cache landed) produced a fully disconnected graph (4 edges / 8 nodes / all deg-1 / 0 intersections) — DouglasPeucker flattened its near-collinear coordinates and dropped the junction vertices. Real Katoomba does NOT have this (126 intersections form correctly). So it's a fixture-degeneracy note, not a real-town finding — but worth flagging to A that DP can drop junctions on near-straight ways (charter risk #4, fidelity).

For F's alpha gate (assertion list, all green on real Katoomba except the budget finding)

boots 0-error · graph has real intersections (deg ≥ 3 present) · my modules all build · budget needs the BIG_CITY fix (else 280k > 200k at the venue block; with it, 104k) · tram no-crash · pools ~1 draw.


Round 17 (Fable's ROUND17 → Lane B) — wind sway SHIPS (ledger #5, parked since R7)

Verdict: SHIPPED, not killed. The R7 park reason ("touches shared materials") was tested against R16's measurement-over-brief standard and is a non-blocker: the gum-tree billboard material lives in furniture.js (Lane B), so wind sway is entirely Lane B — no C/E material seam. New file web/js/world/wind.js + two small hookups (furniture.js applies it, weather.js drives it).

What ships

  • wind.js — one shared uniform set (uWindTime, uWindAmp) + applyWind(material) (an onBeforeCompile vertex patch: top-weighted horizontal sway, per-instance phase from world position so neighbours desync, #ifdef USE_INSTANCING-guarded) + updateWind(dt, intensity).
  • Trees only. Gum-tree canopies sway. Awnings are excluded — a measured call, not a punt: they're rigid posted-verandah box slabs (buildings.js), and a structural verandah roof doesn't flutter. (Fabric-canopy ripple would be a separate v3.3 item — the striped canopies share the rigid awning InstancedMesh today, so splitting them is its own task. Not needed for "wind sway ships.")
  • Weather-driven: updateWind is called only from weather.update(dt) → base breeze on any weather-on boot (amp 0.05, clear included) scaling to rain gusts (0.05 + intensity·0.30 ≈ 0.35).

Byte-identical when weather is off (the covenant)

updateWind runs only inside weather.update, and the weather system is not constructed under ?weather=0 or ?classic=1 — so uWindAmp stays at its init 0, and the injected displacement is sin(...)·0.0·h == 0.0transformed += 0.0 → identical vertices → identical pixels. Verified:

  • amp=0, two different uWindTime values → 0 / 180,000 region pixels differ.
  • ?weather=0 boot → uWindAmp 0, advancing time → 0 tree-motion pixels.
  • ?classic=1 boot → uWindAmp 0, trees render, 0 console errors (shader patched but inert).
  • No plan change, no fetch, no rng (time-based, not seeded) — F's classic gate (0x3fa36874 + fetch delta) is untouched; the sway is pure render.

Sway works + budget

  • Realistic rain amp 0.305: advancing 0.8 s moves ~14,100 / 180,000 region pixels (clear motion); exaggerated amp 2.5 moves 101k. Trees render normally with the shader (verified the side-street verge — natural canopies, no glitch/explosion).
  • ~0 draws (a handful of ALU ops in the tree InstancedMesh's existing vertex program — no new geometry, no new draw). 0 console errors (GLSL compiles clean).

→ Lane F (B→F handshake): SWAY SHIPPED. It's in old frames now under the default boot (weather-on). ?classic=1 / ?weather=0 are sway-free and byte-identical (proven above). README ?weather note, if you grow one, can mention "trees sway with the wind." No new asset (procedural), so E has nothing to do.


Round 16 (Fable's ROUND16 → Lane B) — v3.1 THE FLIP: town-wide pools + default-boot re-baseline

Two deliverables. Verified fresh Chromium, seed 20261990. (Ran before F's flip landed — my pools are gig-gated so they work under ?gigs=1 now, and the re-baseline uses explicit all-four-flags URLs = the post-flip default-boot content; F re-confirms against the real default at close.)

1. Town-wide streetlamp pools (ledger #4) — venue.js

Extended the R13 instanced-pool pass from venue frontages to every streetlight + bus shelter. New townPoolSpecs(plan) replicates furniture.js's exact lamp cadence off the plan's street graph (a lamp every 18 m from s=8, alternating sides, at off = halfRoad+0.7) and reuses the exported busShelterStops(plan)read-only, furniture.js untouched (imported the one function). All pools (venue + town) ride the one existing InstancedMesh, so town-wide is still ~1 draw:

  • 381 pool instances / 1 draw / ~760 tris for Boolarra Heads (22 edges, 27 nodes). Night-driven via the existing opacity ramp; fog: true now (was false) so distant pools fade into the night fog instead of floating as dots over un-built chunks (pools aren't chunk-streamed). A/B'd fog:false — no dramatic difference and a dot-risk on bigger towns, so fog:true is the keeper.
  • Gig-gated → classic parity by construction: venue.js only exists under the gig layer, so ?classic=1 (gigs off) is pool-free = v2 parity. No always-on furniture.js change (the v3.0 blocker).
  • Pure function of the plan (deterministic cadence, no rng) → no new seeded draws (release law).

2. Default-boot budget re-baseline (ledger #4) — the number the budget law now means

Measured on the default boot content (gigs + weather=rain worst-case + winmap + tram all live), settled (shoot-twice / clean-load to defeat the orphan-chunk teleport inflation):

view (default boot, all flags) draws (composer total) tris gate
busiest venue block (pub, NIGHT, rain, 3/4) ~157 scene / ~170 total 66k ≤300 / ≤200k
venue_night 122 53k
queue_night 121 61k
district_posters / street_noon (wide main spine) 185 131k (worst tour shot)
market_square (densest daytime, fresh) 97 52k

No budget bust. The flip adds ~3 draws (rain Points 1 + tram 2; winmap 0 — it's a shader on existing windows), and the town-wide pools add ~0 (1 instanced mesh). Worst default-boot street view measured is 185 total draws / 131k tris — ~115 draw / ~70k tri margin. (Pools are night-only, so the pre-existing daytime dense-citizen ceiling — Lane D territory, ~275 total per R13's live-walk — is unchanged by this round; winmap/rain add ≤3 there. F's live week-soak confirms the full-density number.)

3. Bookmark spot-check on the default boot

All 19 bookmarks resolve, un-letterboxed, 0 page errors with all four flags on. Rain particles + tram + winmap now appear in the old opt-in frames and compose cleanly — street_noon rainy (177 draws / 114k tris) frames the strip with rain + storm sky + wet road, nothing blocked. No pose fixes needed.

→ Lane F (B→F handshake — the default worst-case numbers): default boot, busiest venue block = ~170 total draws / 66k tris; worst street view (wide main spine) = 185 / 131k; densest daytime = 97 / 52k. All ≤300 / ≤200k. Town-wide pools are ~1 draw. Re-confirm against the real default once your flip lands; the pool sprite is procedural (no asset, E has nothing to do).


Round 15 (Fable's ROUND15 → Lane B) — verify the hero after A's poster flip (v3.0 release)

Task: re-verify queue_night after A's ledger-#1 flip (35da83c: buildPosters item 1 → +sin,+cos street face, ry = lot.ry + π; gig golden re-pinned 0x1f636349 → 0x4f4a549d, synthetic frozen).

A's placement is correct — verified. The pub's own-frontage poster moved from 15.9 m behind the building (R13/R14) to 0.09 m from the door on the +Z street face, facing the reader (A's ry+π composes with venue.js's render → the printed face points along the street outward normal, name not mirrored). No pose change needed — my R14 prediction held.

But a night-lighting gap surfaced (and John sanctioned the fix). Posters are unlit MeshStandardMaterial (emissive 000000); nothing illuminates them at night (marquee/pools/glow are emissive materials, not lights). Measured the frontage poster centre pixel: DAY [183,71,59] (vivid) → NIGHT [4,0,1] (pure black) — so queue_night (seg 5) still framed no readable bill, the very thing A's golden move was for; district_posters was dark for the same reason. Fix (venue.js, John-sanctioned exception to the R14 freeze): posters get emissiveMap = <the poster art> + emissive = white, emissiveIntensity ramped in update() by night (0 by day → 0.55 at night, same lerp as the glow/pools). Self-lights the bill after dark; daylight stays byte-identical (emissive 0 → verified the midday SCREAMING UTES spine poster is pixel-unchanged, no glow/wash). No new rng, no plan change, goldens untouched (0x3fa36874 / 0x4f4a549d both frozen). ?noassets fallback poster self-lights too (its canvas exists, zero fetch).

Verified fresh Chromium, seed 20261990:

  • queue_night (seg 5, live queue spawned): marquee + window glow + streetlamp pools + readable red poster by the door + queue punters + dark CLOSED neighbours. 90 draws / 29k tris.
  • district_posters (seg 5): a venue poster now reads on the spine (was black). 180 draws / 138k tris.
  • Daylight spine poster: byte-identical to R13/R14 (day emissive ramps to ~0). venue_night reads.
  • Spine posters face the street everywhere (A's flip only moved item-1 frontage posters; item-2 pole posters unchanged, still readable).

→ Lane F (B→F handshake): HERO VERIFIED. queue_night frames queue + marquee + A's now-visible poster; drive the queue live in tour_shots.py (the rAF is throttled in automation — I spawn D's VenueQueue manually to preview). Shoot at seg 5 (NIGHT) — the poster now reads there (fix above), and night is required for the pools + window glow. Poster night-readability is on venue.js's update() ramp, so the shell's per-frame venuePresentation.update(gigState.byVenue) already drives it.


Round 14 (Fable's ROUND14 → Lane B) — v3.0 RELEASE: alias retirement + the tour audit

Release round — no new systems. Three B files touched (audio.js, venue.js, dbg.js); no world geometry, no rng, goldens frozen (0x3fa36874 synthetic, 0x1f636349 gig — neither moved). Verified fresh Chromium, seed 20261990, ?gigs=1.

1. Alpha-alias readers dropped (debt #1 — B first; F unblocked)

The R12 window.PROCITY.gigs scalar alias (.state/.on/.venueShopId/.cover/.paid/…) is retired this round. B's readers removed:

  • audio.js venueGigState() — dropped the alias hop (g.venueShopId === shopId → g.state). Now byVenue-only + the clock fallback: g.byVenue[id] → else plan.gigs night-0 vs the segment.
  • venue.js stateGetter() — dropped the bare-string arm; reads gigStates.byVenue || gigStates (F passes gigState.byVenue, confirmed at index.html:379). Seed call is now update(null).

Verified live: F's gigState.byVenue reads {116:'on', 404:'on', 490:'quiet'} at NIGHT (pub + RSL playing, band_room dark); passing it to venue.update() lights the marquee with no string arm, and audio.js resolves each venue's gig-<genreKey> with no alias hop. Audio engine loads live.

→ Lane F (you can now delete the alias): an alias-surface sweep (all of web/js/**, index.html, tools/**) confirms CROSS-LANE CLEAN — no A/C/D/E file reads the scalar alias (interior_mode.js uses onOf()/gigOf(); sim.js uses shell-driven setGig). The only remaining alias readers are your own 20 lines in tools/flags_check.py + tools/qa/gig_shot.py (.venueShopId×12 → venueShopIds[0] or an explicit id; .state×5 → stateOf(id); .cover×2 → coverOf(id); .bandName×3 → bandNameOf(id); gig_shot.py:81 already prefers stateOf(id)). Delete the alias getters at gig_state.js:141151 + migrate those tool lines in the same commit; !!P.gigs existence checks keep working (the per-venue machine still exists). CITY_SPEC §v3 runtime block loses the alias line (with A).

2. Bookmark / tour audit under ?gigs=1all clean, no pose fixes

All 19 bookmarks (16 v2 + venue_night + district_posters + new queue_night) resolve, frame un-letterboxed, 0 page errors, ≤300 draws. The district's in-place venue conversions hijacked no bookmark: night_neon→"Video Regal" (openLate is never converted), dig_real→"Retro Groove" (a real record shop survives — multiple per type). Framing eyeballed clean on the tour spread: street_noon, venue_night, queue_night, tram_stop, district_posters, night_neon. No dbg.js pose changes were needed beyond adding queue_night.

  • Measurement gotcha (re-confirmed): DBG.shot teleports, so shooting many bookmarks in sequence leaves orphan chunks that inflate tris on later shots (saw 238k on patronage_door; 42k on a fresh load / shoot-twice-to-settle). Shoot from a clean load, or twice, for the true per-frame count.
  • Observation → Lane D / Fable (not a Lane B fix): tram_stop is a stable ~293k tris — real, from close-range full-detail GLB ped rigs on the footpath (framing is fine). It's not a venue block (F's tri gate measures at the venue block, ~64k), so it likely doesn't trip the gate, but it exceeds the 200k reference for a tour shot; worth a look if a close ped shot lands in the release set.

3. queue_night — John's release hero (dbg.js)

New bookmark queue_night (seg 5 / NIGHT / 'on'): a close 3/4 on the pub door, biased to the venue's +right (the queue side). New poseForVenueQueue() recomputes venue.js's frontage geometry from the pub lot: DIST 7 m out on the street, SIDE = mw/2 + 2.2 m to the queue side, eye 2.2 m, looking at the door lifted to y2.7 (catches the marquee). Verified: frames marquee + window glow + streetlamp pools + the queue. The queue is spawned by the shell's live rAF loop (index.html:383 builds D's VenueQueue on openOf(id)), so F's gig_shot.py must drive it live (the automation rAF is throttled — I confirmed the pose + D's line by manually spawning VenueQueue at the pub queueZone: 2 punters seat at the door, facing it).

  • → Lane A (blocks the hero's poster): queue_night frames door + marquee + queue, but no poster lands in the close frame — the pub's own-frontage poster is still on A's local Z (the BACK wall, gigs.js buildPosters item 1 -sin,-cos; measured 15.9 m from the +Z street facade), and the nearest spine poster is 53 m away. Per Fable's "fix poses, don't touch world code," B did not work around it. Flip item 1 to +sin,+cos and the frontage poster seats by the door — in the existing queue_night frame with no pose change. (Re-flagged from R13; now it gates John's hero shot.)

4. Poster-repetition call (debt #4) — FINE, no ask to E

Tonight's 14 posters carry 2 distinct designs (Screaming Utes / screenprint ×7, The Dead Ringers / xerox ×7 — different skins, different bands). Variety = number of venues playing tonight (one seeded skin per gigId), so a spine run reads as two real band bills, not a repeat. Only a town where a single gig plays everywhere would look monotonous (rare, and reads as "a residency"). No new templates needed.

5. Town-wide streetlamp pools — v3.1 candidate (Fable ruling, no code)

Fable ruled the night pools stay venue-scoped for v3.0 (town-wide needs always-on furniture.js changes that fight flags-off parity in release week; venue blocks read fine — venue_night proves it). Parked here as a v3.1 candidate alongside wind sway and bench-sit loiter. No R14 code.


Round 13 (Fable's ROUND13 → Lane B) — the district reads by night (?gigs=1, v3.0-beta)

Four B pieces, all gig-gated (flags-off byte-identical, verified). Files: venue.js (rewrite), audio.js (spill), dbg.js (bookmarks). Verified in a fresh browser context (the stale-module trap bit me once here — the pre-warmed tab served R12 venue.js; a cache:'reload' + hard nav fixed it, proof = the returned object's key set flipped from R12 {frontage,venueShopId} to R13 {venues,venueShopIds,queueZones,…}). Seed 20261990 = Boolarra Heads, 3 venues: The Exchange Hotel (pub/pubrock, id116), Wangaratta RSL (rsl/covers, id404), The Vinyl Shed (band_room/grunge, id490); 14 gigs/week, tonight Screaming Utes @ pub $10 + The Dead Ringers @ RSL free.

1. venue.js generalised to N venues — createVenuePresentation(plan, skins, scene)

Now renders every plan.shops with s.venue, not just the pub. Returns { group, venues[], update(gigStates), dispose, venueShopId (primary/alpha compat), venueShopIds[], queueZones{}, queueZoneFor(id), frontage (primary, compat), posterCount, posterDraws }.

  • Frontage per venue — warm marquee slab + emissive bulb row over the door, on B's +Z facade side (sin ry, cos ry; buildings.js canon), plus a window-light spill wash (additive, faint night baseline + gig glow). Ramped per-venue by update().
  • Streetlamp pools (the night-readability pass) — one procedural warm radial sprite, one InstancedMesh for the whole district (door pool + 2 footpath pools per venue). Night-driven (on at NIGHT, off by day — streetlamps don't care about gigs), so venue blocks read on the Friday-night walk. Scoped to venue blocks; town-wide deferred on purpose (would need always-on furniture.js changes → breaks flags-off parity).
  • Posters, town-wide, instanced per gig. A given gig's poster is identical wherever pasted, so B groups plan.posters by gigId, picks ONE seeded skin per gig, and renders each group as a single InstancedMesh14 posters tonight = 2 draws / 2 canvas textures (was 1 mesh + 1 canvas each). Band name overprinted in the skin's nameZone; flat printed-poster fallback on 404 / ?noassets.
  • ?noassets fetch fix (latent R12 bug). R12 posterTexture fetched poster-*.jpg even under ?noassets. R13 gates the fetch behind !noassets (read from the URL, like buildings.js WINMAP). Measured ?noassets=1&gigs=1: 0 poster fetch, 0 audio/manifest fetch — the gig layer adds no network over the documented 22-JPEG facade/ground baseline. Venue still builds (flat posters), no crash.

2. audio.js muffled spill → per-genre, per-venue (debt #1 closed on B's side)

The R12 spill hard-coded pubrock-live; E's rename made that a silent bed (band mimes). Now B import { gigKeyFor } from '../citygen/gigs.js' (the ONE genre→bed map) and spills each venue's gig-<genreKey>. Walks the venue list, the nearest venue at doors/on within 26 m wins, so pub→band_room→RSL crossfades the genre through the same 470 Hz lowpass. Verified beds resolve to E's manifest: pub→gig-pubrock, RSL→gig-covers, band_room→gig-grunge (all present). Audio engine loads live with the new citygen import; flags-off it loads fine and the spill loop is skipped (no s.venue shops). F: delete the GIG_BED bridge — B no longer needs it.

3. Queue-zone marker (for Lane D) — venue.queueZone = { x, z, ry, len }

Each venue publishes a queue zone: a subtle chalk-pale ground strip beside the door (past the marquee edge, out on the footpath), running along the frontage. queueZones = { [venueShopId]: zone }; also queueZoneFor(id). Contract for D: (x,z) = the head (front of queue, at the door end); the line extends in (sin ry, cos ry) for len metres; a queuer faces the door = (sin ry, cos ry). D places poses at head + (sin ry, cos ry)·s for s ∈ [0,len]. Verified 3 zones seat correctly (pub head (30.9, 390.0) beside the door). D's fallback ("straight line from the door") stays valid if the zone is absent.

4. dbg.js bookmarks — the street money shots (for tools/qa/gig_shot.py)

  • venue_night (seg 5): stands 13 m in front of the pub frontage — lit marquee + streetlamp pools + window spill + queue + posters, dark CLOSED neighbours behind. The street money shot.
  • district_posters (seg 5): the spine at night, poster poles + lit frontages receding.
  • New poseForVenue(kind, dist) resolves from the live plan (falls back to streetViewPose with no gig layer, so flags-off shots don't crash).

update(gigStates) — the shape to agree with F (F drives it every street frame)

update is polymorphic: pass { [venueShopId]: 'quiet'|'doors'|'on'|'done' } (a plain object or Map, or { byVenue: {…} }) for per-venue frontage lighting; a bare state string is alpha compat and lights only the primary pub (plan.gigs[0].venueShopId). F R13 ask: build window.PROCITY.gigs.byVenue = { [venueShopId]: { state, cover, paid, … } } (keep the alpha single object as a compat alias) and call venuePresentation.update(PROCITY.gigs.byVenue). audio.js reads PROCITY.gigs.byVenue[id].state too (falls back to the alpha alias, then a clock fallback from plan.gigs night 0), so the spill works across the transition. Until F wires the map, only the pub frontage marquee lights from the alpha string — pools + baseline window glow (night-driven) light all venue blocks regardless, so the district still reads at night.

Budget (measured, fresh Chromium, gigs-on, NIGHT, all frontages lit, busiest = pub block)

view scene draws tris gate
venue_night (composer total) 96113 1932k ≤300 / ≤200k
pub block 3/4 street (worst of 4 poses) 154 scene (~167 total) 64k ~130 draw margin
district_posters (composer total) 142
Gig-night layer cost is tiny: pools 1 draw, posters ≤2 draws (instanced per gig), frontage
~3 draws/venue-in-frame. ?noassets=1&gigs=1 worst 176198 (post passes vary), still ≤300. **0
console errors** in every mode (gigs / gigs+noassets / flags-off).

→ Lane A (a finding for you — cross-lane, please flip one sign)

buildPosters item 1 seats each venue's own frontage poster at lot + (sin ry, cos ry)·(d/2) — the lot's Z side. But B's facades/doors are on +Z (buildings.js canon since round 1, and the shipped town the player faces), so the venue-frontage poster lands on the back wall: measured for the pub it's 15.9 m from the street facade, 0.07 m from the back. The CITY_SPEC "front = Z" poster convention disagrees with the building convention — a street-side sibling of debt #4's RY clash. Fix: item 1 → +sin, +cos (the spine-run posters, which use the pole outward-normal derivation, render correctly and face the footpath — verified "SCREAMING UTES" readable, not mirrored). B renders A's (x,z,ry) verbatim (mesh oriented ry+π to face A's Z normal), so once A flips, the pub gets its poster with no B change. Not blocking the money shot (marquee + pools carry it).


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=1DBG.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)

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 stateweatherFor(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)

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.jsno 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):

  • Ambiencestreet-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.
  • SFXprocity: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)

// 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 wiringaudio.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.