PROCITY/C-progress.md
m3ultra b0e4ce103e Lane C R22 (v4.0): 23-town door-soak (#5) + the G2a atlas contract (#3)
Wave 3 — ran after A pinned the 36 goldens (3face97) and E's tri-diet fix (d81e499).

#5 — 23-TOWN DOOR-SOAK: five-town harness scaled (~10 doors/town; sample takes one of every
type present then stride-fills). 238 doors / 1,133 in-plan shops / 23 towns.
  23/23 PASS — 0 throws / 0 carves / 0 path-fails / 0 over-budget; worst 159 draws (fitzroy)
  <=350. All 12 archetypes covered town-set-wide (the whole registry). 23/23 towns landed 3
  venues — A's cluster bias + district placement hold from the 12-shop thin tail (darwin,
  toowoomba) to the 139-shop mecca (fitzroy, worst 159 = same band as everywhere: interiors
  don't care about town scale). Lane G's newtown_godverse passes (18 shops, worst 134).

#3 — G2a PER-SHOP ATLAS CONTRACT (C's half), published in LANE_C_PUB §7. All three Fable priors
hold; atlas-per-shop is strictly BETTER for draws than today's type-packs. The binding constraint
is VRAM, not draws — E's dispose ask GRANTED, it's my seam:
  - Draws: 1 atlas/shop = 1 draw (shared material + baked UV -> batch merges). Cap 2; 60+ item
    shops spill to the type-pack. Headroom huge (stock rooms 41-58 vs the <=350 law).
  - VRAM: E's 181MB audit is right, and the cause is my own design note (stockpack.js:11 caches
    atlases "NOT per-room, like depot GLBs") — correct for 3 bounded type-packs, wrong for
    unbounded per-shop. Ruling: <=2 resident atlases, <=8MB decoded, <=1024" typical; C lands the
    LRU cap + dispose in stockpack.js. Pre-existing + ?stock=real is opt-in (index.html:196) so
    it can't touch the default boot -> NOT a v4.0 blocker; C's first v4.x item, before G2a ships.
  - Schema: E's index confirmed verbatim, keyed per shop; UV top-left (C flips V); C reads uv not
    cell. Fail-soft/parody/?noassets unchanged. Ask to G: emit items[] in deterministic id order —
    C's seeded per-bin pick indexes into it.

C->F: 23-town interior verdict GREEN. Docs-only; both C items closed for v4.0.

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

