PROCITY/C-progress.md
m3ultra 82b711f962 Lane C R13 (v3.0-beta, the district): band_room + rsl archetypes, 4-piece band, RY audit (?gigs=1)
- theme.js: band_room (small/scuzzy tin shed) + rsl (carpet-and-flock club) venue recipes,
  reusing the pub machinery via a new per-kind venueSpec (deck size/height + crowd cap).
- layout.js: parametrized placeStage/placeWatchPoints; 4th `drums` pose seated on the riser
  (debt #3 — the band is now a 4-piece across all venues), stage.riserY + per-pose y.
- interiors.js: robust gig-<genreKey> resolution (gig.genreKey -> shop.genreKey -> kind
  default) matching E's renamed manifest beds (debt #1); API doc for the 4-piece + riserY.
- LANE_C_PUB.md: 3-kind venue contract + pinned ry convention and the empirically-verified
  keeper/browser back-to-front finding + source-normalise fix for Lane D (debt #4).

Verified fresh (seed 20261990 + 90-build soak): watch caps 8/8/12, gigKeys
gig-pubrock/gig-grunge/gig-covers, determinism 0, path/carve 0, gig-night worst RSL 237 draws
asset-free (16 rigs) / 77 GLB <=350, leak geo0/tex0. Shot: docs/shots/laneC/rsl_gig_r13.jpg.
Lane F: bump flags_check band==3 -> ==4 (tools/flags_check.py:674,794) — RED by design.

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

534 lines
40 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-15 (round 13) · owner: PROCITY-C · reviewer: Fable
---
## 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).