# LANE C — interiors contract (venues + real-town class mapping) → Lanes A/D/E/F > **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. §0–§5 describe the *shipped* > venue state; changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (audit). > **v3.1 amendment:** §3 became the `stage.backline[]` contract (ledger #5 — the amp unification). > **v4.0-beta amendment (2026-07-16):** **§6 added — the real-town shop-class → archetype mapping** > (ledger #2, the density widening; C→E handshake). The frozen venue contract (§0–§5) is unchanged. *The district ships **three** venue archetypes behind `?gigs=1` — `pub`, `band_room`, `rsl`. Lane A converts a chosen shop to a venue kind in place (`shop.type = kind`); C keys the interior recipe off `shop.type`. All three return the SAME shape (below), so Lane D drops rigs in and Lane F reads state with no per-kind branching. Non-venue rooms are byte-identical (venue code runs only for `recipe.venue`, on its own seed sub-streams). Verified fresh (seeds 20261990/1990/7/42/101 + 90-build soak): 0 throws · 0 carves · 0 path-fails · 0 determinism-fails · leak geo0/tex0 · worst build 8.3 ms. Budgets in §3.* --- ## 0. The pose `ry` convention (pinned) — READ THIS FIRST **Canonical convention (every C pose uses it):** a pose's `ry` is the yaw of a thing whose **front / outward normal is local −Z at ry=0**, i.e. world-facing `(−sin ry, −cos ry)`. `counter.stand`, `browsePoints`, `watchPoints` and `bandPoses` ALL follow it, and the box placeholder (`placeholder.js`) is built to it (toes at −Z). `keepers.js` greet math assumes it. **Drop a rig in at a pose's `ry` and it faces the right way — no per-consumer flip.** **Why no flip (the R13 fix, for the record):** the GLB fleet meshes ship facing local **+Z** at ry=0 — the opposite of the −Z convention (verified: the anterior toe-bone vector `toe − foot` reads `(≈0, +0.99)`). Before R13 this was compensated per-consumer, inconsistently: `band.js` flipped by π (band/crowd correct, placeholders backward) while `keepers.js` did not (placeholders correct, **keepers + browsers 180° backward whenever the GLB fleet was loaded** — they'd been shipping back-to-front). **D fixed it at the source in `rigs.js buildFigure`: the cloned `inner` is rotated π about Y so every GLB rig faces −Z**, matching the placeholder and every C pose. All consumer flips were then deleted. Net: keepers, browsers, band and crowd all face correctly, GLB and placeholder modes are consistent, and C's pure-data poses never moved (no golden touched). If you add a new rig consumer, plant it at the pose `ry` with no flip. --- ## 1. What `buildInterior` returns for a venue (`shop.type` ∈ `pub` | `band_room` | `rsl`) Everything below is **pure data on the return** (room-local coords, the ry convention in §0). Lane D drops rigs straight in; Lane F reads state — no new lifecycle vs. the R9 keeper/browser seam. ### `room.stage` — the band stage, a **4-piece** (null for non-venues) ```js room.stage = { x, z, // deck centre (against the back wall) w, d, // deck size (m) — varies by kind (table below) deckY, // deck top surface height (m) — the front line stands here riserY, // drum riser top height (m) = deckY + 0.16 — the drummer sits here frontZ, // z of the deck lip (the crowd stands in front of this, +Z) bandPoses: [ { x, z, ry, role, y, seated? } ×4 ], } ``` - `bandPoses[0..2]` = the **front line**: `role` ∈ `guitar` / `vocal` / `bass`, across the deck lip, `y = deckY`. - `bandPoses[3]` = the **drummer**: `role: 'drums'`, `seated: true`, up-stage centre on the drum riser, `y = riserY` (= `deckY + 0.16`, the `fittings.js` stage riser box). - **Lane D:** lift each member to its own `pose.y` (front line = `deckY`, drummer = `riserY`). `role` hints the instrument — E's GLBs via `opts.instrumentFor(role)` (electric_guitar/bass_guitar/mic_stand/drum_kit), primitives otherwise (asset law). `seated:true` → seated bob. `ry = π` already faces the audience (+Z) — no flip (§0). - The band is a **4-piece** for v3.0 (front-line trio + drummer). Reverting to a trio would be a version bump (drop `bandPoses[3]`), not a runtime toggle. ### `room.watchPoints` — the audience, **cap by kind** (empty `[]` for non-venues) ```js room.watchPoints = [ { x, z, ry, dance, slotIndex } ] // seeded floor poses, all facing the stage centre ``` - **Crowd cap by kind: `pub` 8 · `band_room` 8 · `rsl` 12** (the RSL is the crowd-cap stress case). The room fills what geometry allows up to the cap; **the count IS the crowd cap** (F asserts crowd ≤ `watchPoints.length`). - `dance` seeded **~⅓ true** ("a bit of both"). D: `dance:false` → stand-and-watch idle; `dance:true` → bob/sway with a **seeded phase offset** (use `slotIndex`/coords). Every point walkable, reachable, clear of the keeper stand + browse points + stage/PA. Deterministic per `shop.seed`. ### `room.counter` / keeper — unchanged The bar is a normal counter; the barkeep is just a keeper at `room.counter.stand` (R9 seam). Faces the customer correctly under §0. ### `room.audio.gigKey` — the live bed `room.audio = { musicKey, toneKey, gigKey? }`. The manifest key **is** the gigKey, canonical form **`gig-`** — no mapping table. F passes gig state via `opts.gig`: ```js buildInterior(rslShop, THREE, { gig: { on: true } }) // → room.audio.gigKey === 'gig-covers' ``` - Genre resolves `opts.gig.genreKey` → the venue's own `shop.genreKey` (A sets it) → a kind default (`pub`→pubrock, `band_room`→grunge, `rsl`→covers). Present **only when a gig is on**. - **Prefer `gigKey` over `musicKey`** while the gig plays. E's beds are `gig-pubrock` / `gig-grunge` / `gig-covers`; a missing key → silence (audio law), band still visible. - C hand-builds `'gig-'+genreKey` (byte-identical to citygen's `gigKeyFor`, selfcheck-asserted) rather than importing it — deliberately, to keep the interiors lib standalone (it never imports citygen). ### The venue table (C-owned tuning) | kind | archetype bias | ceiling | deck `w × d`, `deckY` | crowd cap | genre → gigKey | dressing | |---|---|---|---|---|---|---| | `pub` | wide / hall | 3.6 m | `0.58·W × 2.0`, 0.32 | 8 | pubrock → `gig-pubrock` | pub tables, sticky carpet, bar | | `band_room` | cosy / pokey / wide | **3.4 m (low)** | `0.62·W × 1.6`, **0.20** (barely a riser) | 8 | grunge → `gig-grunge` | near-empty standing room, tin-shed dingy | | `rsl` | hall / wide | **4.0 m (tall)** | `0.50·W × 2.4`, **0.40** | **12** | covers → `gig-covers` | flock walls, club carpet, bistro tables, members' bar | ## 2. Quiet-night variant No `opts.gig` → a normal interior: `stage` + `watchPoints` are still returned as data, but there's **no `gigKey`** (room-tone + maybe the seeded radio `musicKey`), and D spawns no crowd. A seeded dark night reads true — the venue is just a quiet pub/club. ## 3. `stage.backline[]` — the amp slots (v3.1, ledger #5) `room.stage.backline = [ { x, z, ry, y } ×2 ]` — seeded up-stage amp/spare-cab poses, **pure data** (C plants no amp meshes as of v3.1). This is the unified backline: **Lane D plants E's `guitar_amp` GLB here** (its own primitive fallback under `?noassets`) — one amp system, GLB when present. C's old primitive ampStacks are **dropped** from all three venue recipes. - **slot[0]** = stage-right (the primary amp) at `(stage.x + stage.w·0.36, deckY, stage.z − stage.d·0.22)` — identical to D's R15-verified plant, so wiring D's amp to `backline[0]` moves nothing on screen. **slot[1]** = stage-left spare cab (mirror). `ry = π` faces the audience (+Z, the §0 convention); `y = deckY`. - **Guarantees** (verified fresh, seed 20261990, pub/band_room/rsl): every slot is **up-stage of the front line**, flanks the drum riser, **clear of all 4 bandPoses** (nearest-bass clearance **0.87 m pub / 0.70 m band_room / 0.92 m rsl**) and **all watchPoints** (≥1.95 m), and **on-deck** (within the deck footprint). Deterministic per `shop.seed`. **Lane D:** verify no interpenetration once wired (C's R15 clearance discipline). - **Draw impact:** dropping C's primitives shaved ~4 draws off each empty venue shell (drawSweep glb-off: pub 122 · band_room 120 · rsl 131 ≤350); D's GLB/primitive at `backline[0]` adds it back on the gig night. - *v3.2 (parked):* the drummer sit clip, instrument LOD, and C's `audioEmitter` idea. ## 4. Budgets (measured R14, seed 20261990, fresh context) - **Empty shells** (`drawSweep` glb-off): band_room **124** · pub **126** · rsl **135** — town-wide worst 161 (opshop/hall). ≤ 350. ✓ (identical to R13 — C geometry unchanged). - **Gig-night interior draws**, D's `GigCrew` with the real instrument GLBs + amp (localdepot): pub **60** · band_room **52** · rsl **71**. Asset-free (placeholder crowd, no GLBs) worst RSL **237** (16 rigs). ≤ 350. ✓ - **Tris**: fleet peds ~2.7k each; E's instrument GLBs **14k each** (decimated to the ≤15k target), 5 on a stage = 70k — the instruments dominate. RSL gig-night ≈ **182k tris** once the async GLBs settle. The interior gate is **draws** (passes); tris are the *street* budget (200k), so this is not an interior gate. *Note for E/F:* F's gig-night smoke reports ~34k because it measures **before** the async instrument GLBs load; the settled scene is ~180k. A camera-distance LOD on instruments is a v3.1 candidate. - **Determinism / release-law**: C's interior code is unchanged since R13; D's continuity + RY-fix ride disjoint rng namespaces (`ctx.stream(salt)` vs `rng(citySeed,kind,id)`) and cannot perturb C's placement or any golden. 90-build venue soak: 0 determinism · 0 carve · 0 path-fail · worst 8.3 ms; leak geo0/tex0. ## 5. The archetypes (C-owned) `band_room` = a tin shed: small (cosy/pokey), low ceiling, barely-a-riser deck, near-empty standing floor, bar in the corner — the PA louder than the room. `rsl` = carpet-and-flock club: the biggest floor in town (hall/wide), a proper raised stage at one end, bistro tables round the edge, members' bar, the biggest crowd cap. Both reuse the `pub` venue machinery (`theme.js venueSpec` + `layout.js` placeStage/placeWatchPoints); only the dressing + `venueSpec` differ. Stage/PA keep GLB slots for Lane E (primitive fallback holds); the backline amp is D's, planted at `stage.backline[0]` (see §3). --- ## 6. Real-town shop-class → interior archetype mapping (v4.0-beta, ledger #2) → Lane E **Directive (John, kickoff — SUBTLE: texture, not takeover).** The secondhand shops stay the **heroes** of the town (they *are* the game). The widening adds general retail as *texture* — the everyday main-street staples a real Australian town has — mapped onto **existing** archetypes (**no new archetypes this round**). The player should feel the town's more alive, not that the game changed. **How it's applied (E owns the emission).** `plan_osm` keeps only **registry types** (`SHOP_TYPES` — unknowns flatten to `opshop`). So E maps each fetched OSM `shop=*` / `amenity=*` value to a **registry type** below and bakes it into the cache's `shop.type`. Every target is an existing type, so C's recipes, A's registry, and the budgets are all unchanged. Unknown/unlisted classes degrade safely to `opshop` (R18-proven: 18 real OSM types all built valid). **Heroes — secondhand (unchanged; NEVER subsampled):** | OSM class | → type | |---|---| | `charity`, `second_hand`, `antiques`, `vintage` | `opshop` | | `books`, `bookshop` | `book` | | `music` | `record` | | `video`, `games` | `video` | | `pawnbroker` | `pawn` | | `toys` | `toy` | **Widened — everyday main-street texture (general retail):** | OSM class(es) | → type | wears it as | |---|---|---| | `bakery`, `cafe`†, `confectionery`, `pastry`, `chocolate`, `deli`/`delicatessen`, `butcher`, `cheese`, `coffee`, `tea` | `milkbar` | the food counter | | `convenience`, `kiosk`, `general`, `dairy` | `milkbar` | the corner store | | `newsagent`, `stationery` | `milkbar` | papers + magazines | | `chemist`, `pharmacy`, `cosmetics`, `perfumery` | `milkbar` | dispensary counter | | `clothes`, `fashion`, `boutique`, `shoes`, `bag`, `jewelry`/`jewellery` | `dept` | the dressed retail floor | | `department_store`, `variety_store`, `supermarket` | `dept` | big general retail | | `hardware`, `doityourself`, `trade`, `electronics`, `mobile_phone`, `furniture`, `houseware`/`homeware` | `dept` | shelved goods | | `greengrocer`, `florist`, `farm`, `garden_centre` | `stall` | produce/plants on trestles | † `cafe`/`fast_food` are `amenity=*`, not `shop=*` — fetch them from the amenity tag; everything else is `shop=*`. **Fallback:** any fetched class not listed → `opshop`. **Copy-paste map for E (`build_towns.py`):** ```json { "charity":"opshop","second_hand":"opshop","antiques":"opshop","vintage":"opshop", "books":"book","bookshop":"book","music":"record","video":"video","games":"video", "pawnbroker":"pawn","toys":"toy", "bakery":"milkbar","cafe":"milkbar","confectionery":"milkbar","pastry":"milkbar","chocolate":"milkbar", "deli":"milkbar","delicatessen":"milkbar","butcher":"milkbar","cheese":"milkbar","coffee":"milkbar","tea":"milkbar", "convenience":"milkbar","kiosk":"milkbar","general":"milkbar","dairy":"milkbar", "newsagent":"milkbar","stationery":"milkbar", "chemist":"milkbar","pharmacy":"milkbar","cosmetics":"milkbar","perfumery":"milkbar", "clothes":"dept","fashion":"dept","boutique":"dept","shoes":"dept","bag":"dept","jewelry":"dept","jewellery":"dept", "department_store":"dept","variety_store":"dept","supermarket":"dept", "hardware":"dept","doityourself":"dept","trade":"dept","electronics":"dept","mobile_phone":"dept", "furniture":"dept","houseware":"dept","homeware":"dept", "greengrocer":"stall","florist":"stall","farm":"stall","garden_centre":"stall" } ``` Default (not in the map) → `opshop`. **Subtlety guardrails (E):** - Fetch only these tasteful staples — **not** every OSM `shop=*`. Skip vehicle/tyres/fuel, funeral, storage, industrial, and pure services the player never browses (`hairdresser`, `beauty`, `travel_agency`, `bank`, `laundry`, `estate_agent`). - **Never** subsample the secondhand heroes. - If a town's general-retail count would visually **drown** the secondhand strip, seed-subsample the *texture* classes (the milkbar/dept/stall targets) toward **~2–3× the secondhand count**, and count the drops in `norm.dropped` like every other drop. **Why these targets:** `milkbar` (counter + fridge + magazine rack + glass case) is the canonical Australian counter shop — it wears cafe/bakery/deli/newsagent/chemist naturally; `dept` (cube shelves + clothes racks + glass cases) wears clothes + hardware + big retail; `stall` (trestles + crates) wears produce + florist. Three general-retail looks + the six secondhand heroes + the three venues = variety with no new archetypes. **Pre-verified (seed 20261990, real-town lot 8.5×14):** milkbar/dept/stall/opshop all build valid (0 carves, ≤143 draws); `dept` reads as a clean **dressed-retail floor** (cube shelves + clothes racks + counter, the escalator does *not* dominate the ~7.9×11.7 room), so `clothes`/`hardware` → `dept` is confirmed. C re-checks the full result in the five-town door-soak (ledger #6) once E's caches land. (FYI: C's `theme.js canonicalType`/ALIAS already recognizes several of these — `deli`/`corner_store`→milkbar etc. — for any direct `buildInterior(rawClass)`; the authoritative mapping for the caches is this table.)