822 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# LANE C — INTERIORS · progress (PROCITY-C)
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-16 (round 22) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-16 (round 22, v4.0 THE EPOCH CLOSE) — 23-town door-soak (#5) + the G2a contract (#3)
R22 §Lane C, both items. Wave 3 — ran **after A pinned the 36 goldens** (`3face97`) and E's tri-diet fix
(`d81e499`), so this soaks the final v4.0 world.
### Ledger #5 — sampled door-soak across all 23 towns
The five-town harness scaled: **~10 doors/town, sample takes one of every type present then stride-fills**, so
every archetype is covered town-set-wide. **238 doors · 1,133 in-plan shops · 23 towns.**
| town | shops | doors | worst draws | | town | shops | doors | worst draws |
|---|---|---|---|---|---|---|---|---|
| adelaide | 110 | 11 | 158 | | katoomba | 72 | 10 | 149 |
| ballarat | 19 | 10 | 154 | | launceston | 30 | 10 | 137 |
| bendigo | 35 | 10 | 141 | | marrickville | 26 | 10 | 149 |
| bowral | 30 | 10 | 156 | | newcastle | 34 | 10 | 152 |
| braddon | 19 | 10 | 158 | | **newtown_godverse** | 18 | 10 | 134 |
| brunswick | 92 | 11 | 149 | | newtown | 67 | 11 | 149 |
| castlemaine | 23 | 10 | 150 | | northbridge | 58 | 11 | 149 |
| darwin | 12 | 10 | 154 | | toowoomba | 12 | 10 | 138 |
| daylesford | 26 | 10 | 153 | | westend | 41 | 10 | 146 |
| **fitzroy (mecca)** | **139** | 10 | **159** | | fremantle | 79 | 11 | 152 |
| geelong | 62 | 11 | 145 | | glebe | 51 | 11 | 151 |
| hobart | 78 | 11 | 157 | | | | | |
**Verdict: 23/23 PASS — 0 throws · 0 carves · 0 path-fails · 0 over-budget; worst 159 draws (fitzroy) ≤350.**
- **All 12 archetypes covered town-set-wide** (band_room, book, dept, milkbar, opshop, pawn, pub, record, rsl,
stall, toy, video) — the whole registry.
- **23/23 towns landed 3 venues** — A's cluster bias + district placement hold from the 12-shop thin tail
(darwin, toowoomba) to the 139-shop mecca (fitzroy). Fitzroy, 2× anything absorbed before, is the *cheapest*
end of nothing — worst draw 159, same band as everywhere else: interiors don't care about town scale.
- **Lane G's `newtown_godverse` passes** (18 shops, 8 types, worst 134) — the merged town needs nothing from C.
### Ledger #3 — the G2a per-shop atlas contract (C's half) → published in LANE_C_PUB **§7**
Answered Fable's priors + E's half. **All three priors hold; atlas-per-shop is strictly better for draws than
today's type-packs. The binding constraint is VRAM, not draws — and E's dispose ask is granted: it's my seam.**
- **Draws:** 1 atlas/shop = **1 draw** (shared material + baked UV → batch.js merges). Rule: 1, hard cap 2;
60+ item shops spill to the type-pack. Headroom is enormous (stock rooms 4158 draws vs the ≤350 law).
- **VRAM (the real ceiling):** E's 181 MB audit is right and the cause is my own design note
(`stockpack.js:11` caches atlases "NOT per-room, like depot GLBs") — correct for 3 bounded type-packs,
**wrong for unbounded per-shop atlases**. Ruling: **≤2 resident shop atlases, ≤8 MB decoded, ≤1024² typical**
(2048² only for 60+ items); **C lands the LRU cap + dispose in `stockpack.js`**. Scheduling: the accretion is
pre-existing and `?stock=real` is **opt-in** (`index.html:196`) so it can't touch the default boot — **not a
v4.0 blocker**; I land it as **C's first v4.x item, before G2a ships**.
- **Schema:** E's index confirmed verbatim, keyed per shop; UV origin stays top-left (C flips V); C reads `uv`
never `cell` (so book's 176×256 portrait is fine). **Fail-soft/parody/`?noassets`:** unchanged law.
- **One ask back to G:** emit `items[]` in **deterministic order (sort by id)** — C's seeded per-bin pick
indexes into it, so a re-emit would silently reshuffle every crate.
**C→F: 23-town interior verdict GREEN** (nothing gates the tag from C). Docs-only. Both C items closed for v4.0.
---
## Update 2026-07-16 (round 21, v4.0-beta close) — the five-town density door-soak (ledger #3)
R21 §Lane C (wave 2 — ran **after A's venue cluster-adjacency bias** landed, `110c828`, so the soak reads the
relocated world + A's re-pinned goldens). The carried R20 #6: **every one of the five beta towns at its new
density**, deterministically sampled ~25 doors/town — the sample takes **one of every type present** then
stride-fills, so the texture classes' first real appearance (milkbar/dept/stall on real shops) is always covered.
| town | shops (in plan) | sampled | venues | types (all sampled) | throws | carves | path-fails | over-budget | worst draws |
|---|---|---|---|---|---|---|---|---|---|
| `katoomba_real` | 72 | 25 | 3 | 10 | 0 | 0 | 0 | 0 | **157** |
| `newtown_real` | 67 | 25 | 3 | 11 | 0 | 0 | 0 | 0 | 155 |
| `fremantle_real` | 79 | 25 | 3 | 11 | 0 | 0 | 0 | 0 | 153 |
| `bendigo_real` | 35 | 25 | 3 | 10 | 0 | 0 | 0 | 0 | 154 |
| `castlemaine_real` | 23 | 23 (all) | 3 | 8 | 0 | 0 | 0 | 0 | 156 |
**123 doors · 0 throws · 0 carves · 0 path-fails · 0 over-budget · worst 157 draws (≤350).** In every town
`typesInTown === typesSampled` — every archetype present was entered, including all three venue kinds
(pub/band_room/rsl) and the texture classes. **All five towns landed 3 venues** → A's cluster bias + district
placement hold at density.
**§6 mapping verified live (John's SUBTLE directive, working by construction):** katoomba's cache is **20
heroes + 60 texture** — exactly E's 3× cap, heroes untouched. In-plan (after A's counted drops): 17 heroes
(opshop 10 · book 4 · record 2 · video 1) + 52 texture (milkbar 29 · dept 18 · stall 5) + 3 venues = 72.
The texture classes' **first real appearance** renders right: **"Blue Mountains Chocolate Company"** — a real
Katoomba `shop=chocolate` — now wears `milkbar` (70s geo wallpaper, cube shelves of price-tagged goods, counter,
wall rack): a believable confectionery counter shop. The widening reads as texture, not takeover.
*(Cache→plan deltas — e.g. katoomba 80→72 — are A's overflow/fragmentation drops, counted by cause in A's R20
accounting; not an interior concern, and every surviving door builds clean.)* **C→F: density verdict GREEN.**
Docs-only. C otherwise on-call; my §6 mapping + this soak close both C items for the beta.
---
## Update 2026-07-16 (round 20, v4.0-beta FIVE TOWNS) — the class→archetype mapping (ledger #2, C's half)
R20 §Lane C (I lead — E builds to it, half-day rule): **published the OSM shop-class → interior archetype
mapping** in [LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) **§6** (new v4.0-beta amendment; the frozen venue
contract §0§5 untouched). This is the density widening — real towns fetch **general retail**, not just the
thriftgod secondhand classes, so the main street holds its real bakery/cafe/chemist/clothes shops.
**Design (John's directive: SUBTLE — texture, not takeover):**
- **No new archetypes.** General retail wears **existing** recipes: food/grocer/newsagent/chemist → `milkbar`
(the canonical AU counter shop); clothes/hardware/big-retail → `dept`; produce/florist → `stall`.
- **Secondhand stays the hero** — charity/antiques→opshop, books→book, music→record, video/games→video,
pawn→pawn, toys→toy; never subsampled.
- **Unknown/unlisted → `opshop`** (R18-proven safe). Included a copy-paste JSON map for E's `build_towns.py`
+ a tasteful fetch list (skip vehicle/funeral/industrial/pure-services like hairdresser/bank).
- **Guardrail for E:** if a town's general retail would visually drown the secondhand strip, seed-subsample
the texture classes toward ~23× the secondhand count and count the drops in `norm.dropped`.
**Mechanic note:** `plan_osm` keeps only registry types (unknowns flatten to opshop), so E bakes a **registry
type** per the map into each cache's `shop.type` — no C code change, all targets are existing recipes/registry
entries, budgets unchanged. **C→E handshake: §6 is live.**
**Pre-validated the mapping (measurement over folklore, before E builds to it):** milkbar/dept/stall/opshop all
build valid at the katoomba lot (8.5×14) — 0 carves, ≤143 draws, pathOK. Rendered `dept` (my one flagged case
— the escalator centrepiece): reads as a clean **dressed-retail floor** (cube shelves of goods + clothes rack +
counter + "GROUND FLOOR/HABERDASHERY" signage) in the 7.9×11.7 hall, escalator does *not* dominate — so
`clothes`/`hardware` → `dept` is confirmed, not a concern.
**Pending (ledger #6, after E re-emits the 5 widened caches):** the five-town sampled door-soak — every town,
sampled doors at the new density, all types resolve, no carve, budgets; one table, five rows. I'll watch that
`dept` reads right on small real lots (the escalator centrepiece). Docs-only this commit; door-soak follows E.
---
## Update 2026-07-16 (round 19, v4.0-alpha CLOSE) — re-soak katoomba on REAL roads (against A's in-flight pass)
R19 §Lane C (on-call, ledger #5): E's roads landed (`dbb52c2` — katoomba_real is now **schema v2, 484 real
roads**), so I re-ran the 20-door soak against the real street graph — closing the R18 "re-run when roads land"
caveat. **Honest caveat: the working tree also carried Lane A's UNCOMMITTED R19 fragmentation pass** (plan_osm.js
WIP — `JOIN_TOL`/`BRIDGE_MAX`, junction-protected DouglasPeucker, shopless-island cull / shop-island bridge),
so the dev server served A's *in-progress* finalization, not a frozen state. Read this as "interiors survive
A's real-graph pipeline as it stands mid-round"; a clean confirm follows A's commit.
**Robust result (holds whatever A's WIP does):** every shop the plan produced builds a **valid interior**
0 throws · 0 carves · 0 path-fails · 0 over-budget · worst **147** draws (min 115); 3 gig venues
(pub/band_room/rsl — **district placement holds on the real graph**); plan deterministic (byte-identical
re-gen). Lot dims **unchanged** (7.58.5 × 14) → interiors are dim-identical to R18 (road-agnostic confirmed;
my R18 degenerate-lot proof already spans anything the real graph can seat).
**Shop count observed = 19 (NOT filed as a defect).** The plan yielded 19 shops here, but against A's in-flight
fragmentation code that's expected, not a bug — A's ruling explicitly culls shopless islands and bridges/
drops-with-count shop-bearing ones, so the final count is A's to pin (could land at 20 connected or an explicit
19 with a `norm.dropped` count). I'm not attributing it to A as a finding; I only confirm interiors build clean
whichever way it resolves. (If A's committed pass surprises with a shop that *can't* build an interior, that
would be the finding — none did in this run.)
**Final C spot-confirm pending A's COMMITTED finalization + golden pin** (per Fable's sequencing, C confirms
after A). When A pins `katoomba_real`'s real-roads golden I'll re-run once against the frozen state. Docs-only
round for C.
---
## Update 2026-07-16 (round 18, v4.0-alpha REAL ROADS) — interior spot-soak of katoomba_real (ledger #6)
R18 §Lane C (small): enter every `katoomba_real` shop and assert valid interiors + no carve + budgets.
Built the town plan the way the shell does — `registerTownCache(katoomba_real)``generatePlanFor(seed,
'osm', {town, gigs:true})` (gigs on = the default boot) → `buildInterior` per shop (lot dims resolved from
`plan.lots`). **All 20 doors PASS:**
| metric | result |
|---|---|
| shops entered | **20 / 20 built** |
| throws · carves · path-fails · over-budget | **0 · 0 · 0 · 0** |
| worst draws | **153** (shop 16, opshop/hall) ≤ 350 |
| types resolved | opshop · book · record · video + venues pub · rsl · band_room |
| gig venues (default boot) | 3 (ids 3/5/19) — all built valid gig interiors (stage + watchPoints) |
| draw range | 108153 |
**Degenerate-lot stress (preempts A's real-road re-seating):** 32 combos (8 extreme lots {2×3, 1×1, 3×30,
40×4, 100×100, **0×0**, 4.2×4.1, 2.5×18} × 4 types incl. venues) → **0 throws · 0 carves · 0 path-fails**;
every room floors at **≥4×4 m** (`computeDims Math.max(4, lot0.6)`). So whatever frontage the real-graph
lift seats shops on, the interior contract absorbs it.
**Caveat (honest):** audited against the CURRENT `katoomba_real.json` — schema v1, `roads: none`, so it boots
the **marched fallback** (E's katoomba roads cache isn't committed yet; A's lift `a78dc76` is in). Interiors
are **road-agnostic** (they consume lot dims + type + seed, never road geometry), and the degenerate-lot proof
covers any lot the real graph produces, so the conclusion is stable. **Interior lane contributes 0 failures to
the alpha's failure list.** A one-line re-run once E's `roads[]` cache lands (lot dims may shift with real
frontage) — I'll re-confirm if asked, but the interior contract already absorbs the range. C otherwise on-call.
---
## Update 2026-07-16 (round 17, v3.2 THE TAIL) — backline handshake CLOSED + real-town type fallback
R17 §Lane C (ledger #3): close the R16-pending render-verify of D's wired amp. D landed the wiring
(`ab7a8d0`): `band.js _addBackline` reads `stage.backline[0]` with an immediate primitive `ampPrim()` fallback
(asset-free) + GLB upgrade. **Handshake CLOSED — verified fresh (seed 20261990), 3 archetypes × 2 modes:**
- amp plants at `backline[0]` **exactly** (offset 0.00 GLB / 0.01 prim — rounding); **1 amp per stage** (no
more C primitive → the double-amp is gone).
- **no interpenetration** — amp↔nearest-band AABB edge gaps all positive: pub **0.40/0.44 m**, band_room
**0.27/0.29 m** (tightest; the bass extends downstage away from the up-stage amp), rsl **0.54/0.57 m**
(GLB/primitive). Rendered band_room gig confirms the read (amp up-stage-right behind the bassist, clear).
**On-call (proactive, for A/E's real-map scout):** confirmed C's interior type fallback eats real OSM shop
types. 18 real `shop=` values (hairdresser/bakery/convenience/supermarket/chemist/butcher/… + ''/null/
undefined/`SHOP`/`Op_Shop`/`antiques`) all build with **0 throws, all pathOK**, resolving to `opshop`
(theme.js `canonicalType`) or a mapped alias — so a player entering any real-town shop gets a valid interior.
Venue types (pub/band_room/rsl) are recognized; everything else degrades to opshop. No C change needed for v4.
Docs-only round (verification confirmed shipped behavior). C otherwise on-call.
---
## Update 2026-07-16 (round 16, v3.1 THE FLIP) — the backline handoff (C's half of ledger #5)
R16 §Lane C (ledger #5, C's half): unify the amp. **`room.stage.backline[]`** now exposes 2 seeded up-stage
amp slots `{x,z,ry,y}` (pure data, no meshes), and C's **primitive ampStacks are dropped** from `placeStage`
Lane D plants E's `guitar_amp` GLB at `backline[0]` (its own primitive fallback under `?noassets`), one amp
system. Shape published in [LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) §3 (v3.1 amendment marker; rest of the
frozen doc unchanged). **C→D handshake: backline shape is live.**
- `backline[0]` = stage-right `(stage.x + stage.w·0.36, deckY, stage.z stage.d·0.22)` == D's R15-verified
amp pose exactly, so wiring D's plant to it moves **nothing** on screen. `backline[1]` = stage-left spare
(mirror). ry=π, y=deckY.
- **Verified fresh (port-isolated, seed 20261990):** both slots up-stage, on-deck, clear of all 4 bandPoses
(nearest-bass **0.87 m pub / 0.70 m band_room / 0.92 m rsl**) and all watchPoints (≥1.95 m); determinism
byte-identical (placement + stage + watchPoints); 0 leftover ampStack prop groups; drawSweep glb-off venues
**pub 122 · band_room 120 · rsl 131** (was 126/124/135 — the dropped primitives shaved ~4 draws each), worst
161 (opshop/hall, unchanged). ≤350 ✓.
- **Determinism/golden safety:** removing the ampStack loop only drops draws on the `stage` rng sub-stream,
which nothing downstream reads; the `placement` snapshot excludes ampStacks (never tracked), so it's
byte-identical. Interiors aren't hashed into any golden; the `?classic=1` boot has no venues → untouched.
- Disjoint from F's flip (interiors don't read flags) — landed early so D isn't blocked. **Verify with D
once wired** (ledger #5 D-half): re-render each archetype, confirm the GLB at `backline[0]` doesn't
interpenetrate (pending D's R16 commit).
**Code:** `layout.js` placeStage (backline[] + drop the amp loop), `interiors.js` API doc. `theme.js` untouched
(recipes carry no ampStack fitting — the primitives were placeStage-planted, not recipe fittings).
---
## Update 2026-07-16 (round 15, v3.0: closing the release) — verified D's amp fix
R15 §Lane C (one item): verify D's `guitar_amp` reposition (ledger #4). D moved the plant (commit `66aec0f`)
to C's §3 corrected pose exactly — `band.js:236 amp.position.set(stage.x + stage.w·0.36, deckY, stage.z
stage.d·0.22)`. **Verified (fresh port-isolated context, seed 20261990, D's real GigCrew + GLB backline):**
matches §3 (both venues); **bass clearance 0.87 m (pub) / 0.92 m (rsl)** — the R14 interpenetration (0.29 m)
is gone; on-deck within footprint; flanks C's primitive ampStack (opposite side, 4.4 m pub / 3.5 m rsl) as a
clean up-stage backline; no clash with band/crowd/watchPoints. Rendered pub gig confirms the read (amp up-stage
behind the bassist, primitive up-stage-left, band clear). LANE_C_PUB §3 updated RESOLVED. Freeze otherwise
stands; no other C change this round. Nothing else blocks on C — F runs the release (continuity wiring + smoke,
alias deletion, week soak, tour, tag `v3.0`).
---
## Update 2026-07-16 (round 14, v3.0: the release) — venue audit + docs freeze (light round)
R14 §Lane C: interior release audit, the `guitar_amp` ruling, and the docs freeze. C's interior code was
**unchanged** since R13, so venue geometry is stable by construction; the audit re-verified it against the
current tree (D's R13 continuity + RY-fix + real instrument GLBs landed) and froze the contracts. Ran an
adversarial audit **workflow** (10 agents: conformance + D-integration/amp + docs-freeze edit list, then a
verify pass) — it earned its keep (caught the amp defect I'd first waved through + two stale comments).
**Empirical re-verify (fresh context, port-isolated server):**
- Venue contract **byte-identical to R13**: caps 8/8/12, deckY 0.32/0.20/0.40, riserY 0.48/0.36/0.56, gigKeys
gig-pubrock/grunge/covers, quiet-night = no gigKey, determinism true.
- `drawSweep` glb-off: band_room 124 · pub 126 · rsl 135 (worst 161 opshop/hall) ≤350 — unchanged.
- **Gig-night draws with D's real `GigCrew` + GLB instruments** (localdepot): pub **60** · band_room **52** ·
rsl **71** ≤350. Asset-free worst RSL 237. All 4 instruments load (guitar/bass/mic/drums).
- **Release-law confirmed** (workflow, CONFIRMED): D's continuity + the RY fix ride a disjoint rng namespace
(`rng(citySeed,kind,id)`) from C's `ctx.stream(salt)`; the RY fix is a constant Y-rotation in `buildFigure`
(no rng). Cannot perturb C's placement or any golden.
**`guitar_amp` ruling (debt for C) → Lane D:** **NOT blessed as-is — reposition up-stage.** D plants the GLB
at `(stage.x+stage.w·0.32, deckY, stage.z+0.15)` — that `+0.15` is *down-stage*, landing the 0.5 m amp 0.29 m
from the bass rig (**interpenetrates the bassist**, confirmed by render + 3 audit agents), and it's redundant
with C's own primitive ampStacks (`placeStage` plants 12 up-stage at `(±stage.w·0.28, deckY, stage.z
stage.d·0.18)`). Corrected pose in [LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) §3: move up-stage-right ≈
`(stage.w·0.36, deckY, stage.z stage.d·0.22)`, clear of the bass and offset from C's primitives → a clean
flanking backline. Full amp unification (C exposes `stage.backline[]`, GLB replaces primitive) = v3.1.
Cosmetic + `?gigs=1` opt-in → does not block the tag.
**Tri observation → E/F (not a C gate):** E's decimated instrument GLBs are **14k tris each** (5 on a stage =
70k; peds ~2.7k each), so a settled RSL gig-night interior is ~**182k tris**. The interior gate is *draws*
(passes at ≤71); the 200k tris is the *street* budget, so no violation. But F's gig-night smoke reports ~34k
because it measures **before** the async instrument GLBs load — the real settled scene is ~5× heavier. A
camera-distance instrument LOD is a v3.1 candidate.
**Docs freeze (v3.0-FROZEN markers added):**
- `LANE_C_PUB.md` — retitled + frozen marker; §0 ry convention reframed past-tense (the +Z fleet was fixed at
the source in D's `rigs.js buildFigure`, R13 — no per-consumer flips); 4-piece stated as shipped (veto
language removed); the amp ruling added (§3); budgets updated to R14; in-flight handoffs removed.
- `LANE_C_AUDIO.md` — frozen marker + a scope note (this covers base interior audio; gig audio `gigKey`
lives in LANE_C_PUB); the deferred `audioEmitter` idea re-targeted to v3.1.
**Code (comment-only, zero runtime/golden/rng impact):** fixed two stale R12 inline return-shape comments in
`interiors.js` (`watchPoints` 6..8 → 8/8/12 caps; `stage` `bandPoses[3]``[4]` + `riserY`) + one in
`layout.js`; added a note at the gigKey hand-build explaining it's deliberately standalone (byte-identical to
citygen's `gigKeyFor`, not imported — keeps the interiors lib citygen-free). `theme.js` untouched.
---
## Update 2026-07-15 (round 13, v3.0-beta: the district) — two more rooms + the drummer
R13 §Lane C. The district: two new venue archetypes + the 4-piece band + the RY-facing audit. Interfaces
(re)published in [docs/LANES/LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) — **read §0 first (the pinned ry
convention + the keeper/browser back-to-front finding for Lane D).** Committed C-only by pathspec (the tree
had E/B/D changes landing concurrently — [[procity-shared-tree-hazard]]).
**Shipped (C-owned files only): `theme.js`, `layout.js`, `interiors.js`.**
- **`band_room` + `rsl` recipes** (`theme.js`, both `venue:true`), reusing the pub venue machinery with a new
`venueSpec` field. `band_room` = small/scuzzy tin shed (cosy/pokey bias, low 3.4 m ceiling, deck barely a
riser `deckY 0.2`, near-empty standing floor). `rsl` = carpet-and-flock club (hall/wide bias, tall 4.0 m,
proper stage `deckY 0.4`, bistro tables, the biggest crowd cap). A converts a shop with `shop.type = kind`;
C keys the recipe off it. `SHOP_TYPES` now auto-enrols both in `drawSweep`/`soak`.
- **4-piece band (debt #3):** `placeStage` grows `stage.bandPoses` to **4** — front-line trio
(guitar/vocal/bass, `y=deckY`) + a **drummer** (`role:'drums'`, `seated:true`, up-stage on the drum riser,
`y=riserY=deckY+0.16`). Each pose now carries its own `y`; `stage.riserY` added. Applies to all three
archetypes (the pub gains a drummer too). Pure geometry — no new rng draw, determinism intact.
- **Per-kind venue tuning:** `placeStage`/`placeWatchPoints` read `recipe.venueSpec` → deck size/height +
**crowd cap** (pub/band_room **8**, rsl **12** — the crowd-cap stress case). Pub values == the R12
constants, so the pub stage geometry is byte-identical (bar the new drummer).
- **Robust gigKey (debt #1):** `audio.gigKey = 'gig-'+genreKey`, genre resolving `opts.gig.genreKey`
`shop.genreKey` (A sets it) → a kind default. Matches E's renamed manifest beds `gig-pubrock`/`gig-grunge`/
`gig-covers` exactly (verified: manifest has all three, zero stale `pubrock-live`). One key, no mapping.
**RY-facing audit (debt #4) — see LANE_C_PUB.md §0 for the full write-up + fix. Headline:**
- Canonical convention pinned: **rig-front = local Z at ry=0** (every C pose + the box placeholder use it).
- **The GLB fleet mesh faces +Z (opposite).** Verified in a fresh context two ways: toe-bone probe
(`toefoot = (0,+0.99)` on 5 peds) and dotting a fleet rig planted at `counter.stand.ry` against the
intended customer-facing (`0.99` = 180° backward; `+π``+0.99`).
- **Finding: keepers + browsers have been facing 180° backward whenever the GLB fleet is loaded** —
`keepers.js` spawns at `pose.ry` with no flip, and the mesh front is +Z. The R12 band reads right only
via `band.js RY_FLIP=π`. Placeholder (Z) and GLB (+Z) are opposite, so it's inconsistent both ways.
- **Recommended fix for Lane D (visual-only, no golden moves):** normalise at the source — rotate the clone
`inner` by π in `rigs.js buildFigure` so every GLB rig faces Z; then `keepers.js` stays flip-free (now
correct) and `band.js` DROPS `RY_FLIP`. Repro below.
**→ Lane F (flags_check.py): two smokes are RED by design — bump `band == 3``== 4`.** My 4th bandPose
makes the band a 4-piece (D's `band.js` already iterates `bandPoses`, so it spawns 4 today), which trips F's
R12 assertions verbatim as the round-13 instructions anticipated ("F's band==3 smoke moves to 4"):
- `tools/flags_check.py:674``if night['band'] == 3``== 4`.
- `tools/flags_check.py:794``if na['inRoom'] and na['band'] == 3 and na['crowd'] > 0``== 4`. *(The
noassets gig still happens — band 4, crowd 8, 0 fetches, 0 console errors; silent-and-happy holds. It only
"fails" because 3 is hardcoded.)* These are the ONLY qa reds from C; the other 5 gates pass.
**→ Lane D:** lift each band member to `pose.y` (front line `deckY` unchanged; drummer sits at `riserY`);
`seated:true` on the drums pose → seated bob; raise `band.js CROWD_CAP` to the watchPoint cap so the RSL's 12
fill (I measured the full 12). And apply the §0 facing fix. Repro for the audit (paste in the interior test page):
```js
const rigs = await import('./js/citizens/rigs.js'); const THREE = await import('three');
const fleet = rigs.loadPedFleet('models/peds/'); await fleet.whenReady;
const sp = rigs.spawnRig(fleet.all[2], { ry: 0, height: 1.75 }); sp.fig.updateWorldMatrix(true, true);
let foot, toe, wp = new THREE.Vector3();
sp.inner.traverse(o => { if (o.isBone){ o.getWorldPosition(wp);
if (/LeftFoot$/i.test(o.name)) foot = wp.clone(); if (/LeftToeBase$/i.test(o.name)) toe = wp.clone(); }});
console.log('fleet forward at ry=0:', toe.clone().sub(foot).setY(0).normalize()); // → (0, +1) = +Z
```
**Budgets (measured, fresh context, seed 20261990):**
- Empty shells (`drawSweep` glb-off): band_room 124 · pub 126 · rsl 135 (worst town-wide 161 opshop/hall) ≤350.
- **Gig-night incl. full crowd + 4-piece:** worst **RSL 237 draws** asset-free (16 rigs) / 77 GLB, 40.9k tris.
pub 176/56, band_room 173/53. ≤350 draws / 200k tris. ✓
- 90-build venue soak: 0 determinism · 0 carve · 0 path-fail · worst 8.3 ms; leak geo0/tex0. `soak(60)` in
the page: PASS 60 rooms, leak 0/0, determinism identical, worst draws 85. qa figure-scale gate: 24 figs
human-sized. Shot: [docs/shots/laneC/rsl_gig_r13.jpg](docs/shots/laneC/rsl_gig_r13.jpg) — RSL, 4-piece
facing the crowd, crowd facing the stage (facing reads correct via band.js's flip).
**Drummer veto (John):** default is Fable's 4-piece ruling (executed). To revert to a front-line trio, drop
`bandPoses[3]` in `layout.js placeStage` (1 line) + F reverts the smoke to `== 3` — say so before D wires it.
---
## Update 2026-07-15 (round 12, v3.0-alpha) — the pub venue: archetype + watchPoints
R12 §Lane C (venues & gigs, behind `?gigs=1`). Interface published in
[docs/LANES/LANE_C_PUB.md](docs/LANES/LANE_C_PUB.md) — the "publish interface first" deliverable D/F build on.
- **`pub` archetype** (`theme.js`, `venue:true`): low **stage** + PA cabinets + amps at the back wall (new
`stage`/`paSpeaker`/`ampStack` fittings, GLB slots `stage`/`pa_speaker`/`amp` for E), bar counter (`corner`
— the barkeep is just a keeper), sticky-carpet floor, the blocked back doorway = the green room.
- **`room.watchPoints[]`** — 68 seeded audience floor poses `{x,z,ry,dance,slotIndex}` facing the stage
centre (`counter.stand` ry convention). `dance` seeded **~⅓ true** (John: "a bit of both"). Walkable,
reachable, clear of keeper/browse/stage. **`room.stage`** = `{x,z,w,d,deckY,frontZ,bandPoses[3]}` so D
drops the band on the deck.
- **`room.audio.gigKey`** — F passes `opts.gig` (gig state); a gig-on pub returns `gigKey='gig-'+genreKey`
(`gig-pubrock` for the alpha), else a quiet-night interior. F prefers gigKey over musicKey. (→ E: name the
live bed `gig-pubrock`.)
- **Flags-off byte-identical**: venue code runs only for `recipe.venue`, on its own `stage`/`watch` sub-
streams — non-pub rooms have `watchPoints:[]`, `stage:null`, unchanged audio + placement.
- Verified: 40-seed pub sweep — 0 throws · **0 carves** (PA `areaFree`-guarded off the counter strip) · 0
path-fails · 8 watchPoints each · dance 0.38. 60-room mixed soak (incl. pub) — 0 throws/det/path fails,
**leak geo0/tex0**, worst 19 ms. Non-pub rooms confirmed unchanged.
---
## Update 2026-07-15 (round 11, audio) — `room.audio` contract
R11 §Lane C ("one contract answer"). **Confirmed** Fable's proposal: `buildInterior` now returns
`room.audio = { musicKey, toneKey }`, seeded per shop — F's interior_mode plays it, B's audio.js
resolves the keys. Full contract + F wiring recipe: [docs/LANES/LANE_C_AUDIO.md](docs/LANES/LANE_C_AUDIO.md).
- `toneKey` → a `manifest.audio.ambience` interior room-tone (always set); `musicKey` → a
`manifest.audio.music` bed, or **null** (no music). Keys, not files — buildInterior stays synchronous
and asset-free; silence on missing/`?mute=1`/`?noassets=1` (house audio law).
- Maps mirror the manifest's per-type `types` arrays (one source of truth): record/milkbar/video/dept/
arcade always play their bed; opshop/book/toy/pawn/stall are room-tone-only, except a seeded ~1-in-3
keeps a general radio on (the `general`-tagged milk-bar bed).
- Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches manifest
`types` membership; deterministic per shop. `qa.sh --strict` green.
- Optional visual-source prop (`userData.audioEmitter`) deferred to v2.2 (in the note) — out of round scope.
---
## Update 2026-07-15 (round 9) — ship v2.0: browse points (C→D→F seam) + buy-anywhere
Round-9 §Lane C. The round hangs off C1 (two lanes build on it), so it shipped first as a handshake +
working code; C2 widens buying beyond the dig.
**C1 — browse points (the round's one cross-lane seam).** `layout.js placeBrowsePoints()` exposes
`room.browsePoints[]` — 13 deterministic floor poses in front of browsable fittings, facing the goods,
where Lane D stands browser rigs (F wires occupancy → points). Same coord frame + `ry` convention as
`counter.stand`, so D's fleet clones drop straight in. Pure data — **no meshes, no draws**; the ≤350 law
is untouched by C1. Guarantees (all asserted headless): walkable (occ-free, not reserved), reachable from
spawn (the door→counter flood-fill), ≥0.6 m apart + clear of the keeper stand, deterministic per
`shop.seed`, ≥1 point in every archetype that has a browsable fitting. A rich candidate ring (4 sides × 2
gaps + diagonals) + a nearest-free-cell fallback covers tight/mismatched rooms. Interface published in
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) **before** code (decision #1 half-day handshake) → committed
first so D/F unblock. Validated: **180 type×archetype×seed combos → 0 empties** (177×3 pts, 3×1 via
fallback), determinism 0 fails, min-sep 0.7 m, 0 keeper clashes. Test page: cyan browse-post markers on the
`path` overlay + a HUD count.
**C2 — buy-anywhere (decision #2).** Book spines + toy boxes get pull-and-buy over the **same wallet**;
records keep the deep dig. New `stockpack.buildBuyableShelf()` builds each real shelf as merged,
per-item-**addressable** meshes — one mesh per atlas (all covers on a shelf drawn from a single seeded
atlas → **one draw/shelf**), tagged `noBatch` so `batch.js` leaves them intact. Aiming a spine/box raises a
pull card (title / author-or-maker / $price / band from the pack index); BUY debits the wallet, adds to
inventory, and `collapseBuyItem()` zero-areas that item's quad in place (**removed from the shelf**, one
draw, no geometry churn). Parody stock has no buy mesh → **no card** ("not for sale"). Fail-soft per pack.
Validated: raycast→card→BUY works end-to-end (item collapses to its centre, cash/inventory update, card
closes); **buy-soak 6/6 bought, `collapseOk`, leak geo 0 / tex 0**; draws with `?stock=real` **record 41 ·
book 58 · toy 57** (≤350); full drawSweep unchanged (GLB-off 168 · GLB-on 344, both pass); placement
determinism 0 fails. Shot: [browse_buy_r9.jpg](docs/shots/laneC/browse_buy_r9.jpg).
**→ D**: consume `room.browsePoints` (contract in LANE_C_NOTES) — stand rigs at the first
`min(occupancy, 3)` points. **→ F**: two new warn smokes (hooks in LANE_C_NOTES): browse-points present +
valid, and shelf-buy (aim → card → wallet debit → collapse). C's soak covers keeper+stock+dig+buy together.
---
## Update 2026-07-15 (round 8) — the town comes alive: buy loop v0 + book/toy real stock
Round-8 §Lane C, theme "the town comes alive". Two deliverables + a verify pass.
**C1 — buy loop v0** (new `web/js/interiors/wallet.js`). A shallow, **runtime-only** economy: seeded
starting cash, purchases, an in-memory bag. Reload resets; world generation is **untouched** (purchases
never enter the plan or the seeded room build — the wallet is a separate object the shell reads, never
writes back into gen).
- `createWallet(seed)``{ cash, start, canBuy, buy(offer), inventory, count, onChange }`. Start is
seeded ~$60200 (`mulberry32`). `buy` deducts + records on success, **returns false unchanged if broke**.
`onChange` lets F's street shell subscribe the same wallet to a cash chip.
- **Test page** (`interior_test.html`): a `#cashChip` HUD (`$163 · 🛍 N`, street + interior) + an
inventory panel toggled with **I**. The dig's pull card gets a **BUY IT** button (price + band); buying
pulls the sleeve into the bag, decrements cash, updates the chip, and **depletes that bin** (bought bins
offer fewer sleeves on re-riffle). Broke → the button no-ops (can't buy). `digSoak` now buys up to 2/visit.
- Offer shape is the dig offer (`{a,t,price,s}`) OR a pack item (`{title,artist,price,price_band}`) — one
`buy()` eats both.
**C2 — book spines + toy boxes wired to `?stock=real`.** E shipped the generalized packs
(`stock_book_*` 311 items / 4 atlases, `stock_toy_*` 273 items / 5 atlases, alongside `stock_record_*`
350/6). `stockpack.js` now keys each pack to its slot via `SLOT_FOR = {record:'sleeve', book:'spine',
toy:'box'}`; `stock.js shelfLine` consults the adapter and builds real cover planes for book spines
(bookshelves) and toy boxes (cube shelves) — **static shelf stock, no dig** for those, fail-soft per pack.
Every real mesh samples its atlas material with a baked per-item UV, so a whole shelf still batches to one
draw per atlas.
**C3 — validated** (all three packs live, `?stock=real`):
- **Draws ≤350** (the law): record **41**, book **42**, toy **51** worst over seeds {1990,7,42(,101)}.
- **Determinism**: same seed → identical draw calls + mesh count, all three types (0 fails).
- **Leak-free**: 0 geometry/texture delta over 20 build/dispose cycles; **buy-soak** 12 record-dig visits,
5 bought (wallet depletes to broke → exercises the can't-buy path), `leakGeo 0 / leakTex 0`; resting geo
back to baseline after close. Atlas textures shared/cached (not per-room); per-item geo ctx-tracked & disposed.
- **Reload resets** the wallet (fresh boot → seeded start again); **flag-off + `?noassets` untouched**.
- Shot: [buyloop_r8.jpg](docs/shots/laneC/buyloop_r8.jpg) — toy boxes, book spines, record dig → BUY card.
**→ F**: two new warn-level smokes (hooks in [LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md)): (1) **buy loop**
`createWallet` deduct/broke/inventory is pure and headless-testable; (2) **book/toy packs** — enter book or
toy shop with `?stock=real`, assert `isStock` meshes on a shared atlas material (drawSweep ≤350). Buy UI is
test-page-local; the reusable piece for the street shell is `wallet.js` + `onChange`.
---
## Update 2026-07-14 (round 7) — `?stock=real`: real GODVERSE sleeves in the crates
Round-7 §Lane C: the `stockAdapter` seam built in v1 finally eats. Lane E's GODVERSE stock pack (parody-
transformed Discogs metadata + cover atlas) feeds record-shop bins + the dig riffle behind `?stock=real`.
- **Contract handshake** (task 1, unblocked E): confirmed E's index schema as-is in
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) — `{ items:[{id,title,artist,price,price_band,atlas,uv}] }`,
UV origin top-left (C flips V for WebGL), fail-soft, per-type packs.
- **New `stockpack.js`**: `preloadStockPack`/`getStockPack` (cached) + `makeStockAdapter`. Atlas texture +
material are shared/cached (like depot GLBs); sleeves are cover PLANEs sampling the atlas at each item's
baked UV → all real sleeves in a room **batch to one draw** (record: 59 draws).
- **Consumers**: `stock.js binFan` builds real cover planes (else parody canvas); `dig.js` riffles real
covers and the pull card shows **title / artist / $price / band**; `interiors.js` wires `opts.stock='real'`
through the seam (warn-once + parody fallback if the pack is absent); test page reads `?stock=real`.
- **Validated (sample 24-pack)**: determinism 0 fails/30, leak-free rooms AND real dig (0/0 over 25 cycles —
atlas shared, sleeve geo disposed), draws ≤139 all rooms (record 59), fail-soft on no-pack types (opshop
ok → parody), `?dig=1&stock=real` leak-free. Flag-off + `?noassets` untouched (procedural path byte-identical).
- Built against E's **staged sample** (real index format, synthetic covers); real Discogs covers drop in
unchanged when E gets the DB (external — John/Fable). Shot: [stock_real_r7.jpg](docs/shots/laneC/stock_real_r7.jpg).
**→ F**: new smoke for your strict harness — `?stock=real` enter record shop + open dig → assert real
(non-parody) sleeves; the record bin's real sleeves are cover planes with `userData.isStock` on a shared
atlas material (drawSweep stays ≤350). Hook + adapter in LANE_C_NOTES.
---
## Update 2026-07-14 (round 6) — C1 interior draw batching (≤350 draws/room is law)
Round-6 §Lane C task 1 (the review debt: book room was 1,245 draws GLB-on). **Diagnosed** the two
drivers — (a) multi-material stock boxes cost one draw PER face (6×), (b) one mesh per stock item —
then batched (new `web/js/interiors/batch.js`):
- **stock.js**: boxes are now single-material; every stock mesh tagged `userData.isStock`.
- **batch.js** (`batchRoom`, called from interiors.js before the GLB upgrade): two-tier merge —
room-level merge of all `isStock` across every fitting (grouped by material, world transforms baked)
+ per-fitting merge of fixture frames (kept in-group so bins stay raycast targets and the GLB
upgrade can hide the fixture). `mergeGeometries` from vendored BufferGeometryUtils.
- **glb.js**: frame-hide is now by `isStock` tag (not frame-count), so batched fixtures hide and the
room-level stock rides on the GLB.
**Result** (drawSweep, all 9 types × 6 archetypes, seed 1990):
- GLB-off worst **152** (opshop/hall), GLB-on worst **313** (opshop/hall) — book 1,245 → **189**. **≤350 ✓**.
- Determinism 0 fails / 810 builds · leak-free · dig still riffles batched bins (bins keep frame as hit
target; dig regenerates its own sleeves) · worst warm build 38ms (<50) · visually identical.
- **Sweep now ASSERTS 350** (`drawSweep()` in the test page, folded into the soak; `window.PROCITY_C.
drawSweep` for F's harness see LANE_C_NOTES for the hook).
**C2 — map the 3 orphaned fittings** (published R5, unconsumed). In `glb.js`: `glassCase→glass_case`,
`magazineRack→magazine_rack`, and a NEW `crateStack` primitive (stack of crates) `crate_stack`.
Recipe placements: crateStack in record + opshop (back-room dressing), glassCase added to milkbar,
magazineRack added to pawn (+ moved milkbar's magazineRack windowwall to dodge the door-counter).
Also **fixed `placeNearCounter`** it picked one random side and gave up, so counter-zone fittings
(the glass case) rarely landed; now it tries both sides + offsets, then falls back to a free aisle spot.
All 3 load + render planted/scaled/facing right (yaw 0). Appearance: glassCase **12/12** (toy/pawn/milkbar),
magazineRack **12/12** (milkbar/pawn), crateStack ~11/12 (record/opshop). Contact sheet:
[glb_orphans_r6.jpg](docs/shots/laneC/glb_orphans_r6.jpg). drawSweep still 350 with them on (worst 177
GLB-on). **C3**: dig soak re-run after batching + new fittings riffle works on batched bins, leak-free.
**Round 6 complete for Lane C**: C1 (batching, `35981ad`) + C2/C3 (orphans + dig re-verify) committed.
Draw law satisfied (≤313 was the tightest; 177 after C2), determinism/leak/dig all green, qa strict green.
---
## Update 2026-07-14 (round 5) — C1 v1.1 re-map (record_crate + counter_till) · **F can tag v1.1**
Round-5 §Lane C task 1 (the v1.1 gate). Lane E's re-exports mapped + validated in `glb.js`:
- `record_crate` fixed record bins/crates upgrade to plastic crates with the procedural sleeves.
- `counter → 'counter_till'` (the real ~1.6 m counter-with-till). Keeper-stand pose unaffected.
- **Double-till resolved**: cash_register attach dropped when counter_till is live (it has its own till);
falls back on the primitive; milkshake_mixer stays on the milk-bar bench (lifted to the GLB height).
- Sweep + soak (GLB on): `0 throws / 0 path / 0 det / leak-free / worst 10.4ms`; **0 double tills**.
Primitive path unchanged (glb.js-only edit). Contact sheet: [glb_v1_1_r5.jpg](docs/shots/laneC/glb_v1_1_r5.jpg).
- Report + decision detail: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md); F note in
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md). ** F: C1 committed, v1.1 can be tagged.**
**C2 — `?dig=1` crate-riffle (v2, behind flag).** Ported 90sDJsim `dig.js` `web/js/interiors/dig.js`:
walk to a record bin, press **E**, riffle its sleeves (scroll/drag), click the front to pull, **BUY IT**
(STUBBED toast + remove; getCash constant; real economy is round 6+). Riffle items are seeded per bin
via the `stockAdapter(shop,'sleeve')` seam (procedural fallback now parody 90s-AU sleeves). Own scene +
camera, procedural fwip/thunk audio, **leak-free** (per-open resources freed on close dig soak 20×
`leakGeo 0 / leakTex 0`). Flag-off (`?dig=0`) is **byte-identical** `buildInterior` is untouched; the dig
is wired only in the test page (F owns the shell seam hook documented in LANE_C_NOTES §"input/mode hook").
Shot: [dig_riffle_r5.jpg](docs/shots/laneC/dig_riffle_r5.jpg).
**C3 — interior re-measure after Lane E's bake (`c522464`).** E baked the 6 heavy hero props to 8k tris.
Re-measured every interior (cache-busted to bypass the dev browser's stale HTTP cache): **record 43.6k**
(the C3 target, ~273k pre-bake), **milkbar 38.9k** (was the flagged 468k over-budget flag CLOSED), all
rooms 107k tris. `book` is the draw-call outlier (1,245 draws from ~9 bookshelf GLBs + primitive spines)
tris fine, interiors render one-at-a-time so gate 3 unaffected; instancing spines is the future lever.
Full table in [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md §C3).
**Round 5 complete for Lane C**: C1 (v1.1 re-map) + C2 (`?dig=1`) + C3 (re-measure) all committed.
---
## Update 2026-07-14 (round 4) — hero props into rooms
Round-4 task (ROUND4_INSTRUCTIONS §Lane C): map the 4 new hero props + cash_register (manifest now 14
fittings), sweep/soak with them on, screenshot each, delete the stale `_local_glb/`, measure a hero
room. **All done.** Full report for Lane E: [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md);
contact sheet [docs/shots/laneC/glb_heroprops_r4.jpg](docs/shots/laneC/glb_heroprops_r4.jpg).
- **New mappings** (`glb.js`): `arcade_cabinet`new `arcadeCabinet` primitive (video focal prop),
`listening_booth`→`listeningCorner` (record), `drinks_fridge`→`fridge` (milkbar). All load, `glb_law`
compliant, yaw 0 (no correction). A new **counter-top attach** (`attachCounterTop`) puts `cash_register`
on every counter's till slot (hides the primitive till) and `milkshake_mixer` on the milk-bar benchtop
the counter primitive + keeper stand are untouched.
- **Focal-prop reliability fix**: the door-counter path loop was pulling priority-4 focal props (arcade
appeared in only 5/24 video rooms). Raised the 3 focal props to priority 6 + placed-first
**24/24 appearance**, still 0 carves / 0 path-fails.
- **Validation**: primitive sweep 810 builds `{throws 0, pathFail 0, detFail 0}`; GLB-on soak
`{throws 0, path 0, det 0, leakGeo 0, leakTex 0, worst 7.3ms}`; all 5 new props confirmed in-room.
- **Room tris measured** (task 4): milk-bar GLB-on **468k tris** (over budget) driven by `drinks_fridge`
(77k) + `milkshake_mixer` (73k) **reported to Lane E** as the #1 bake-pass target (not fixed, per
integrator decision #2; interiors render one-at-a-time so gate 3 / the street budget is unaffected).
- **Cleanup**: deleted `web/assets/_local_glb/` (R3 validation copy); test page now loads GLBs via the
live `depot:` path (`?localdepot=1` still works for offline). `localBase` hook kept in `glb.js`.
- **Standby (C task 5)**: when Lane E re-exports `record_crate` (broken) + a ~2 m counter-with-till, I
re-map both. Not landed at commit time.
---
## Update 2026-07-14 (round 3) — GLB hero-prop visual validation
Round-3 task (ROUND3_INSTRUCTIONS §Lane C): flip `useGLB` on and validate Lane E's fitting GLBs in real
rooms. Done full report for Lane E in [docs/LANES/LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md).
**Verdict:** 5 of 9 fitting GLBs are good and now live behind `useGLB` (wire_shelf, clothes_rack,
bookshelf, cube_shelf_wide, work_table); 2 fall back to primitive with a report filed
(`record_crate` **broken — won't load**; `counter` **too long/no till** primitive keeps the till +
keeper stand). record bins stay primitive until Lane E re-exports the crate.
**Soak with `useGLB` on** (50 rooms, all types): `throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 ·
leakTex 0 · worst build 10.5ms · 256 upgrades applied` GLB resources dispose leak-free on room exit.
**glb.js changes (my file):**
- `manifest.localBase` load GLBs from a served local dir for offline validation (production still uses
`depot:<file>`; forward-compatible with Lane E's local-depot mode).
- **Real-metre scale, clamped** to the reserved footprint (was fit-to-width, which squashed tall props);
matches `glb_law` (metres). Per-kind **yaw correction** (`KIND_TO_YAW`) for the Z-major assets
(bookshelf/cube/work_table need +90°) per the manifest `facing` convention ("Lane B/C rotate directional props").
- `room.glbReady` (interiors.js) a promise that resolves when GLB swaps settle, so callers can await
before screenshot/leak-check (Lane F can use it in the §3.4 wiring).
- Test page (`interior_test.html`) gains a **GLB props** toggle + a `GLB props: N upgraded` HUD line.
Local validation needs the GLBs copied in: `cp pipeline/_normalized/procity_fit_*.glb web/assets/_local_glb/`
(untracked; real GLBs live on the depot). Screenshot grid: [docs/shots/laneC/glb_validation_grid.jpg](docs/shots/laneC/glb_validation_grid.jpg).
---
## Update 2026-07-14 (b) — keeper stand pose (resolves Lane F §8 · unblocks §3.2/§3.3)
Lane F's runbook asked (LANE_F_NOTES §8): *"does `places` tag the counter slot with a stable
`kind:'counter'` so keepers find it?"* **Yes** the counter interactable has always carried
`mesh.userData.kind === 'counter'` (+ `interactable:true`). But Lane D's keeper needs more than a
tag: `keepers.spawn(group,{x,z,ry})` needs *where the shopkeeper stands and which way it faces*
(keepers.js:28 "`ry` = the counter's outward facing"). I computed the customer-facing side
internally but never surfaced it so Lane D would have read the bench's raw transform and stood a
keeper embedded in the counter, possibly facing a wall. Closed that gap (my files only, purely additive):
- **New: `room.counter = { mesh, pose:{x,z,ry}, stand:{x,z,ry} }`** (interiors.js). `pose` is the
bench; **`stand` is the keeper pose** behind the bench on the service side, facing the customer,
clamped inside the room, in **room-local space** (the same space Lane D adds the keeper into via
`interior.group`). `ry` uses the rig-front = local Z convention keepers.js expects.
- **The same `{x,z,ry}` also rides the counter interactable**: `place.userData.keeperStand` for the
`places`-iteration path Lane D/F described. Deep-equal to `room.counter.stand`.
**Lane D/F wiring (drop-in for §3.2/§3.3):**
```js
const room = buildInterior(shop, THREE);
const s = room.counter.stand; // or: room.places.find(p=>p.userData.kind==='counter').userData.keeperStand
keepers.spawn(room.group, { ...s, shopId: shop.id, type: shop.type });
// on exit: keepers.remove(handle) / keepers.disposeAll(), then room.dispose()
```
Verified in-browser (9 types × 4 seeds = 36 builds, + 50-room soak):
`stand in-bounds ✓ · faces-customer ✓ (dot>0 for all) · places-carries-keeperStand ✓ (deep-equal) ·
stand deterministic ✓ · placement determinism unchanged ✓ · soak avg 3.6ms / worst 7.7ms · leak geo0 tex0 ·
pathFail 0 · every room has a counter+stand`. No change to layout/placement of existing rooms.
**Minor known cosmetic (corner counters):** a corner counter sits ~0.55 m off the east wall, so the
service gap (~0.25 m) is narrower than a person the keeper stands *at* the till with a small hidden
overlap into the solid waist-high bench (torso reads above the counter, legs behind it). Facing and
side are correct; it reads as "keeper at the till." If Lane D/Fable want real standing clearance I can
nudge corner counters ~0.2 m off the wall but that changes those rooms' committed placement +
screenshots, so it's a decision, not a bug.
**Two contract-drift corrections for the F runbook** 2's Lane-C line was written against an earlier
draft of my API): (1) there is **no `opts.registry`** registry override is the one-time global
`mergeRegistry(registry)` (a per-build registry mutation broke same-seed determinism; removed in the
review pass). (2) `recipe.counterPos` is a **placement-strategy string** (`door|forward|back|corner`),
**not** a world position use `room.counter.stand` for the keeper pose.
---
## What shipped
All files under my lane's ownership (`web/js/interiors/*`, `web/interior_test.html`) no other lane's
files touched. Lane A's `core/registry.js` hadn't landed, so the lane is **fully standalone** (its 9
type recipes live in `theme.js`) with a `mergeRegistry()` hook for Lane F to wire the registry later.
| file | role |
|---|---|
| [interiors.js](web/js/interiors/interiors.js) | **public API** `buildInterior(shop, THREE, opts)` |
| [theme.js](web/js/interiors/theme.js) | 9 shop-type recipes (archetype/wallpaper/floor bias, clutter, counter pos, fittings mix, stock kind, signage) + aliasing + registry-override hook |
| [shell.js](web/js/interiors/shell.js) | room shell: floor/walls/ceiling from lot×archetype, glazed shopfront + street backdrop, blocked back doorway, interior lighting |
| [fittings.js](web/js/interiors/fittings.js) | parametric fittings kit (ported from 90sDJsim + extended): bins, crates, racks, 4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner racks, armchair, escalator, pegboard, barred window, returns slot, art frames |
| [layout.js](web/js/interiors/layout.js) | the placer: per-archetype zones, shuffled wall-slot system, occupancy grid, **guaranteed door→counter flood-fill path** |
| [stock.js](web/js/interiors/stock.js) | v1 visual stock: pooled canvas-texture sleeves/spines/boxes/garments/snacks with price stickers (dig.js trick) + `stockAdapter` hook for BaseGod data |
| [context.js](web/js/interiors/context.js) | build/dispose/seed backbone: independent seeded sub-streams, shared-geometry cache, leak-free `disposeAll()` |
| [glb.js](web/js/interiors/glb.js) | **optional** GLB hero-prop upgrade reads Lane E's `manifest.json`, swaps primitives for depot GLBs, placeholder-persists. OFF by default; primitive fallback always |
| [interior_test.html](web/interior_test.html) | standalone test page (below) |
## Public API (the contract Lane B/F build against)
```js
import { buildInterior } from './js/interiors/interiors.js';
import * as THREE from 'three';
const room = buildInterior(shop, THREE, opts?);
scene.add(room.group);
// … player walks; on hitting room.exits[i], fire procity:exitShop …
room.dispose(); // frees ALL per-room GPU resources; removes group from parent
// room = {
// group, // THREE.Group, self-contained (lights included)
// spawn: { x, z, ry }, // enter just inside the door, facing Z (into the shop)
// exits: [ { x, z, w, toStreet } ], // door back to street
// places: [ …meshes w/ userData ], // interactables: counter, bin, case, fridge, returns, exit
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = keeper (Lane D) spawn pose
// dims: { W, D, H, archetype, type },
// placement, // deterministic summary (deep-equal per seed)
// pathOK, buildMs, counts(), dispose()
// }
```
`shop` is tolerant recognized fields `{ id, type, name, seed, storeys, lot:{w,d} }`:
- **seed** (uint32) is the ONLY randomness source; falls back to a hash of id/name/type if absent.
- **type** any CITY_SPEC type (`record opshop toy book video pawn milkbar dept stall`) **or** a
thriftgod/Overpass alias (`music charity pawnbroker market video_games …`); defaults to `opshop`.
- **lot** `{w,d}` metres the room adapts to it (never exceeds it); absent archetype ranges decide.
- **storeys** drives ceiling height 3.24.5m.
- `opts`: `{ archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, useGLB?, manifest?, manifestUrl? }`.
Registry override is a **one-time global** `import { mergeRegistry } from interiors.js; mergeRegistry(registry)`
at init (NOT a per-build opt a per-call mutation of the shared recipe table would break same-seed determinism).
**Coordinate convention** (shared with a future Lane B wiring): `+Z` = door/street side, `Z` = back,
origin = room centre, floor `y = 0`. Everything is in the returned `group`'s local space.
### For Lane B (door wiring, later)
`buildInterior` is a pure function call it on `procity:enterShop`, add `room.group` to the scene,
teleport the player to `room.spawn`. On `procity:exitShop` (player reaches `room.exits[0]`) call
`room.dispose()` and restore the street. The street window fake is Lane B's; I own the inside view
(a bright canvas street backdrop plane is already behind the glazing).
### For content phase (BaseGod / GODVERSE)
Pass `opts.stockAdapter = (shop, slotKind) => ({ texture }) | ({ mesh }) | null`. Returning a texture
skins that stock item's face; a mesh replaces the item; null procedural placeholder. No layout/
fittings changes needed. `slotKind ∈ { sleeve, spine, box, snack, magazine, garment, treasure }`.
## Test page — `web/interior_test.html`
Serve: `cd web && python3 -m http.server 8130` open `/interior_test.html`.
- seed input · type dropdown (9) · archetype override (auto + 5) · lot `w×d`/auto · re-roll · seed ±
- first-person walk (PointerLock + WASD) with occupancy-grid collision + room clamp
- debug toggles: **wireframe** (F), **occupancy grid** (G), **path** (spawn/counter markers)
- **▶ 50-room soak test**: builds+disposes 50 seeded rooms, asserts ms/room, leaked geometries vs
baseline, and determinism (same seed identical placement)
## Acceptance — all met
| requirement | result |
|---|---|
| Same seed twice identical room (deep-equal placement) | 0 mismatches / 810 builds |
| All 9 types × 5 archetypes render sensibly | contact sheet `docs/shots/laneC/_grid_9types_5archetypes.jpg` |
| Build < 50ms/room | steady-state ~4ms, clean-soak worst ~8ms (first-ever build ~60ms one-time cold-JIT; occasional ~60ms GC pauses only under back-to-back torture loops not a real per-room cost) |
| Dispose leak-free (info.memory baseline) | soak leak: geo 0, tex 0 |
| Flood-fill doorcounter always exists | 0 path-fails, 0 carves / 810 builds |
| Runs with zero assets & zero network | seeded flat-colour fallback under every texture; map only assigned on load |
**Full sweep** (9 types × {auto+5 archetypes} × 3 seeds × {null,4×4,5×14,20×20,3×3} lots = 810 builds):
`throws 0 · pathFail 0 · determinismFail 0 · carved 0`. Warm perf: all < 50ms (worst 33.2ms).
**Soak** (50 rooms): `avg 4.0ms · worst 9.6ms · leakGeo 0 · leakTex 0 · detFail 0 · pathFail 0`.
## Key decisions & notes for review
- **90sDJsim `fittings.js` ported** (it was written to be lifted): `clothesRack`, `shelfUnit`,
`recordBin`, `table`, `counter`, `pegboard`, garment-silhouette canvas trick. Extended with the
fittings CITY_SPEC's registry needs (cube/metal/book/VHS shelving, glass case, fridge, spinner,
magazine rack, armchair, escalator prop, returns slot, barred window, listening corner).
- **thriftgod `buildShop` ported**: the 5 archetypes, the shuffled wall-slot system, bins with fanned
sleeves wearing covers, corner counter+till, crooked wall art, blocked back doorway.
- **Determinism** via independent seeded sub-streams keyed by a salt string (`ctx.stream('wallpaper')`
etc.) so adding a fitting in one subsystem never shifts another subsystem's picks.
- **Leak-free** via a tracked build context: per-room geometry/material/CanvasTexture/tiled-file-texture
are freed in `dispose()`; globally-cached file textures (`core/loaders.loadTex`) are shared and not
disposed (bounded cache). Dispose-before-texture-load race is guarded (`_disposed`).
- **Perf**: shared-geometry cache (`ctx.boxGeo/planeGeo/cylGeo`) collapses the hundreds of identical
stock items in a room down to ~80110 geometries; spine density tuned so a book barn is ~600 meshes.
- **GLB upgrades**: `web/assets/manifest.json` (Lane E) is read if present, primitive fallback always.
Not present yet 100% primitives, which is why the test page runs asset-free.
## Integration with sibling lanes (landed in parallel)
- **Lane A `core/registry.js`** its `SHOP_TYPE_IDS` match my 9 recipe keys exactly. I kept the
interior recipes local (the registry doesn't carry archetype-bias/clutter/counter-pos/stock detail),
with `mergeRegistry()` as the one-time override seam for Lane F.
- **Lane E `web/assets/manifest.json`** its `fittings` section (9 depot GLBs with footprints) is now
wired through `glb.js` (opt-in `useGLB`, primitive fallback). Depot GLBs may not be uploaded yet;
verified the fallback (unreachable depot primitives persist, no crash, no leak).
- **Lane D `citizens/keepers.js`** its `keepers.spawn(group,{x,z,ry,shopId,type})` now has a clean
source: `room.counter.stand` (also on `place.userData.keeperStand`). See the *Update (b)* section at
the top. Verified the pose is on the service side, faces the customer, and is deterministic.
## Adversarial review — 5 findings, all fixed
Ran a 5-dimension adversarial multi-agent review (determinism / disposal-leaks / path-guarantee /
three-api / edge-robustness) with a per-finding verify pass. 5 real defects surfaced; all fixed and
re-verified (830-build sweep + 50-room soak green after each fix):
| # | sev | file | defect | fix |
|---|---|---|---|---|
| 1 | med | interiors.js | per-build `opts.registry` permanently mutated the shared recipe table latent same-seed determinism break across differing registry states | removed per-build option; registry override is now a one-time global `mergeRegistry()` (Lane F setup) |
| 2 | low | interiors.js | explicit `null` opts threw (`opts = {}` default only covers `undefined`) | normalize `opts = opts \|\| {}` |
| 3 | low | shell.js | empty `archetypeBias` array `bias[0][0]` on `[]` throws | fall back to uniform bias when missing **or** empty |
| 4 | med | layout.js | fittings pulled by the path loop left phantom interactables in `places` (dangling after dispose) | collect `places` from survivors at the end, not incrementally at placement |
| 5 | low | layout.js | `rebuildOcc` re-stamped wall-mounted fittings that `placeAtWall` never stamped phantom floor obstacles after a blocker-pull | mark wall-mounted `noStamp`; skip in `rebuildOcc` **and** exclude from the removable pool |
Verify evidence (post-fix): `nullOpts ok · phantomPlaces 0/332 · determinismAcrossBuilds identical ·
emptyBias ok · sweep 810 {throws 0, pathFail 0, detFail 0} · soak {avg 4.2ms, worst 8ms, leakGeo 0,
leakTex 0}`. (Reviewers also confirmed no disposal/leak or three.js-contract defects.)
## Not done (out of scope for v1 / depends on other lanes)
- Real item data / economy (content phase `stockAdapter` hook is ready).
- GLB hero props are **wired** (`glb.js`, opt-in) but not visually validated end-to-end needs the
depot GLBs actually uploaded (Lane E) + `useGLB` flipped on by Lane B/F. Primitive path is the tested baseline.
- Interior-mapping shader for street windows (Lane B stretch goal).
- Back-room beyond the blocked doorway (v2).