PROCITY/C-progress.md
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:49:49 +10:00

1807 lines
132 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-08-07 (round 42) · owner: PROCITY-C · reviewer: Fable
---
## Round 42 (§42.2b) — MAKE THE DECKS READ: the composition half
Lane E fixed the asset in §42.2a (chassis → Technics silver, the record → black vinyl, the inside-out
platter turned right way out). This is the other half of the same fault: **the booth still did not read
as a DJ setup from where a player stands, because the bench was a black slab in a place the player was
not looking, turned so they saw its end.** Nothing was driven, no mechanic was added (ruling 5). What
changed is colour, structure, orientation and placement.
**Everything green: 0 throws · 0 path-fails · 0 carves · 0 determinism-fails · 0 leaks · 0 console
errors · worst warm build 13.0 ms (budget 50) · worst room 123 draws against the ≤350 law, UNCHANGED.**
### 0. FIRST: the 122-vs-123 reconciliation — neither of us was wrong, the instrument was unpinned
F's §41.6 close read **123 @ `dept`/auto** where this lane published **122**, on the same tree, and F's
note said "the difference is C's measurement environment". That is exactly right, and here is the
environment: **`renderer.info.render.calls` counts what survives FRUSTUM CULLING, and the sweep camera
holds a fixed VERTICAL fov (70) while taking its aspect from the browser window.** A wider window sees
more of the room and therefore reads more draws.
Measured on ONE tree, running **F's own `SWEEP` function from `tools/qa/r41_integration.py` verbatim**,
changing nothing but the viewport:
| viewport | camera aspect | GLB on | GLB off | whose harness |
|---|---:|---:|---:|---|
| 1024×768 | 1.333 | 120 @ `dept/auto` | 88 @ `stall/hall` | — |
| 1280×1024 | 1.250 | 116 @ `dept/auto` | 88 @ `stall/hall` | — |
| **1280×800** | **1.600** | **122** @ `dept/auto` | 92 @ `opshop/hall` | **C's R41 harness** |
| 1440×900 | 1.600 | 122 @ `dept/auto` | 92 @ `opshop/hall` | — |
| **1280×720** | **1.778** | **123** @ `dept/auto` | 93 @ `opshop/hall` | **F's R41 harness** |
| 1920×1080 | 1.778 | 123 @ `dept/auto` | 93 @ `opshop/hall` | — |
| 2560×1080 | 2.370 | 124 @ `dept/auto` | 95 @ `opshop/hall` | (21:9 bound) |
Corroborating detail: the phantom control decomposes identically at both aspects — 197123 = **+74** on
F's harness, 196122 = **+74** on mine. Same bug, same magnitude, two readings of the same tree.
**THE TRUE NUMBER IS 123, and the margin is 227** — F's number, and I am adopting it, because the law
has to be the worst a *player* can produce and a player's window is wide (16:9 is the common case; 21:9
reads 124). My 122 was a 16:10 reading.
**Fixed, not just explained.** `drawSweep` now pins its own measurement aspect at **16:9**
(`MEASURE_ASPECT` in `interior_test.html`) and restores the window's aspect afterwards, so the number
stops depending on who ran it. **Control demonstrated:** `drawSweep({ glb: true, aspect: 1.25 })` on the
same tree reads **116** — the instrument can still be made to lie, on purpose, on demand.
*(Two things ruled out on the way, so the aspect finding is not a guess: I re-ran F's arm read-only and
it reproduced 123/93/197 exactly; and E's "+0 draws on all 46 repainted assets" was checked
independently by counting glTF primitives in `web/assets/models/` against the pristine copies in
`pipeline/.props_orig/r42/`**46/46 identical primitive counts**, so the repaint cannot be the cause.)*
### 1. E's numbers, reproduced before building on them
| E's claim | reproduced here | how |
|---|---|---|
| chassis `#b9bdc2` rel. luminance **0.506** | **0.5060** | WCAG relative luminance from the hex |
| vs `djBooth` bench `#3a3128` (0.0325) = **6.74:1** | **6.74:1** | ditto — exact |
| R41 chassis (linear 0.05) vs the same bench = **1.21:1** | **1.21:1** | ditto |
| `deck_1200_rigged` has no plinth; ~43 mm missing | **confirmed, and sharpened** | see §4 |
| 46 repainted assets are **+0 draws** | **confirmed** | 46/46 identical glTF primitive counts vs `.props_orig/r42` |
**And the conclusion I drew from them is not E's.** E's advice was "the bench should stay dark, a
lighter bench reduces the ratio". True, and not the binding constraint: 6.74:1 and 4.73:1 are both far
above the 3:1 WCAG threshold for a graphical object, so deck-to-bench contrast was already solved by
E's half. The fault that survived is a claim about pixels, so I measured pixels.
### 2. THE MEASUREMENT: "a plain black box" is a statement about pixels
Six booths rendered at eye height, 2.0 m off the bench's own face, counting inside the booth's own
projected screen rect (`pixels.py`, scratch). BEFORE = the R41 lane-C tree **with E's §42.2a repaint
already live** — so this isolates my half and nothing else.
| room | booth px < rel.lum 0.05 | bench mass < 0.05 | distinct 4-bit colours | mean saturation |
|---|---:|---:|---:|---:|
| `record`/auto/1990 | 46.4% **28.7%** | 53.8% **32.4%** | 507 359 | 0.385 0.454 |
| `record`/gallery/4242 | 65.1% **10.3%** | 73.7% **7.8%** | 172 526 | 0.220 0.401 |
| `record`/pokey/7 | 88.4% **17.1%** | 93.1% **7.8%** | 150 408 | 0.227 0.455 |
| `pub`/auto/1990 | 20.1% 70.0% | 16.4% 67.6% | 154 177 | 0.214 0.295 |
| `band_room`/auto/1990 | 65.7% **20.6%** | 64.4% **5.9%** | 121 439 | 0.285 0.467 |
| `pawn`/auto/1990 | 59.5% **13.4%** | 69.4% **5.2%** | 261 425 | 0.299 0.429 |
| **mean, all six** | **57.5% → 26.7%** | **61.8% → 21.1%** | **228 → 389** | **0.272 → 0.417** |
| mean, excluding the pub | 65.0% 18.0% | 70.9% 11.8% | 242 431 | |
**The pub row is an occlusion artefact and I am not hiding it.** A 1.8 m `speaker_stack` stands
between the 2 m camera and that booth, so the crop is mostly speaker. It is a real composition
complaint about that one room (see §5), not a measurement of the bench.
### 3. WHAT CHANGED, and why each
**a. The bench stops being one rectangle.** `djBooth` was three boxes (body, top edge, a dark cavity)
and is now seven: recessed plinth · two end cheeks · apron under · apron over · a back panel · the
worktop. **Footprint (`w+0.12 × d+0.12`), height (`h+0.25`) and therefore every room's occupancy grid,
path guarantee and placement are untouched by the new geometry** every part is inside the volume R41
already reserved.
**b. Colour, decided by measurement not by taste.** The R41 bench was a 50/50 seeded coin flip between
`#3a3128` and `#2a2a30` two near-blacks. Now: carcass `#5a4630` / `#6b4526` (stained pine, red-brown
timber E's own R42 pub palette family), **worktop kept at `#1c1a18`**, plinth and bay interior
`#211d19`.
| pair | rel. luminance | WCAG |
|---|---:|---:|
| chassis `#b9bdc2` vs R41 body `#3a3128` | 0.5060 / 0.0325 | 6.74:1 (E's number) |
| chassis vs R42 carcass `#5a4630` | 0.5060 / 0.0677 | **4.73:1** |
| chassis vs R42 carcass `#6b4526` | 0.5060 / 0.0752 | **4.44:1** |
| **chassis vs the worktop it actually stands on `#1c1a18`** | 0.5060 / 0.0105 | **9.19:1** |
| black vinyl `#17171a` vs alloy platter `#c9ced3` | 0.0087 / 0.6126 | 11.29:1 |
**The deck's frame is the worktop, not the bench body** the deck is a 0.11 m object sitting on a
0.05 m slab so the number that governs whether the deck reads went **up** (6.74 9.19), while the
mass below it stopped being a void.
**c. An open record bay across the bench front.** The strongest single "this is a record shop's DJ
booth" signal available, and nearly free. `djBooth` now exposes a stock `slot`, so `stock.fill` puts
seeded records in it: pooled procedural sleeves by default, and **real GODVERSE covers under
`?stock=real`** (the record pack serves the `sleeve` slot). Pawn's bay gets `box` stock instead a
pawnbroker's bench must not read as a DJ booth.
*Built wrong first and caught by looking:* the bay was a solid dark box, which put an opaque front
plane in front of the records; it is now an actual opening (cheeks + aprons + back panel).
**d. Headphones on the mixer.** `stackOn` in the booth part spec: a part may ride another part, y
resolved off the `KIT` table so the primitive stand-in and the GLB agree. It costs **no footprint**
the bench is full edge to edge (0.51 + 0.23 + 0.45 across 1.55 m leaves two 0.14 m gaps and an HD25 is
0.23 wide), so the only free surface was upward, which is also where a real pair of cans lives.
**e. Turn the booth to face the player, and put it somewhere they look.** `spec.faceDoor`, on all four
`djBooth` specs. Two parts, and the second only exists because the first moved the problem:
* **the yaw** aim local +Z at the spawn point (`door x`, `D/2 1.5`), with the zone's own yaw as
fallback. `placeNearCounter` copies the counter's yaw, and a `corner` counter is at π/2, which
stands the booth square to a side wall.
* **the candidate order** aiming it made its deep axis narrow, which made a counter-side candidate
"fit" that never had, and the record shop's hero landed 2.5 m to the player's LEFT at the door, out
of frame. So a `faceDoor` spec's candidate list is now **sorted by `doorViewScore`**: angle off the
view axis, penalised under 2.2 m and over 7 m. Pure geometry, no stream draws, applied as a stable
sort to the already-shuffled list, so determinism holds.
**Measured over 190 → 189 booth rooms (4 types × 6 archetypes × 8 seeds):**
| | before | after |
|---|---:|---:|
| face within 45° of the player's entry line | 94 (49%) | **173 (92%)** |
| face within 60° | 117 (62%) | **176 (93%)** |
| booth within 35° of the door view axis | 105 (55%) | **142 (75%)** |
| booth within 60° | 153 (81%) | **156 (83%)** |
| facing away (≥120°) | 0 | 0 |
**f. A pre-existing bug this exposed: a counter-side fitting could be planted inside a wall.**
`areaFree` deliberately allows border cells (shelves hug walls) and `cellOf` clamps anything past the
edge back onto the border but `placeNearCounter`'s candidates are `counter ± gap` with no bound,
unlike every other placer. Measured on the record shop, seed 1990: the corner counter sits at z = D/4,
so `counter.z 2.35` puts the booth at z = 4.35 in a room whose back wall is z = 4.0 **0.77 m of a
0.86 m half-depth buried in plaster.** R41 never saw it because at the counter's own yaw the booth was
too deep to fit there at all. Fixed with an `inRoom` bound in `placeNearCounter`, for every
`zone:'counter'` fitting; a rejected candidate is not a lost fitting (this placer already falls back to
the aisle).
**g. The venue booth's fallback order.** `stage` was first, and the stage flanks are exactly where the
PA lives on `pub`/auto/1990 the booth landed **0.24 m from a 1.8 m `speaker_stack`** that hides it
completely. Open floor is now tried first, the stage flanks kept as a fallback rather than a preference.
### 4. THE PLINTH WORKAROUND — clearly marked, REMOVE IT IN R43
`kit41.BOOTH_PLINTH_FIX = { deck_1200_rigged: -0.043 }`, applied **only to the GLB**, never to the
primitive stand-in. E filed the fault; measured again here from the shipped GLB rather than taken on
report (`web/assets/models/procity_fit_deck_1200_rigged_01.glb`, node transforms applied):
| | `deck_1200_rigged` | `deck_1200` (the static twin, same source) |
|---|---:|---:|
| vertices | 8,740 | 11,138 |
| within 30 mm of the model's y-min | **9 (0.10%)** | 729 (6.55%) |
| within 43 mm | 69 (0.79%) | 739 (6.63%) |
| **1st percentile of vertex y** | **0.0524** | 0.0140 |
99% of the rigged deck sits 52 mm above the floor its own bounding box claims, and `glb.js` plants a
part at `y bb.min.y`. **0.043 m drops the feet onto the worktop and buries the nine stray vertices
inside the solid bench, where nothing can see them.** When R43 re-runs `rig_join.py` and the model gets
its plinth back, delete the constant and the `p.dy` term in `djBooth`'s `glbParts` map. Both are tagged
`[R42 §42.2b — WORKAROUND, REMOVE WHEN R43 FIXES THE ASSET]`.
**A second, smaller thing found by the same look, and this one is a real fix not a workaround:** the
worktop is a 0.05 m slab **centred** at y = h, so its top face is at h + 0.025 and R41 planted every
booth part at y = h, i.e. **25 mm sunk into its own bench**. Invisible while nothing moved; the moment
the plinth correction went in it buried the whole chassis and left a black disc floating on the bench.
All parts (stand-in and GLB) now stand on `TOP = h + 0.025`.
### 5. THE DRAW TABLE — worst room by shop type, F's 16:9 aspect, before → after
Both columns measured on this instrument at the same pinned aspect; BEFORE is the frozen R41 lane-C
tree with E's §42.2a repaint live.
| type | GLB off | GLB on | GLB on + `?stock=real` | `noKit` (the control arm) |
|---|---|---|---|---|
| `record` **(booth)** | 89 **89** | 81 **82** (+1) | 73 **74** (+1) | 63 63 (+0) |
| `pub` **(booth)** | 64 **65** (+1) | 59 **57** (2) | 59 **57** (2) | 45 45 (+0) |
| `band_room` **(booth)** | 57 **60** (+3) | 51 **55** (+4) | 51 **55** (+4) | 39 39 (+0) |
| `pawn` **(booth)** | 83 **83** | 72 **72** (+0) | 72 **72** (+0) | 64 64 (+0) |
| `opshop` | 93 93 | 105 105 | 105 105 | 102 102 |
| `toy` | 68 68 | 86 86 | 120 120 | 86 86 |
| `book` | 71 71 | 64 64 | 98 98 | 53 53 |
| `video` | 79 79 | 65 65 | 65 65 | 55 55 |
| `milkbar` | 83 83 | 70 70 | 70 70 | 70 70 |
| **`dept`** | 88 88 | **123 → 123** | **123 → 123** | 116 116 |
| `stall` | 89 89 | 82 82 | 82 82 | 72 72 |
| `rsl` | 73 73 | 60 60 | 60 60 | 52 52 |
| **WORST ROOM** | **93 @ `opshop/hall`** | **123 @ `dept/auto`** | **123 @ `dept/auto`** | 116 @ `dept/auto` |
**WORST ROOM IN THE GAME: 123 draws @ `dept`/auto, unchanged. Law ≤350 → MARGIN 227, unchanged.**
`dept` has no booth, so §42.2b spent **zero draws at the worst room**; the most spent anywhere is +4 at
`band_room`, which sits at 55. **The `noKit` arm is +0 on all twelve types** the control for the
control: everything I touched lives inside a kit spec, and dropping the kit reproduces the pre-R41 room
exactly as it did in §41.4.
The bay's records are the reason `record` moved by 1 and not by 9: `slot.pool` puts the whole row on
**three shared pooled face materials**, so it batches to at most three meshes and in a record shop
those are buckets the room's own bins already have, so it merges to zero. Under `?stock=real` the whole
bay is one atlas material. New GLB parts: `headphones_hd25` on `record_hero` and `venue` (+1 draw each);
`pawn` gets none.
### 6. SWEEP · SOAK · DETERMINISM · `?noassets`
- **`drawSweep`, 4 arms × 12 types × 6 archetypes = 288 rooms:** all 350, **0 console errors**.
Aspect control (`aspect: 1.25`): **116 @ `dept/auto`** the instrument demonstrably moves.
- **Soak, 60 rooms build+dispose+rebuild:** avg **3.61 ms**, worst **13.0 ms** (budget 50) · leak geo
**0** / tex **0** · determinism **0** mismatches · path-fails **0** · carves **0**. (Three independent
runs of the whole suite: worst build 17.3 / 9.2 / 13.0 ms build time is machine noise, every other
number in this section was byte-identical across all three.)
- **576-room corpus** (12 types × 6 archetypes × 8 seeds): **0 path-fails, 0 carves, 0 throws.**
- **Determinism, two FRESH browser contexts**, 240 rooms (12 types × 5 archetypes × 4 seeds), GLB on +
`?stock=real`, comparing placement, GLB id list, booth pose (position + yaw to 5 dp), browse/watch
points, keeper stand and stage: **byte-equal, sha256 `7c78674a…22c0` on both runs, 462,006 bytes.**
- **`?noassets` arm, all 60 type × archetype rooms:** **0 GLB · 0 manifest · 0 wardrobe · 0 stock-pack
requests.** The only 10 requests are the pre-existing `assets/gen/wall-*.jpg` / `tex-*.jpg` shell
wallpapers the same 10 as R41. Worst primitive room 71 draws, 20 booths built, 0 errors.
**CONTROL:** the same 60 rooms with the manifest handed in fetch **50 GLBs** the gate is not vacuous.
- **`kitAudit(manifest)`: 96/96 rows checked, 0 dimension errors, 0 missing, 0 pair errors, 0 oversize.**
It covers the new booth parts too (`headphones_hd25` is a catalogue row and a booth part id).
- **Booth count 190 189 over 576 rooms.** All three misses are `pub` rooms where the doorcounter
path guarantee **pulled** the booth and the §41.4 restore pass could not put it back (`pulled 79,
restored 68, lost 1`) the guarantee doing its job, not a placement failure. R41 lost two of the
same class. **watchPoints did not suffer: `pub` 361 → 363, `band_room` 384/384, `rsl` 560.**
### 7. `assets/wardrobe/` was spelled in two files (F's tidy)
`'assets/wardrobe/'` was written out in `web/interior_test.html` **and** `web/js/world/interior_mode.js`
a string that has to match a directory on disk, in two places. It is now declared once, in
`stockpack.js` next to the loader that resolves it, re-exported from `interiors.js`, and imported by the
test page. ** LANE B/F: `interior_mode.js` can drop its own copy for one import line** (LANE_C_NOTES
§42); I did not touch that file, it is not in this lane's editable set.
### 8. Shots — `docs/shots/laneC/`
`r42_dj_booth_door_ab.png` **the deliverable**: the DOOR view, before/after, same seed, same camera
(the room's own spawn), E's repaint live in both · `r42_dj_booth_before_after.png` the round's origin
(`r41_record_dj_booth.png` as committed) against the answer · `r42_dj_booth_door.png` (the after door
view alone) · `r42_record_shop.png` (record shop wide) · `r42_dj_booth_variants.png` (band room · pawn ·
a second record shop) · `r42_dj_booth_arms.png` (`?noassets` primitive · default · `?stock=real`).
Every booth-scale shot carries a **1.75 m scale post**; no shot here contains a humanoid.
### 9. Files
Changed: `kit41.js` (`BOOTHS` bay/`stackOn`/`dy`, `BOOTH_PLINTH_FIX`), `fittings.js` (`djBooth` rebuilt,
booth palette, `TOP` surface fix), `stock.js` (`binFan` `slot.axis` + `slot.pool`), `layout.js`
(`faceDoorYaw`, `doorViewScore`, the `inRoom` bound, `placeAnywhere` signature), `theme.js` (`faceDoor`
on the four booth specs, venue fallback order), `stockpack.js` + `interiors.js` (`WARDROBE_BASE` once),
`interior_test.html` (`MEASURE_ASPECT`, the import). `glb.js` deliberately **unchanged** the booth
bakes its own part heights, so the plinth workaround needed no edit to the loader.
**Not touched:** `web/assets/models/**`, `web/assets/manifest.json`, `pipeline/**`, `web/js/citizens/**`,
`web/js/world/**`, `web/index.html`, `tools/qa.sh`.
---
## Round 41 (§41.4) — THE SHOPS GET THEIR STOCK
Lane E's 110 new interior fittings are placed, the pub is furnished, the record shop has a rigged 1200
behind the counter, and 52 hand-prompted 1990s Australian garments hang on the op-shop rails at **zero
geometry cost**. New file `web/js/interiors/kit41.js` is the catalogue; `fittings.js` grew one generic
builder that covers all 110 and a DJ-booth builder; the recipes name pools, never asset ids.
**Everything green: 0 throws · 0 path-fails · 0 carves · 0 determinism-fails · 0 leaks · worst warm build
10.3 ms (budget 50) · worst room 122 draws against the 350 law.**
### 0. FIRST: the instrument was measuring a room that wasn't there
`drawSweep` this lane's own 350 instrument, the one F mirrors in `r39_transmission.py` cleared every
interior from the scene **except `current`**, the live room the page happens to be showing. So every
per-type number carried a constant offset from a *second, unrelated room* (seed 1990, type = the first
entry in the list, i.e. a record shop). It was invisible while nothing changed. The moment §41.4 furnished
that record shop, `toy` a type this round does not touch "gained" 4 draws in both modes.
Fixed before anything was costed: the sweep now clears **all** interiors and rebuilds `current` at the end.
**Every number below is on the corrected instrument.** Against R39/R40 prints of the same function the
values are lower; that is the phantom leaving, not rooms getting cheaper. The old worst reading of 188 @
dept was 116 of dept and ~72 of a record shop nobody was looking at.
### 1. The control arm — `opts.noKit`, and why it is exact
Every R41 spec is **appended last** inside its recipe, so `layout.js` hands each surviving spec the same
draws off the same `lay-count`/`lay-place` streams in the same order. `buildInterior(..., { noKit: true })`
drops exactly those specs and reproduces the pre-R41 room. That is what makes "the kit costs N draws" a
measurement rather than a claim and `toy`, which gets no kit by design, comes out **+0 in all three
modes**, which is the control for the control.
### 2. Draws by shop type — worst room per type, seed 1990, 5 archetypes + auto
| type | fittings placed (R41 additions) | OFF pre | OFF R41 | **ON pre** | **ON R41** | Δ | stock=real R41 |
|---|---|---:|---:|---:|---:|---:|---:|
| `record` | 24 tubs/crates (dig) · 13 tub racks · 12 wall boards · window ledge · 12 pinned sleeves · **the rigged DJ booth** | 69 | 85 | 61 | **78** | +17 | 70 |
| `opshop` | 12 bargain tubs · 12 racks · wall board | 89 | 92 | 102 | **105** | +3 | 105 |
| `book` | library trolley · 12 racks · 12 wall boards | 55 | 69 | 51 | **61** | +10 | 95 |
| `video` | ex-rental bargain tub · 12 wall boards · window ledge | 65 | 76 | 54 | **63** | +9 | 63 |
| `pawn` | 12 flight cases/sub · **808 + SP-1200 bench** · wall board | 67 | 81 | 60 | **70** | +10 | 70 |
| `stall` | **24 diggable bins** · 12 racks | 76 | 89 | 71 | **82** | +11 | 82 |
| `dept` | 02 columns · 12 racks · wall board | 80 | 87 | 116 | **122** | +6 | 122 |
| `milkbar` | **bain-marie / pie warmer** · 13 floor props · air-con | 72 | 80 | 68 | **69** | +1 | 69 |
| `pub` | **bar = `plywood_bar`** · 23 stage-flank venue kit · 36 seats · 36 floor props · air-con · DJ booth | 48 | 64 | 45 | **59** | +14 | 59 |
| `band_room` | bar = `plywood_bar` · 24 venue kit · 23 floor props · DJ booth | 43 | 56 | 39 | **50** | +11 | 50 |
| `rsl` | bar = `plywood_bar` · 13 venue kit · 36 seats · 35 floor props (pokies, raffle drum) | 56 | 73 | 52 | **60** | +8 | 60 |
| `toy` | **none — untouched by design** | 67 | 67 | 86 | **86** | **+0** | 120 |
**WORST ROOM IN THE GAME: 122 draws @ `dept`/auto, GLB on, with or without `?stock=real`.**
> **[CORRECTED IN R42 §42.2b — the true number is 123, margin 227.]** Every figure in this §41.4 table
> was read at a 16:10 viewport, and `drawSweep`'s camera holds a fixed VERTICAL fov while inheriting the
> window's aspect — so a wider window sees more of the room and reads more draws. At 16:9 (F's harness,
> and the sweep's pinned aspect from R42 on) this table reads 14 higher per type and the worst room is
> 123. The rooms did not change; the instrument was unpinned. Reconciliation in § Round 42 (§42.2b) §0.
Law 350 **margin 228**. It was 116 before this round, so §41.4 spent **6 draws at the worst room** and
at most 17 anywhere. `dept` deliberately gets the fewest additions of any type precisely because it owns
the worst room; the pub, which this round exists for, could afford 14 and still sit at 59.
Cost discipline came free from E's work: **every one of the 110 is 1 draw** bar the four rigged heroes.
The record shop's booth is the round's only multi-draw fitting `deck_1200_rigged` (5) +
`mixer_ttm54i_rigged` (2) + a static `deck_1200` (1) = **8 draws, one room, once**.
### 3. Sweep + soak
- **`drawSweep` 4 arms × 12 types × 6 archetypes = 288 rooms:** all pass 350, **0 console errors**.
- **Soak, 60 rooms build+dispose+rebuild:** avg **3.83 ms**, worst **10.3 ms** (budget 50) · leak geo **0**
/ tex **0** · determinism **0** mismatches · path-fails **0** · carves **0** · throws **0**.
- **960-room corpus** (12 types × 5 archetypes × 8 seeds × kit/noKit, GLB on): **0 path-fails, 0 carves.**
| type | R41 fittings/room | GLB props/room (max) | diggable bins/room | rooms with none | watchPoints | browsePoints |
|---|---:|---:|---:|---:|---:|---:|
| record | 7.90 | 20.8 (28) | **8.32** | 0/40 | | 3.00 |
| stall | 3.92 | 11.7 (18) | **4.55** | **0/40** | | 3.00 |
| pub | 8.40 | 13.0 (19) | | | **7.5 / cap 8** | 3.00 |
| band_room | 5.03 | 9.1 (11) | | | **8.0 / cap 8** | 3.00 |
| rsl | 5.78 | 10.1 (18) | | | **11.4 / cap 12** | 3.00 |
| opshop | 2.95 | 13.1 (19) | | | | 3.00 |
| milkbar | 3.77 | 13.5 (18) | | | | 3.00 |
| book / video / pawn / dept | 3.80 / 3.10 / 3.17 / 1.90 | 11.4 / 5.1 / 9.7 / 11.8 | | | | 3.00 |
**Furnishing did not cost the crowd.** watchPoints are computed after placement, so a furnished venue could
have starved its own audience; measured, pub holds 7.5 of 8, band_room 8.0 of 8, RSL 11.4 of 12.
**A measured R39 hole closed on the way past.** R39 counted 48 market-stall rooms and found **21 (44%) with
no crate at all** the recipe's `crate` is `zone:'centre'` and 36 trestles take the aisle first so RED
TICKET DAY could name a stall you cannot dig in. `stall` now carries 24 kit bins with the full fallback
chain: **0 of 40 stall rooms have no bin, mean 4.55.**
### 4. Four defects found by building this, all fixed
1. **`buyMesh` was invisible on any fitting with a GLB.** R9's buy-anywhere shelves are merged,
per-item-addressable meshes tagged `{ noBatch, buyMesh }` and *not* `isStock` so `glb.js`'s
frame-hide turned **every real, buyable item in the room invisible** the moment the shelf upgraded.
Live since R9 on `bookshelf`, `cubeShelf` and `clothesRack`; never seen because `?stock=real` is opt-in
and R9 measured it GLB-off. Found the instant 52 real garments went on a rail and the rail came back
empty. `hidePrimitive` now skips `buyMesh`.
2. **The DJ booths were nondeterministic.** Parts were attached inside each load's own `.then`, so the
booth's children landed in network-completion order. Two runs of the same 240 rooms differed on exactly
the three types that have a booth and on nothing else. Loads stay parallel; attachment is now a plain
in-order loop.
3. **The booth's gear floated at 0.92 m over bare floor.** A single-GLB swap hides the whole primitive,
but a booth's GLBs are the *gear* the bench is the fitting. Gear stand-ins are tagged `boothGear` and
only those are hidden. Which exposed a fourth: **`batch.js` rebuilt `userData` from scratch and dropped
sub-tags on merge**, so the tag never survived to be read. It now carries `boothGear`/`tillPrimitive`
through a merge when *every* mesh in the material bucket has it.
4. **The door→counter path guarantee could empty a room.** The pull loop takes the *lowest-priority*
survivor each pass, not the one standing in the corridor, so it clears the cheap furniture first and
reaches the real blocker by attrition. Measured: `pub/wide/4242` came back with a bar, a stage and
**nothing else** a wide pub puts the bar beside the stage and leaves one ~1 m gap. Pre-existing; ten
more fittings per pub made it impossible to miss. New step 3b walks the pulled list in reverse and puts
each fitting back **only if door→counter still reaches**: over 480 rooms, **129 pulled 101 restored,
28 genuinely lost**, and the guarantee is re-proved on every single restore. A room that pulled nothing
does nothing (byte-identical); a room that pulled N can only end with more fittings, never fewer.
### 5. E's three traps
| trap | what §41.4 does |
|---|---|
| `-long`/`-wide` pairs are the same mesh rotated 90° | `kit41.PAIRS` records all 9; **no pool contains both halves**, and `kitAudit()` asserts it. Facing is not a hand-tuned table either `glb.js autoYaw` compares the manifest footprint against the slot the grid reserved and turns the GLB a quarter if the major axis is swapped. Pre-R41 kinds keep their hand-tuned `KIND_TO_YAW` exactly, so legacy rooms are unchanged on screen. |
| `vinyl_sleeve` depth floored to 0.01 m | wall dressing only `sleeve_wall`, `wallMounted`, mountY 1.72, in **no floor pool**. |
| `beam_roof` 6.5 m · `shelf_board_long` 4.6 m · `wall_shelves_big` 4.65 m | in **no pool at all** (archetypes are 612 m wide). Listed in `kit41.OVERSIZE`, asserted by `kitAudit()`, and generalised as `spec.o.maxRun`. |
**`kitAudit(manifest)` (on `PROCITY_C`) is the drift check, not a promise:** it compares all **96
catalogue rows** against the live manifest. Result **96/96 checked, 0 dimension errors, 0 missing, 0 pair
errors, 0 oversize; 67 pool/booth ids all resolve.** It found one real drift on the first run
(`sampler_s900` height 0.14 vs the manifest's 0.135), which is fixed.
### 6. THE WARDROBE — route R5, zero geometry
`web/assets/wardrobe/` (new): **`wardrobe_atlas_00.webp` 246,364 B + `stock_opshop_index.json` 17,347 B =
263,711 B (257.5 KB), ONE fetch, ONE material, ONE draw per fitting.**
- **52 layers selected BY NAME** (ruling 3 no wildcard copy) from the 441 at
`~/Documents/90sDJsim/web/world/media/doll/`. Each is cropped to its **alpha bbox** (the sources are
704×1408 full-body layers that are ~90% empty; a top occupies the torso band only), scaled and
shelf-packed. **257.5 KB is 3.7% of the 52 sources' 6.74 MB and 0.49% of the 53 MB full set.**
- Garment cells are **128×152** exactly the resolution of the procedural garment canvas they replace
(`stock.js makeGarment` is 128×160). This is a swap in art, not an increase in texture budget.
- **LAZY AND BOUNDED:** it is a stock pack under its own base, so it loads only under `?stock=real`.
A default boot fetches nothing; `?noassets=1` fetches nothing. Fail-soft: no pack the procedural
canvas, byte-identically.
- **Where the names show:** garments come through `buildBuyableShelf`, so they are per-item addressable
and the **existing shelf buy-card path reads `artist — title`** with no new UI.
**THE LICENCE CALL, made explicitly.** `pipeline/AUDIT.md` is standing law: *no real titles/authors/brands
ship*. The source library was hand-prompted **from** real 1990s labels, so a majority of the 441 carry a
live trademark in the filename **and in the artwork** `top_i_adidas_track_jacket.png` renders a legible
trefoil and three stripes; `bottom_i_tech_fleece_pant.png` has a swoosh on the leg. So the screen was two
stage: every candidate was eyeballed on a contact sheet and **rejected if the artwork carried a mark**
(that took out adidas/nike/versace/dr-martens/champion/coogi/stussy/fila and the rest), and the surviving
ids are then labelled in a **parody voice**: `Paddy's Bootleg`, `Coogee Knits`, `Mombo`, `Stoosh`,
`Kaymart`, `Criss Colors`, `Kangaruu`, `Army Disposals`, `Rissole Classic`, `Nan Knitted It`.
**The parody is not a compromise, it is the period detail** Paddy's Market genuinely sold "Tommy
Hillfigga", and the source library itself already ships `adidos_shelltops` and `doc_martys_8_eye`.
Titles stay literal and Australian: *Double Pluggers · Terry Towelling Hat · Trackie Dacks · Boardies ·
Squiggle Jumper · Phat Pants · Acid Smiley Tee*.
Two small plumbing changes made this work without touching the three existing packs:
`SLOT_FOR.opshop = ['garment','box']` (a pack may now serve more than one slot kind; the three pre-R41
packs keep their single string and are handed `pack.items` **by reference**, so every code path that
matters compares reference-equal and behaves byte-identically), and an optional `index.material` block
(`alphaTest` + `DoubleSide`) without it a cut-out tee is its own bounding box and the back of a hanging
garment is a hole. Packs that declare no block build the material they always did.
### 7. DJ CONTROL NODES — recorded, NOT driven (for D/F, a later round)
R41 **places** the rigged models and records their contracts. **Nothing drives them**, and `djBooth` ships
no animation hook on purpose so a placed node is never mistaken for a live one. The names are in
`kit41.CONTROL_NODES`, on the fitting as `fitting.controlNodes`, and on the group as
`group.userData.controlNodes`:
| id | draws | control nodes | where it is |
|---|---:|---|---|
| `deck_1200_rigged` | 5 | `Platter_SPIN` · `Arm_YAW` · `Fader_PITCH` · `Btn_STARTSTOP` | record shop, booth `record_hero`, left deck |
| `mixer_ttm54i_rigged` | 2 | `Fader_CROSS` | record shop, booth `record_hero`, centre |
| `mixer_pmc05pro_rigged` | 2 | `Fader_CROSS` | catalogued, not placed |
| `mixer_shdj1200_rigged` | 2 | `Fader_CROSS` | catalogued, not placed |
Two things a future driver must not rediscover the hard way: drive them by the **bare node name** (the
cap/stem/rib are parented under it), and **glTF drops the source's limit constraints, so clamp the travel
yourself.** Venue and pawn booths are deliberately **statics** E's call, and correct: a static copy is
1 draw and looks the same standing still.
### 8. Determinism + `?noassets`
- **Determinism, two FRESH browser contexts** (never a reused tab), 240 rooms = 12 types × 5 archetypes ×
4 seeds, **GLB on + `?stock=real`**, comparing placement, GLB id list, browse/watch points, keeper stand
and stage: **byte-equal, sha256 `c26b4848…eae852` on both runs, 0 mismatched rooms** over 522,501 bytes
of payload.
- **`?noassets=1`, all 60 type × archetype rooms built:** **0 GLB fetches · 0 manifest fetches · 0 wardrobe
fetches · 0 stock-pack fetches.** The only 10 requests are the pre-existing `assets/gen/wall-*.jpg` /
`tex-*.jpg` shell wallpapers, which are not R41 cargo and are the same 10 before and after. Worst
primitive room **126 draws @ opshop/hall, 0 GLB upgrades, 0 path-fails** the asset-free build is a
real, correctly-sized, walkable shop floor, because `kit41.js` is a hardcoded table and `buildInterior`
is synchronous. Control shot: `docs/shots/laneC/r41_noassets_control.png`.
### 9. Shots — `docs/shots/laneC/`
`r41_record_shop.png` · `r41_record_dj_booth.png` · `r41_pub.png` · **`r41_pub_before.png`** (the same
seed and camera with `noKit:true` the control) · `r41_opshop_wardrobe.png` · `r41_band_room.png` ·
`r41_milkbar.png` · `r41_stall_bins.png` · `r41_noassets_control.png`.
Each carries a **1.75 m scale post** at a floor pose: no shot here contains a humanoid, so the R10 law does
not bite, but these are 110 brand-new assets at claimed real-metre scale and the bar is the proof.
### 10. Files
New: `web/js/interiors/kit41.js` · `web/assets/wardrobe/{wardrobe_atlas_00.webp, stock_opshop_index.json}`.
Changed: `fittings.js` (the kit builder + `djBooth` + counter `glbId`), `theme.js` (recipe pools),
`layout.js` (`stage` zone, `spec.gap`, `wall` as a fallback zone, restore pass, `noKit`), `glb.js`
(`fitting.glbId`, `glbParts`, `autoYaw`, the `buyMesh` fix), `stock.js` (`hang` real path, slot pools),
`stockpack.js` (multi-slot packs, `index.material`, `ry`), `batch.js` (sub-tag propagation),
`interiors.js` (`noKit`, `room.pulls`), `interior_test.html` (sweep fix, `noKit`/`stock` arms, wardrobe
preload, `kitAudit` hook).
**Not touched:** `web/assets/manifest.json`, `web/assets/models/**`, `pipeline/**`, `web/js/citizens/**`,
`web/js/world/**`, `web/index.html`, `tools/qa.sh`.
---
## Update 2026-08-03 (round 39, v9-alpha THE CITY IS THE GAME) — §39.3 the op shop can't be dug
Fable's finding, verified before commissioning: `kind:'bin'` the thing the dig fires on is minted at
exactly `fittings.js` `recordBin`/`crate` and used by exactly two recipes, `record` and market `stall`.
PROCITY is built on thriftgod's op-shop DNA and **you cannot dig in an op shop.** Brief: measure what a
player can actually do in every shop type, build the op shop a bin, **flag the contents, do not decide
them**, and spend against the GLB-path interior margin.
### 1. THE TABLE (LANE_C_PUB §10) — the deliverable, and it is uncomfortable
Built by reading the recipes and the fitting kinds, then counting interactables in **48 rooms per type**
(6 archetypes x 8 seeds). Full table + the five structural facts in the contract; the short version:
- **`record`** digs (mean **4.6** bins/room, 0/48 empty) and is **the only room in the game where
anything can be sold**. **`stall`** digs in 27 of 48 rooms; the other **21 (44%) have no crate at
all** (mean 0.92), because its `crate` is `zone:'centre'` and the 36 trestles take the aisle first.
**That is a measured correction to the round's binding fact #5** and it means RED TICKET DAY's roster,
which counts a stall by type, can name a stall you cannot dig in.
- **The other ten types offer nothing but looking.** No bins, and pull-and-buy is dark: `?stock=real` is
opt-in (`index.html:222`), so **on the boot every player gets there is not one `buyMesh` in the game**.
Even armed, `SLOT_FOR` has three entries and the pack is keyed by SHOP TYPE, so only book spines in a
`book` shop and toy boxes in a `toy` shop can ever be bought the video store's identical spine
shelves cannot, because no `video` pack exists.
- **Four stock kinds can never be bought in any mode**: `garment`, `snack`, `magazine`, `treasure`
`hang()`/`treasures()` never call `buildBuyableShelf`. **The op shop's clothes are unbuyable by
construction.**
- **`interactable: true` is not a verb.** `interior_mode` routes exactly three things (aimed `bin`,
counter, `buyMesh`). `case`, `fridge`, `returns` and the arcade cabinet set the flag and **nothing
reads them** 8 dead glass cases in a dept-store hall, 4 dead fridges in a milk bar.
- **Sell** is `item.type === shopType`, and only two call sites stamp a type: a dig pull is **always
`'record'` wherever it happens**, a shelf buy is the shop's type. Hence: one sale point, default boot.
### 2. THE MECHANISM — `rummageBin`, shipped, contents gated
A shallow galvanised dump tub (0.95 x 0.70 x 0.28) with flared sides on a castored steel frame, rim at
**0.76 m** you rummage standing heaped with stickered mixed goods and a hand-lettered card taped to
the front ("EVERYTHING $2"). Not a record crate recoloured: `crate` is 0.55 x 0.45 and deep because it
is sized to stand LPs in. Shot: [r39_opshop_rummage_bin.png](docs/shots/laneC/r39_opshop_rummage_bin.png).
Three new **opt-in, pure-data** seams (absent pre-R39 byte-identical): `recipe.fittings[i].o`,
`buildInterior opts.fittingOpts[kind]`, and `.fallbackZones` / `.anywhere` placement fallbacks. The last
one is not decoration appended last, the bin's zone is the fullest it will ever be and it landed in
only **11 of 24** rooms without them.
**72 rooms (6 archetypes x 6 seeds x GLB on/off):** placed **72/72** · reachable **72/72** (nearest
spawn-reachable walkable cell **0.771.15 m** from the bin, vs the dig's 2.5/3.2 m reach) · **0 carves** ·
`pathOK` 72/72 · determinism 72/72 · **the rest of the room byte-identical to pre-R39 72/72** (the spec is
appended LAST, so the shared `lay-count`/`lay-place` streams hand every existing fitting the same draws).
End-to-end armed: aim dig opens **16 offers** card **BUY** debits the wallet bin depletes to 15.
Negative control (default): tub present, **0 bins**, aim finds nothing, dig never opens. 0 console errors.
Soak 60 rooms: **leak geo 0 / tex 0**, determinism identical, avg 3.4 ms / worst 16.5 ms.
### 3. SPENT AGAINST THE GLB PATH — and the margin was not six
**`C-progress.md:841`'s "GLB-on 344" is R9-era and stale.** Re-measured on today's tree with the lane's
own instrument before touching anything: **GLB-off 156 · GLB-on 319, both at `opshop/hall`.** So the
margin was **31**, not six. After the bin:
| `drawSweep` | pre-R39 | R39 | Δ |
|---|---|---|---|
| GLB-off worst | 156 opshop/hall | **161** opshop/hall | +5 |
| GLB-on worst | 319 opshop/hall | **329** opshop/hall | +10 |
| the other 11 types, both modes | | | **0, byte-identical** |
**Margin against ≤350: 21 draws.** Stated at the worst room the bin lands in, not an average.
A free win found while costing it: the scatter fill minted a *tinted* material per item (9 goods = 9
draws in a room with no other scatter). New opt-in `slot.pool` keeps every face on the room's shared
pooled textures at white the trick the spine/box rows already use so the pile merges into buckets
the room already has. **12 goods now cost less than 9 did.** Existing scatter callers draw the same
values in the same order; proven by the 11 untouched types' sweep numbers.
### 4. THE BIGGER FINDING — half the interior budget is a pre-pass (→ E/F, NOT shipped)
Lane E's `bookshelf` GLB ships **`transmission: 1`** on `mtl_10218_Bookshelves_v1`. One transmissive
material makes three.js run its transmission pre-pass a second full render of the opaque scene so
**every draw in that room is issued and counted twice.** Measured by zeroing it and re-rendering:
`opshop`/hall **191 → 104 (46%)** · `opshop`/wide 142 80 · `book`/hall **101 → 51 (50%)** ·
`dept`/hall 116 116 (the control, no bookshelf). Those are the two worst GLB-on types in the sweep.
** E:** re-emit without `KHR_materials_transmission`. ** F:** a `transmission = 0` scrub in
`glb.js upgradeFitting` is the cheap fence. **I have not shipped either** it changes how another
lane's asset renders. Either turns a 21-draw margin into ~100.
### 5. HELD FOR JOHN — the contents (LANE_C_PUB §10.6)
Not "op shops now dig", but "op shops now dig **records**". Across the 23 shipped caches (44 record ·
36 stall · **147 opshop**): crate-shops/town **011 → 225**, towns with none **3 → 0**, diggable bins
**~235 ~382 (+63%)**, under-band gems/day ~113 ~183. **A** (parody records) costs zero code and is
what the market stall already does, but an op-shop find is stamped `type:'record'` and bowral/darwin/
daylesford have no record shop they could dig and have nowhere to sell. **B** (books/toys via the
existing packs) needs an `opshop` pack that cannot exist today *and* a second dig presentation, because
`dig.js` builds a crate of 0.31 m square sleeves. **C** (a bric-a-brac class) costs most and is the only
one that closes the sell hole, since type = type would make op shops buy their own bric-a-brac back.
**Recommendation: play arm A behind the flag for one session, then rule; C is the destination; B is not
worth doing alone.** Also held, same reason: the one-line `fallbackZones` fix that would give market
stalls the crates their recipe already promises.
---
## Update 2026-07-18 (round 30, v7.0-alpha THE SAVE AND THE CRATE) — the sell counter: cash comes back, always less
R30 ledger #2 contract first (LANE_C_PUB §9, its own commit), implementation second. F's save core
(ledger #1) had **not landed** when this was built: no `save.js`, no `window.PROCITY.game` anywhere in
the tree so everything below is built to the R30-published shape and verified against a **stub** of
exactly that shape (noted loudly below).
### The contract (§9) — the one number and why
**`sellOffer(p) = min(p 1, max(1, floor(p × 0.5)))`, never < 0.**
- **0.5** is the secondhand-dealer margin the keeper pays half of what he'll sticker it at.
- **`min(p 1, …)` is the no-pump law made STRUCTURAL:** the offer is strictly below `pricePaid` for
every `p ≥ 1`, so buy-then-immediately-sell loses $1 per round trip **by construction** no
balance retune can break F's monotonic-loss gate without violating the contract. At `p = 1` the
offer is $0 and SELL disables (the keeper won't buy what he can't sticker).
- **Alpha basis = `pricePaid`** (every alpha buy is at asking price, so it IS the buy-side price the
player faced; there are no guide bands yet). **Beta swaps the basis to `bandLow`, not the shape.**
- Matching is **type = type, strict, fail-closed**: an untyped item sells NOWHERE (a matcher passing
untyped items would "match" everywhere vacuous-gate law applied to a matcher).
### What shipped (C's files only)
- **`web/js/interiors/sell.js`** `createSell()` + pure `sellOffer` / `sellableIn` / `nearCounter`.
DOM-only (zero draws, zero GPU), the dig's manila price-sticker mirrored to the left with a green
offer sticker; one item at a time, ` ` cycling, ✕/Esc; till rings from the counter emitter (R28
one-shot pattern). Same consumer pattern as `dig.js`: the single side-effect is `onSell(item,
offer)` **C never mutates `game.collection`** (read-only law, §9.3).
- **`web/js/interiors/wallet.js`** `wallet.sell(item, offer)`: the credit mirror of `buy()`.
This file never prices; the offer comes in from `sellOffer`.
- **`web/interior_test.html`** E-at-counter routing (bin dig · counter sell · shelf buy) gated
on `window.PROCITY.game` presence (the in-game classic-pure law, exactly); `?sellstub=1` stubs the
published game shape for verification until F's core lands.
### Verified (fresh context, no-store server on :8144 — NOT :8130, zero console errors throughout)
- **The sale:** record shop seed 1990, at the bench (`nearCounter` true at 0.9 m, false across the
room) card `THE FIBROS — "Servo at Midnight" · you paid $24 · offer $12` SELL **cash 163
175 (+$12 < $24 paid)**, collection 4 3, sold item gone, toast + till fired.
- **Type fence:** stub carried 2 records + 1 book + 1 untyped item. Record shop cards **exactly the 2
records**; book shop cards **exactly the book** ($7 on $15); the untyped item cards **nowhere**.
- **The disabled edge:** the $1 record offers $0 button reads NOT WORTH BUYING, disabled.
- **No-pump round-trip (C's mini-gate, precursor to F's #5):** buy-then-sell through the real wallet +
card path, prices {2,3,5,9,24,60,61,137} **8/8 strictly negative** (1,2,3,5,12,30,31,69).
- **The gate discriminated first** (vacuous-gate law, proven live): my first loop pushed a 999-priced
item into the collection **without checking `wallet.buy` succeeded** broke wallet, no debit, then
a +$499 "sale". A pump from an item never paid for. **Finding for F below.**
- **Fail-soft:** boot with no stub (= no game layer) E at the counter does nothing, `open()` false,
zero DOM, zero errors. `dispose()` leaves 0 panels/0 toasts in the DOM.
- **Budgets:** draws with the card open closed (16 16 DOM is zero-draw); `buildInterior` and all
room code untouched this round; 50-room soak re-run green in the same session: **avg 3.4 ms · worst
8.2 ms · leak geo 0 / tex 0 · determinism identical · worst draws 73 350**.
### Findings / asks (→ F, for the save core + gates)
1. **The collection item needs `type`** (registry stock type, stamped at buy time). The R30 shape
(`{townKey, godverseShopId|shopId, sku|slotId, pricePaid, dayFound}`) doesn't carry it, and without
it **nothing is sellable anywhere** (my matcher is fail-closed on purpose). `title`/`artist` are
strongly asked too (B's collection UI needs them; my card falls back to the sku label honest, ugly).
2. **`pricePaid` must only ever be written by a SUCCESSFUL debit.** The no-pump law's structure
assumes pricePaid was actually paid my broken first loop showed a collection entry that skipped
the wallet debit converts straight into a money pump. F's no-pump gate should include this negative
control: a collection entry minted without a debit must be impossible (or the gate must fail).
3. **The removal handoff:** in-game, the sold item must leave the collection **via the game API**
`onSell` is where F calls it. My harness splices its own stub (its array, its right); C's modules
never touch game state. When F publishes the API name, §9.3 references it verbatim.
R28 §Lane C (ledger #3) my own idea, parked since R7, brief states acceptance only. Wave 1, alongside E+B.
### The trap my own parked note set
v3.0's deferred note said: *"`userData.audioEmitter = musicKey` so Lane B can position/spatialise the
bed"*. **Both halves were wrong**, and the second one is dangerous:
1. **The shape.** The engine holds no reference to the room group it would have to traverse a scene it
cannot reach. Emitters belong on `room.audio` as data, where they ride the **existing**
`playInterior(room.audio)` call: no new lifecycle, no traverse, no dispose hook, no F plumbing.
2. **The method.** "Spatialise" invites a `PannerNode`. **Nothing in this tree sets `ctx.listener`** (zero
hits, whole tree), so a panner resolves against a listener frozen at (0,0,0) and since rooms are built
in room-local coords at the origin, that is *the room's own centre, forever*. Measured with
`OfflineAudioContext` on a real pub stage bed:
| wiring | RMS at door | RMS at stage | ratio |
|---|---|---|---|
| panner, listener never set (**the engine as it ships**) | 0.106905 | 0.106905 | **1.0000** |
| panner + listener tracking the player | 0.055665 | 0.500000 | 8.98 |
It **plays, throws nothing, warns nothing, and never changes by one sample as you cross the room**
while failing at the only thing it exists to do. *"Routed through a panner?"* yes. *"Audible in a
venue?"* yes. *"In a shop?"* yes. All green, all worthless. **The only assertion that can see this is a
DELTA: gain at the door gain at the stage.** Vacuous-gate law, 4th application and this one had no
author to blame but me. There is no listener code anywhere to teach a lane otherwise.
**The house idiom was already right**: tram rumble (R=55), door spill (R=9), gig spill (R=26) are all
hand-rolled distance-gains against `PROCITY.camera.position`. No listener, no panner. Emitters match it.
### What shipped (C's files only)
- **`room.audio.emitters`** = `{ stage?, counter? }`, room-local, 3-dp, **18/18 same-seed deep-equal**.
`r` (reach) is the **room diagonal, not a constant** measured diagonals 9.8115.63 m, and scaling to
the room keeps doorstage proximity in a tight **0.420.59** band from a pokey band_room to a hall pub.
`floor` is the room-filling residue, **never 0** a PA fills a pub; what changes as you walk in is the
*balance*, not the presence. Swing **2.654.52 dB** (John R20: *subtle changes are great*).
- **The till rings from the counter** *audible positioning in a normal shop with **no engine change***,
via B's already-public `playSfx(key,{gain})`: C does its own distance math, hands B a scalar. Measured
through the real BUY button in a real record shop: **0.9489 @ 0.60 m vs 0.6436 @ 4.19 m = 3.37 dB**;
negative control (no emitters) fires at exactly **1.0**. `sfx-till.ogg` had been on disk since 15 Jul and
had **never once played**. Routing, not content exactly as the brief said.
- **`?mute=1` now silences the crate riffle.** See below.
**Why the venue half is an ask, not a ship:** a **one-shot** resolves its gain once, at fire time, from a
position C already knows. A **bed** must be re-gained every frame inside the engine's `update()` loop
B's file. The split is structural, not political. B got a ~12-line paste-ready diff in the house idiom; C
did not touch audio.js. The letter of "consulted not modified" could have been satisfied by reaching
through B's exposed `state.layers` diag to rewire its graph from outside **that backdoor is strictly
worse than the edit it avoids** (invisible to B, breaks on any change of B's). Refused.
### Two bugs found on the way
- **F drops emitters on the one night they matter.** `interior_mode.js:289` rebuilds the spec on a gig
night: `{ musicKey: ra.gigKey, toneKey: WALLA_KEY }` every unnamed field dies. Measured on a real
gig-night pub: `quietNight_emittersSurvive: true`, **`gigNight_emittersSurvive: false`**. Silent, fail-
soft, no error. -> F: don't just spread `ra` — pass emitters **deliberately, gig-night only**, because on
a quiet night the radio and room-tone genuinely *are* room-filling.
- **`?mute=1` never silenced the riffle (R5 → R28).** `dig.js` opened a **second, private AudioContext**
straight to `destination`, breaking both of audio.js's stated laws (*ONE AudioContext*; *mute forces
silence*). It survived three epochs because **no gate asserts mute ⇒ silence**: the engine returns its
silent surface and looks honest *from its own side*, while the sound came from somewhere it never knew
existed. *A gate that asks the engine whether it is muted can only ever answer yes.* Fixed by reading
B's **public** `.muted` getter (consulted, not modified), URL fallback when standalone. Measured with a
positive control so the probe is provably able to see sound: live 1 ctx / 1 source; **muted 0 / 0**.
Blips stay procedural on purpose — zero-fetch, deterministic, and they still work under `?noassets=1`
where a sampled riffle cannot. Only mute was broken; only mute was fixed.
### Harness honesty (again)
First till rig reported a clean 3.37 dB — from a **contaminated harness**. `createDig` appends a panel per
call and `close()` doesn't remove it, so `querySelector` was clicking **trial 1's stale button** every
time. The tell was `panelUp: false` on all three trials, and the **negative control** returning 0.6436
where it must return 1.0. The gains happened to match the formula exactly — I still refused them and
re-ran isolated (`panelCount: 1`, `panelUp: true`, control **1.0** ✓). R27's lesson holds: *never report a
number from a rig you have watched lie, even when you like the number.* The control is what caught it.
QA GREEN (6/0/0/0). Docs: **LANE_C_AUDIO.md → v3.1** (contract + both asks + every measurement above).
---
## Update 2026-07-17 (round 27, v5.0 THE LIVE CRATE) — the tier-2 seam + LANE_C_PUB v5-FROZEN
R27 §Lane C (ledgers #2 + #6). Wave 1 — ran after F tagged `v5.0-beta` (`78848ed`, 7/0/0).
### The seam ruling (§8): bless the direct wire — but the *form* was the real question
Fable asked: publish a post-build hook, or bless the direct wire? **Answer: bless it — no lifecycle hook.**
F already holds `currentAdapter` (`interior_mode.js:67`) and passes it to `dig.open()` **per dig** (`:111`),
so the live read lands, F updates the pack, and the next riffle picks it up. Read not landed / slow / dead ⇒
the adapter is simply the tier-1 pack ⇒ the dig never blocks. No C lifecycle change.
**But the question underneath it — *how* `gone` applies — is C's, and the obvious wiring is wrong.**
`pickRealOffers` indexes `pack.items[(rr()*len)|0]`, so **filtering the array reshuffles the crate**.
Measured across 5 crates: **one record selling changed 21 OTHER records** (6/2/2/6/5 collateral). Pick-then-
omit — draw from the stable full list, drop gone *after* — has **0 collateral**: a crate with 3 sold shows
13, not a different 16. Selling one record must remove one record, not re-stock the shop.
**And the trap: both forms hide the sold record.** So F's planned sandbox assertion — "a real `gone` item
never renders" — **goes green on the wrong wiring**. It cannot see the 21. → **F: assert on collateral (the
survivors are unchanged across a `gone` delta), not absence.** That's the vacuous-gate law's third
application, and the cheapest to miss, because it passes.
**Landed** (`dig.js`): `pack.gone` (Set|array) omitted at pick time; **F's invariant published** — update
item *fields* in place (live price) freely, **never add/remove/reorder `items[]`**; that identity+order IS
the seeded pick's stability (the same lesson as R25's positional ids, one layer up).
**Determinism boundary sits exactly here:** server down ⇒ byte-identical tier 1 (gate-able); server up ⇒ the
crate legitimately changes — tier 2 is non-deterministic *by design*, so **no gate may assert dig determinism
with the server up**, and `pack.gone` never enters a seeded stream or a golden.
**Scoped honestly:** the bin fan is decorative (a different seeded pick from the dig's offers), so a sold
cover may linger there until rebuild — that's when I'd publish a post-build hook, and the epoch's claim
doesn't need it, because *the crate you riffle is true*.
**Verified** — pure-function measurement for the collateral claim, then end-to-end through the real dig's
public surface (pull → Esc → scroll → pull): sold record gone · **survivors 4/4 · collateral 0** · `items[]`
untouched at 120 · live in-place price shows on the next pull. *My first dig harness returned the same title
4× (I never un-pulled), so its "survivors" number was a harness artifact — I re-ran it properly rather than
report a metric I'd just watched lie.*
### Docs freeze (§6): LANE_C_PUB is **v5.0-FROZEN**, whole document
Marked, with the case law kept at the top rather than buried: three of this doc's amendments exist because a
rule of mine met a case written *after* it (§7's naming vs its own loader; §7.2a's `sku_` before mint) — and
both times the lane building to my rule took the red. The header now says it plainly: **when a line here
meets a case it didn't foresee, the line is the stale thing — amend it, don't bend the case.**
---
## Update 2026-07-17 (round 26 wave 4) — the mint-id fence: my rule was the stale thing
F's fourth hold traced to **my** §7.2a. I wrote "`sku_`-prefixed for real-stock packs" in R25 — **before mint
existed** — so it names one space where there are two. E implemented it faithfully and its validator did
exactly what I told it to: demand `sku_` on 14 mint crates. **The check wasn't wrong; my rule was.** Same
shape as R23, and I'll say it plainly: this is twice now that a contract line of mine met a case written
after it and the lane building to it took the red.
**Amended (§7.2a): the id form is sourcing-scoped, and the prefix IS the fence.**
| `sourcing` | id form | the space it names |
|---|---|---|
| `real` | `sku_<POS id>` | the shop's POS inventory — a physical copy on a shelf |
| `mint` | `mint_<dealgod listing id>` | a dealgod listing — a plausible stand-in, not that shop's stock |
**Why G was right to refuse the easy green.** Filing dealgod listing ids under `sku_` would have gone green
in one line and recreated the R24 collision **inside the economy layer** — where **tier-2's sold-means-gone
looks up POS skus**. R24's collision entered the *wrong shop*; the same collision in the money layer **sells
the wrong record** — a real copy, out of a real shop, to satisfy a minted stand-in. A prefix that lies costs
money, not a gate. Blessed as design: disjoint namespaces, and no gate, lookup or economy path may treat
them as one (same standing note as plan-ids vs godverse-ids).
**Verified against every crate in the tree before handing E the line:** 15 crates (1 real + 14 mint) —
**0 fails** under the amended rule (prefix matches `sourcing`, ids unique within pack), and **0 ids appear in
both spaces** across 344 distinct ids. The fence already holds in the data; only my wording didn't.
**→ E:** assert the form against `sourcing` (you already read it at line 178) — `sku_` for real, `mint_` for
mint; a crate wearing the other's prefix FAILS. That's **stronger** than the check it replaces, not looser.
**→ F:** 6/1/0 should flip to 7/0/0. Doc-only from C; no code, no atlas bytes, no goldens.
---
## Update 2026-07-17 (round 26, v5.0-beta EVERY CRATE DIFFERENT) — three contract lines + the price sticker
R26 §Lane C, wave 1 (ledgers #1, #4, #6). Contract lines published FIRST — G and E build to them.
### The brief said `tier`. It can't be `tier` — that name is taken twice (§7.2b)
G's atlases already emit **`tier: 1`** = the **charter's ladder rung** (0 parody · 1 static-real · 2 live),
and `kind` is already the stock type. **A mint atlas is also rung 1** — same mechanism (a statically baked
per-shop atlas); only the data's *origin* differs. Real-vs-mint is an **orthogonal axis, not a rung**.
Overloading `tier` would put two meanings in one name — the same species as the plan-id/godverse-id
collision that just became a standing note, and as R25's positional ids. **Ruling: top-level
`sourcing: "real" | "mint"`, REQUIRED; `tier` stays the numeric rung.** A mint index MUST NOT carry
POS-snapshot fields (the separation IS the honesty); gates read `sourcing`, and an index without it FAILS.
### `condition` isn't in the index — the ledger assumed it was (§7.2c)
Ledger #4 said the card gets "the fields the atlas index already carries (artist/title/price/**condition**)".
It carries artist/title/price/price_band — **not condition**. G's *snapshot* has `condition` + `sleeve_cond`
and drops them at emit, so `pickRealOffers` mapped `cond: ''` and the card rendered dangling separators
around a hole. That *was* the "database row". Ruled: `condition`/`sleeve_cond` are **optional** index fields,
emitted when the source has them; **no invented grades** on mint items — a plausible-but-fictional grade is
exactly what `sourcing` exists to prevent.
### The atlas manifest (§7.2d) — existence declared, never probed
`web/assets/stock_godverse/index.json` is the authority for which shops carry atlases; **no entry ⇒ no
fetch ⇒ zero 404s**, so the zero-console-errors law holds with no attribution exception. Shape + both-ways
consistency (atlas without manifest entry FAILS, and vice versa) in §7.2d. A tree with no godverse atlases
and no manifest is a legitimate silent zero-shop state, not an error.
### The price sticker (ledger #4, code)
The pull panel is now the shop's **price sticker**, not a data readout: manila card, slight rotation +
shadow, typewriter caps, italic title, a rubber-stamped condition grade, and **the round yellow $ sticker
the sleeve art already uses** (dig.js's own canvas language — the panel now echoes it instead of fighting
it). Fields compose only when present: **a missing field never leaves a stray separator.**
**Verified — five card shapes, both star branches, fresh context:**
| case | card |
|---|---|
| real grail | `$28` · Deep Dish · *Penetrate Deeper* · `VG+/VG` · **★ GRAIL** |
| real standard, graded | `$15` · `VG+` · `STANDARD` |
| real ungraded (today's index / mint) | `$15` · `COLLECTOR` — grade omitted cleanly |
| parody | `$15` · THE VERANDAHS · *Tinny* · `NM` · `1973 PUNK` |
| parody rare (seed 4) | `$16` · HILLS HOIST · *Servo at Midnight* · `VG` · `1973 POP` · **★ RARE** |
**The verification caught a defect in my own card:** a real *grail* rendered **"GRAIL ★ GRAIL"** — `s` carried
the price_band *and* `rare` derived from that same band. (Parody is immune: its `s` is a genre.) Fixed at the
mapping — grail becomes the ★ stamp and leaves `s` empty. And I did not leave the parody-rare branch on
assumption: scanned seeds until a rare pull appeared (seed 4) and asserted it says RARE, not GRAIL. An
unexercised branch is an unproven one.
**Budgets:** the card is DOM — zero draws, zero tris, no 3D change. Ceilings untouched.
**→ G:** `sourcing` + `condition`/`sleeve_cond` + the manifest. **→ E:** assert `sourcing` (absent ⇒ FAIL),
the mint/POS-field separation both ways, and manifest↔files both ways. **→ F:** consult the manifest before
fetching — that's what retires the 404 attribution exception.
---
## Update 2026-07-17 (round 25, the tag earned) — sku-derived slot ids (ledger #3, C's one line)
R25 §Lane C: one contract line, published first so G re-emits against it. **§7.2a added** — F's id-collision
finding made structural, while there are two atlases to migrate instead of fifty.
**The collision, measured:** both packs number items positionally from zero, so **all 120 godverse ids are
also generic-pack ids — 120/120 overlap.** An id-equality gate cannot discriminate which pack a rendered
item came from; that's exactly why the brief's own #7a spec was vacuous. (The vacuous-gate law's first
application broke the law — and my own R23 metric had the same disease. It's a persistent species.)
**The rule (§7.2a):** `items[].id` MUST derive from the source's stable key, never from position, and MUST
be unique across ALL packs. Real-stock packs: **`id = "sku_" + <POS sku>`** — G's snapshot already carries
`sku` per item (e.g. `20260314070442`), it's just dropped at emit, so this is index-only. Generic packs keep
`rec_NNNN` as their namespace; real-stock must not emit into it.
**I gave it two reasons, and the second is the one that outlives the gate.** The collision is the presenting
symptom. The deeper defect is that **position is not identity**: a positional id renumbers on every re-bake,
so selling one record out of crate 550 silently makes `rec_0050` a *different* record. Anything holding an
id across snapshots — a wallet purchase, a saved dig, a gate assertion, and above all **tier-2's
sold-means-gone** — would point at the wrong item. The POS sku is stable because it's the shop's own
identity for that copy (`release_id` identifies the pressing, not the copy). So this line isn't only a gate
fix; it's the identity tier-2 will need, bought now for free.
**Scope kept honest:** index-only, no atlas bytes, no town cache, no goldens. `id` isn't read by the loader
(§7.3) — the dig/buy consumers and the gates read it, so no C code changes. **→ G:** re-emit with `sku_`
ids. **→ E:** assert the form (present, unique within the pack, `sku_`-prefixed for real-stock).
*Process note:* F's six files were staged in the shared tree mid-flight; committed by explicit pathspec, so
F's session is untouched.
---
## Update 2026-07-17 (round 24, THE ALPHA COMPLETION) — the contract fix (ledger #1, the root cause)
R24 §Lane C. **The root cause was mine and it's closed.** F held the tag because my published atlas
contract and my own loader were mutually incompatible; the ruling — **the runtime is the authority** — is
now the section's first line. Published FIRST (half-day rule; G and E re-emit against it).
**What was actually wrong.** My R22 §7 invented `stock_shop_<godverseShopId>_index.json`. The loader
(`stockpack.js:25`) fetches `` `${base}stock_${type}_index.json` ``. **An index named per my contract can
never be fetched by my own code.** So G built runtime-compatible (correct), E built contract-compatible
(also correct — the contract said so), and E's validator globbed `stock_shop_*_index.json` → **zero files
→ vacuous pass**. Nobody was wrong; the contract was. I wrote it, so I own it.
**§7 rewritten against the code (LANE_C_PUB):**
- **Authority rule:** `stockpack.js` IS the contract; if the doc and the loader disagree, the doc is a bug.
- **Layout ratified as G shipped it:** `assets/stock_godverse/<godverseShopId>/stock_<type>_index.json`.
`type` is load-bearing (it picks the slot via `SLOT_FOR`: record→sleeve). `stock_shop_*` is **retired**
**E: the validator glob becomes `stock_godverse/**/stock_*_index.json`** (that's the vacuous gate's
actual cause).
- **Runtime-read vs runtime-ignored, stated exactly.** Reads `atlases[]` + `items[{atlas, uv}]` (uv origin
**top-left**; the loader flips V — don't pre-flip). Ignores `version/atlas_px/cell*/count/kind/tier/shop/
crate/provenance` entirely.
- **The provenance drift dies (§7.3):** since the runtime reads none of it, the authority is E's validator
+ the 23 shipped town caches — both use **top-level `license` (US), `attribution`, `generator`,
`snapshot`**. British `licence` retired; **G lifts+renames those four**, keeps its rich nested
`provenance{}` (source/sha256/determinism) as colour. `atlas_px` stays REQUIRED (E checks it against real
dims — a genuine falsifiable check). `cell*` informational: **C reads `uv`, never `cell`** — and don't
shrink the cell for VRAM, the dig renders covers close-up.
**Code landed (the `type+base` keying, F's filed item):**
- `stockpack.js`: cache identity is now `` `${base}|${type}` ``; `getStockPack(type, base = DEFAULT_STOCK_BASE)`.
- `interiors.js`: new **`opts.stockBase`** — the seam F wires (`buildInterior(shop, THREE, { stock:'real',
stockBase: 'assets/stock_godverse/<id>/' })`). Omitted ⇒ town-wide packs, byte-identical to today.
- Pre-v5 callers untouched (`getStockPack('record')` still means the town pack).
**Verified fresh — and I had to fix my own metric first.** R23 I claimed "8/8 atlas-backed"; that checked
`material.map` exists — **but a parody canvas has a map too**. That's the same species as the vacuous gate
I'd just been handed. Re-asserted falsifiably:
- **Every sleeve samples G's atlas FILE** — texture sources are exactly `stock_godverse/3962749/
stock_record_atlas_00.webp` / `_01.webp`; pack items ≡ index items **by id**. Titles are real
(*Yoshitoshi Artists In House We Trust / Deep Dish Penetrate Deeper*).
- **Negative control (what makes it a gate):** the parody room's stock textures are `(canvas, no src)` and
touch the godverse atlas **zero** times — the metric discriminates.
- **Collision FIXED:** shop pack (120 items) + town pack (350) coexist as distinct objects.
- **Fail-soft FIXED with a populated cache:** missing base ⇒ `null` ⇒ parody (the R23 correctness bug —
an unstocked shop inheriting a cached crate — is gone).
- Ceilings: **98 draws** with real covers via `opts.stockBase`; `pathOK`; 0 carves; **0 console errors**.
**Scoped, not slid:** the **LRU + dispose** (§7.6) lands at **v5.0-beta**, not here — with one stocked shop
there's no accretion, and safe eviction needs a room-exit hook (a live room holds the pack's shared
material), which arrives with F's per-shop preload. Written into the contract so it can't slide silently.
**→ F:** the seam is `opts.stockBase`; §7.5 has the call. **→ G:** layout ratified, re-emit provenance field
names only (§7.3). **→ E:** the glob (§7.1) is the vacuous gate's cause.
---
## Update 2026-07-16 (round 23, v5.0-alpha THE FIRST REAL CRATE) — the crate in the room (ledger #3)
R23 §Lane C. **Verdict: the contract holds — G's atlas needs no re-emit.** Verified against G's
`stock_godverse/3962749/` (120 real records from Monster Robot Party's DEEP HOUSE crate 550, 2 × 2048²,
cell 256²) in a fresh context. Ran **before** Fable's merge deliberately — if the contract failed, that
blocks the merge. It didn't.
**PASS — the first real crate renders in a PROCITY room:**
- **Resolves through the existing `stockAdapter` path** via a base override — no C code change needed for
the single-shop alpha. 120 items · 2 atlases · **8/8 stock meshes atlas-backed** (real covers). The
sleeves are legible on screen: *peperecordings · DEEPCHILD · "39" · mff FREAKS · The Angels Instinct feat
Charlie Ashcroft* — a real deep-house crate.
- **Ceilings hold:** **118 draws** with real covers (≤350 law), `pathOK`, 0 carves.
- **Fail-soft holds** (clean cache): missing atlas → `null` → parody (20 parody meshes, 110 draws); no
adapter (the `?noassets` shape) → parody, zero fetch. **0 console errors.**
- **G's index is compliant:** `items[]` sorted by id (my R22 determinism ask honoured), provenance present,
`uv ⊂ [0,1]`, 2 atlases = exactly my cap, `cell_w/cell_h` harmless (C reads `uv`, never `cell`).
**I got a number wrong, and the first real data caught it (LANE_C_PUB §7.2 CORRECTED).** My R22 ceiling
said "≤8 MB decoded" — derived from assuming a "typical shop" is ~16 items @1024². **A real record shop is
120 records.** At the contract's own 256² cell that's 2 × 2048² ≈ **32 MB**, 4× my figure. The *structural*
rules held (≤2 atlases; 2048² for 60+ items — G landed exactly on the cap); only the MB number was wrong,
because it described a shop that doesn't exist. **Corrected:** ≤2 atlases/shop @≤2048², **LRU = 1 shop's
atlas set resident ⇒ ≤32 MB peak** (still bounded; still far better than the 181 MB unbounded accretion E
measured). And explicitly: **don't shrink the cell below 256² to save VRAM** — the dig shows a cover
close-up; resolution is the one thing the player actually sees.
**G's ask #1 CONFIRMED — and worse than reported (LANE_C_PUB §8: C's spec, C's fix).** `stockpack.js` keys
`_packs`/`_resolved` by **`type` alone**, so `base` is **ignored on a cache hit**. Measured:
`preloadStockPack('record', {base:'…/3962749/'})` then `preloadStockPack('record', {base:'assets/models/'})`
**returned the identical object**. Consequences: (1) two stocked shops collide — the 2nd serves the 1st's
crate (blocks v5.0-beta, as G said); (2) **it breaks the fail-soft law** — an *unstocked* shop of a cached
type inherits that crate instead of parody. The second is a correctness bug, not a mix-up. Seam spec
published (cache key = `type + base`; per-shop preload on entry; LRU=1 + dispose; **C consumes whatever
identity field lands — C does not invent it**). **C owns and lands it** — the §7 promise coming due.
**Two gaps block F's gate #7a (neither is C's; both flagged):** (1) **the crate has no street**
`redhill_real` doesn't exist and monsterrobot isn't in any town's `shops[]` (G's ask #2 → E/Fable);
(2) the shell's `interior_mode.js:192` calls `getStockPack(shop.type)` with `base` fixed to
`assets/models/`, so it can't reach a per-shop pack until §8's seam + the identity field land (C+F).
*Process note:* the shared tree was checked out on `lane-G-godverse` for Fable's review, so I committed
these C docs from a temporary `git worktree` on `main` — the shared checkout was never touched, and G's
branch keeps the disjoint namespace the two-studio merge law depends on.
---
## 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).