PROCITY/docs/LANES/LANE_B_NOTES.md
m3ultra 01f398960b Lane B R27 (on-call): the R26 handshake verified against G's real manifest
R27 owes B nothing ("B/D: notes current; nothing else owed"; "B/D on-call").
This is on-call work on F's #5: the tour is specified as "Monster Robot Party's
frontage (B's mark visible)" — a claim R26 could not test, because G's manifest
did not exist and B proved the treatment against a temp manifest it scaffolded
and deleted. G shipped the real one (36d10ca). Now measured, not assumed.

Monster Robot Party is in redhill_godverse — NOT newtown_godverse, the only
town B ever tested the mark in. F's money shot is posed in a town this
treatment had never been verified against. It works.

The handshake held unmodified: G's {version, shops:[{godverseShopId, types,
sourcing}]} lands inside B's tolerant reader with zero coordination. The
tier→sourcing rename did not touch B — the mark never reads that field, so a
rename of it cannot break the footpath. A cue that reads no field cannot break
when the field moves.

Both arms proven live, the discriminating arm for the first time (R26's temp
manifest marked all 18 keyed shops, so "the manifest is the authority, not the
key" was never exercised). Redhill: 9 keyed, only 4 stocked.
  Monster Robot Party #3962749 (real)  keyed + in manifest → MARKED
  Silky Oak Furnature #31              keyed, not in manifest → BARE
  Thrift Shop and Eternity Boutique    keyed, not in manifest → BARE
Two keyed shops stand metres from a stocked one wearing no mark, because they
have no crate. The street advertises exactly the crates that exist. (#31 is
Fable's R25 id-namespace shop; the mark reads godverseShopId only, unaffected.)
Frontage 135 draws / 74,354 tris, 0 page errors, manifest prefetched at boot.

Intel for F's #5, no action owed: the novelty_record landmark (B's furniture —
a giant vinyl disc marking record shops) stands 2.5m off the frontage and is
prominent head-on. Ray-grid occlusion so F can frame without guessing:
  mark+sign   head-on 4% blocked   off-axis +4m 0%
  window      head-on 22% blocked  off-axis +4m 44%
Shoot HEAD-ON: the vinyl sits low (y~1.3), the mark rides high (y~3.3), and
stepping off-axis doubles window occlusion instead of helping. I first read the
black disc as a render bug — it isn't, it's the landmark working; recorded so
the next reader doesn't re-open it.

Notes-only; no code changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:52:57 +10:00

81 KiB
Raw Blame History

LANE B — NOTES (measured)

Round 27 (on-call) — the R26 handshake verified against G's real manifest; intel for F's #5 money shot

R27 owes B nothing ("B/D: notes current; nothing else owed", "B/D on-call"). This is on-call work: F's tour (#5) is specified as Monster Robot Party's frontage, B's mark visible — a claim my R26 verification could not actually test, because G's manifest didn't exist yet and I proved the treatment against a temp manifest I scaffolded and deleted. G has since shipped the real one (36d10ca). Claim now measured, not assumed.

Monster Robot Party is in redhill_godverse, not newtown_godverse — the only town B ever tested the mark in. F's money shot is posed in a town this treatment had never been verified against. It works.

The R26 handshake held, unmodified

G's real emission is {version:1, shops:[{godverseShopId, types, sourcing}]} — 15 entries, lands inside B's tolerant reader (.shops, objects carrying godverseShopId) with zero coordination, exactly as the R26 note promised. The tiersourcing rename (Fable's waves-1-2 ruling) did not touch B: the mark deliberately never reads that field, so a rename of it cannot break the footpath. The "don't leak tier" call paid a dividend it wasn't designed for — a cue that reads no field cannot break when the field moves.

Both arms proven live — the discriminating arm for the first time

R26's temp manifest marked all 18 keyed shops, so "manifest is the authority, not the key" was never actually exercised. G's real manifest makes it load-bearing: redhill has 9 keyed, only 4 stocked. Measured at Monster Robot's frontage (live chunk set), ground truth vs render:

shop keyed in manifest rendered
Monster Robot Party #3962749 (real) marked
Silky Oak Furnature #31 bare
Thrift Shop and Eternity Boutique #908 bare

Two keyed shops stand metres from a stocked one wearing no mark, because they have no crate. The street advertises exactly the crates that exist. (#31 is the same shop as Fable's R25 id-namespace bug — its godverse id collides with a plan id; the mark reads godverseShopId only, so it is unaffected.)

Frontage: 135 draws / 74,354 tris, 0 page errors, manifest prefetched at boot.

Intel for F's #5 (no action needed from B)

The novelty_record landmark (B's furniture — a giant 0x1a1a1a vinyl disc on a post, marking record shops) stands 2.5 m off Monster Robot's frontage and is prominent in the head-on frame. Measured occlusion by ray-grid, so F can frame without guessing:

band head-on (8 m) off-axis (+4 m)
mark + sign 4% blocked 0%
window glass 22% blocked 44% blocked

Shoot head-on. The vinyl sits low (y≈1.3) and the mark rides high (y≈3.3), so it never meaningfully occludes the mark; stepping off-axis doubles the window occlusion instead of helping. A giant vinyl record in the frame of a record-shop money shot is a feature, not a blemish. (I first read the black disc as a render bug — it isn't; it's the landmark doing its job. Recorded so the next reader doesn't re-open it.)

No code changed this round; notes-only.


Round 26 (Fable's ROUND26 → Lane B) — #5 the polish pick: the stocked shopfront reads from the footpath

Shipped. A stocked godverse shop now tells you it's diggable before you reach the door — a crate of spines under the sign, and a warm-lamp-over-crates room behind the glass. Cost: +0 draws / +0 tris.

Pick rationale: of the three offered (signage/window treatment · awning ripple · tram-stop tri diet), this one is the beta's own theme — EVERY CRATE DIFFERENT is worth nothing if the street doesn't say which shops have a crate. (The tri diet is likely moot after E's R22 decimation; the ripple is a vibe with no beta dependency. Both stay on the list.)

The authority question — the manifest, not the key

The mark keys off stock_godverse/index.json membership, deliberately not off godverseShopId. Keyed only means "this shop has a stock identity"; the manifest means "a crate is actually here". Marking keyed-but-unstocked shops would advertise a crate the shop hasn't got — the street would lie, the one thing this epoch keeps refusing to do. This is ledger #6's manifest (G emits, F consumes) doing a second job for free: it is already the truth of who carries an atlas.

The mark does not leak tier. "You can dig here" is equally true of real and mint; provenance belongs on C's price card and in E's gates, not shouted from the awning.

What it is (both halves free)

cue how cost
crate mark — a row of seeded spines under the shop name painted into the chunk's existing sign-atlas cell (drawSigndrawCrateMark), 7-colour palette, shop.seed LCG so a shop's crate is stable 0 draws / 0 tris (same 2048² canvas, same 1 draw/chunk)
window cue — warm lamp over crates, not a generic 90s wall rides the existing per-shop aTint attribute (roomTintSTOCKED_ROOM_TINT), ?winmap parallax path 0 draws / 0 tris (no new attribute, no new material)

Measured (newtown_godverse, 18 keyed = 18 stocked under a temp manifest, since deleted)

True A/B at one pose — manifest 200 vs manifest 404:

control (404) treatment (200)
spine-marked sign cells 0 3 (18,768 exact-palette px)
stocked window tint verts 0 36
street_noon 65 draws / 115,502 tris 65 draws / 115,502 tris

+0 draws / +0 tris — with the treatment proven on, not merely assumed. Stocked shopfront (Goulds Books, footpath pose): 85 draws / 144,730 tris, inside street law (≤300 / ≤200k).

Consistency check against ground truth: 3 stocked shops in the live chunk set (Dorrigo Hotel, Old Wares, Goulds Books) → 3 marked cells, 9 stocked window quads (3 shops × ~2.6 windows). Cues agree.

Covenants (re-proved — the prefetch is new code on the boot path)

boot keyed stock_godverse fetches errors
?classic=1 (zero-fetch covenant) 0 0 0
default synthetic (?dbg=1) 0 0 0
newtown_godverse, manifest 404 18 1 (404, eaten) 0

Tested with the manifest present on disk — the harshest form: a stray fetch would have returned 200 and been unmissable.

Three traps this round (all self-caught, all recorded)

  1. The module-load fetch would have broken the classic covenant. First draft fetched the manifest at module load → fires on every boot → a console 404 on the synthetic default and a fetch delta to ?classic=1, whose covenant is zero fetch. Restructured to lazy ensureStockIndex() + plan-driven prefetchStockIndex(plan), called from createChunkManagerkeyed plans only.
  2. The lazy trigger alone raced the first chunk build. With only the per-chunk trigger, boot showed manifestRequested: [] (spawn chunks had no keyed shops); the manifest only fetched after walking to a keyed shop — meaning the first stocked shop you approach would build unmarked. Fixed by prefetching at chunk-manager construction, before any chunk builds. Verified: manifest now fetched at boot.
  3. A vacuous "zero cost" and a junk detector. First measurement read +0/+0 — because the manifest was never fetched (stale chunks.js + a cached 404), so nothing had been applied. Numbers identical because the feature was off. Then the pixel detector reported 60 "marked" cells with no treatment at all — it was counting ordinary busy sign cells. Replaced with an exact-palette signature (the spine colours no registry sign uses): control 0, treatment 3. Vacuous-gate law applies to a lane's own verification, not just to gates — a green that can't go red proves nothing. Corollary recorded: force-cache serves a deleted file (transferSize: 0, status: 200 while disk 404s) — an A/B against disk state is not an A/B. Poison the cache (cache:'reload') or you are testing your own memory.

Handshake for G / F / C (manifest shape)

buildings.js reads the manifest fail-soft and shape-tolerantly: a root array, or .shops / .entries / .atlases, of bare ids or of objects carrying godverseShopId | id. Any other shape, a 404, or ?noassetsno treatment, no error, no console noise. G's real emission (ledger #6) needs no coordination with B — if it lands in any of those shapes, the footpath lights up on its own. B consumes index.json read-only and emits nothing into stock_godverse/** — the temp manifest used to prove this round was deleted; the directory is G's.

Files: web/js/world/buildings.js (the two cues + the lazy fail-soft reader), web/js/world/chunks.js (one line: the keyed-plan prefetch).


Round 22 (Fable's ROUND22 → Lane B) — #1a the venue_night tri diagnosis → OWNER: LANE E

Verdict: the driver is the four GLB street-furniture props. 149,161 of 163,015 tris = 91.5% of the stress view. Not posters, not buildings, not pools, not the density widening per se. Owner = E (asset tris) per the ledger's own rule. Decimation clears the gate with ~120k to spare.

Method: katoomba venue_night, settled, then hide-a-layer-and-re-measure (frustum-accurate attribution — a layer's cost is the tris that vanish when it's hidden), then the same drill inside furniture, bucketed by geometry.

Layer breakdown (katoomba venue_night, baseline 163,015 tris / 88 draws)

layer rendered tris share
chunk-furniture (26 chunks) 151,341 92.8%
ground 9,932 6.1%
venue-presentation (frontage+posters+pools) 770 0.5%
lighting (sky dome) 624 0.4%
chunk-buildings (26 chunks) 348 0.2%
venue-posters 222 0.1%
citizens · weather · tram · venue-pools 0 0%

Buildings are 348 tris — the R3 atlas/instancing work holds; and posters are 222, so A's cap is already spent (it worked; it was never the lever). Pools cost 0.

Inside furniture — the four GLBs are the whole bill

prop (GLB) tris/instance instances in view rendered tris share of view
bench (procity_street_bench_01) 1,942 64 60,202 37%
bus_shelter (procity_street_bus_shelter_01) 7,998 10 55,986 34%
bin (procity_street_bin_01) 3,000 19 18,000 11%
food_cart (procity_street_food_cart_01) 4,991 8 14,973 9%
pole · lamp · trees · novelty (primitives) 440 303 2,180 1.3%

GLB total 149,161 (91.5%). Primitives 2,180 (1.3%). Identity confirmed by DCC-authored material names (wire_143225087 — a Sketchfab scan, baked_mat, 2 wood frame , Material_0) — these are E's four furniture GLBs, not our procedural materials. Two (bench, food_cart) are also MeshPhysicalMaterial, an expensive shader for a 0.4 m street prop.

Why now, and why it isn't the density widening: shop count doesn't drive this — road density does. Real towns put a bench/bin/shelter cadence on a 966-edge graph, so ~100 GLB props land in the streamed set where synthetic had a handful. The props were always over-tessellated (a 1.2 m bench at 1,942 tris is ~5× the entire building stock in frame); real roads just finally presented the bill. This is my own R6 note coming due — filed then: "the street_bin GLB is 3.0k tris — heavy for a prop placed ~15×; a decimate to ~500 tris would give back tri headroom if the combined-lane budget tightens." It tightened.

→ Lane E: the fix (asset tris), with targets

Decimate the four props to sizes sane for their real-world size (they're 0.42.3 m, seen at street distance; E's R13 instrument decimation is the same drill):

prop now suggested in-view after
bench 1,942 ~250 60,202 → ~16,000
bus_shelter 7,998 ~600 55,986 → ~6,000
bin 3,000 ~200 18,000 → ~3,800
food_cart 4,991 ~400 14,973 → ~3,200

149,161 → ~29,000 (saves ~120k). Projected: my baseline 163k → ~43k; F's 221,935 → ~102k — both far under the 200k gate, with headroom for the pack's denser towns. (Cheap extra: drop the two MeshPhysicalMaterials to MeshStandardMaterial.)

→ Fable: a B-side fallback exists if E can't land decimation this round

furniture.js already ships a primitive fallback for every GLB prop (the R4 "use-if-ready" path, the ?noassets route). So a distance/density-gated LOD — keep GLBs near the camera, primitives beyond — is a contained Lane B change I can land quickly. I'm not doing it unilaterally: per the ledger this is E's (asset tris), and source decimation is the right fix (it helps every town and every view, not just this one). Say the word if you want the B-side gate as belt-and-braces.

Round 22 — #3 per-town bookmarks (Lane G's filed bug) — FIXED

Root cause: every "nearest" resolver in dbg.js sorted by distance from the world origin. That's the town centre only on a synthetic plan. A real OSM town is projected about its cache's bbox centre while its shops cluster hundreds of metres away — so bookmarks teleported into arbitrary far-flung geometry, exactly as G reported. Measured origin-pick distance from the retail heart: katoomba 408 m · newtown 626 m · fremantle 371 m.

Fix: resolve every bookmark about the town's densest shop cluster (grid-bin the shop lots at 120 m, heaviest bin's centroid = the retail heart) instead of the origin — byDist, lotsByDist, streetViewPose, stopPose, and crossroadsPose's tie-break.

Gated on the REAL-ROADS signal (edges > 200) — the same primitive the R20 tram fence uses — so synthetic and the marched fixtures keep their exact origin-centred behaviour and F's release-tour control shot cannot move. This gate is load-bearing, not cosmetic: synthetic's anchor is only 117 m out but it does re-pick (Anglibarn Op Shop → Dusty Pages), so an unconditional switch would have silently moved the control. Same reason crossroadsPose's new tie-break is gated — a degree tie would otherwise resolve differently on synthetic.

town edges ref used
synthetic · melbourne · katoomba-fixture · silverton 22 · 15 · 15 · 1 origin — unchanged
newtown_godverse (G's town) 1,626 anchor (98, 629)
katoomba_real 993 anchor (227, 625)

Result on katoomba — shopfront bookmarks went from ~408 m adrift to ≤42 m of the retail heart: arcade 7 m · milkbar_dusk/patronage_door/shopfront_detail 24 m · market_square 26 m · dig_real 42 m; strip views (street_noon, katoomba_main, district_posters) ~195 m — on the high street; venue_night/queue_night ~345 m (that's A's venue placement, not the bookmark). All 19 resolve, 0 errors, synthetic 19/19 unchanged.

  • warehouse_fringe sits 3,074 m out — correct by design: it's the far:true fringe shot, and on a 5 km town the fringe genuinely is 3 km away. Left alone deliberately.

→ Lane F (ledger #4, the shared posing primitive): published on the harness as DBG.townAnchor ({x, z, n, anchored, ref} — the retail heart + whether it's in use) and DBG.clusterPose(kinds) (stand on the road at the densest cluster, look along it). That's your cluster-pose finding as a dbg.js-consumable — the tour and the bookmarks now resolve through the same primitive, so a tour shot and its bookmark can't drift apart.

Honest note on the baseline (mine 163,015 vs F's 221,935)

Same view, different number: my automation's rAF is throttled, so the live gig crowd never spawns (citizens measured 0 tris here) — F's number includes it (~59k of peds). This doesn't move the verdict: furniture is 92.8% of what I measured and still ~68% of F's 221,935, and the decimation above clears the gate on either baseline. Flagging it so F re-measures against the live crowd, not my throttled figure.


Round 21 (Fable's ROUND21 → Lane B) — selector from E's towns index (ledger #2, the missed R20 #5)

Closed the R20 gap Fable caught (hud.js:11 still hardcoded REAL_TOWNS). The real-town list now comes from E's web/assets/towns/index.json (procity-towns-index/1), so it grows with the town pack instead of being edited in hud.js. Verified fresh Chromium. Nothing else touched — the tram/ground work is done.

How it works (fail-soft by construction)

createHUD is synchronous, so the picker is built immediately from the fallback literal and then upgraded in place when the index resolves. That ordering is the fail-soft: if the index never arrives, the working list is already on screen.

  • Index present → real names, states and shop counts: Katoomba, NSW · 80 shops, Fremantle, WA · 80 shops, Newtown, NSW · 72 shops, Bendigo, VIC · 36 shops, Castlemaine, VIC · 24 shops. (roads:false towns would read · marched; none today.)
  • ?noassets → the fetch is skipped entirely (asset law — the shell doesn't load town caches there either, so reading the index would be pointless as well as illegal). Measured: 0 index fetches, fallback list, 0 errors.
  • Anything wrong with the index → the fallback simply stands. Exercised the exact chain against every nasty case; none throws, none rebuilds from junk:
index case result
404 / missing · network error · malformed JSON fallback stands
towns: [] · towns not an array · junk entries (no key) fallback stands
good index rebuilds from index

REAL_TOWNS_FALLBACK is retained only as that fallback (comment says so) — it is no longer the source of truth. Verified live: booting katoomba_real shows Katoomba, NSW · 80 shops pre-selected; the picker fits (193 px of its 230 px max); 0 page errors; navigation unchanged (onchange is set once on the <select>, so rebuilding the options never clears it).

→ Lane F: unchanged from R19 — no shell seam, the selector is entirely hud.js (already built by createHUD), and it lives inside #pc-hud so setVisible(false) hides it for the beta money shot. Your R19 gating call (always-in-HUD vs ?dbg) is still yours; one line either way.


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.