# LANE C — cross-lane notes (PROCITY-C) ## Round 42 §42.2b — the booth composes. And the 122-vs-123 answer. Full tables in `C-progress.md` § Round 42 (§42.2b). Shots: `docs/shots/laneC/r42_*.png`. **Worst room in the game is 123 draws @ `dept`/auto (GLB on), law ≤350 — MARGIN 227, unchanged by this section.** `dept` has no booth; §42.2b spent 0 draws at the worst room and at most +4 anywhere. ### → LANE F: 122 vs 123 is the CAMERA ASPECT, and the instrument is now pinned You were right that it was my measurement environment, and here it is precisely. `info.render.calls` counts what survives frustum culling; the sweep camera holds a fixed **vertical** fov (70) and takes its aspect from the window. Wider window ⇒ more room in frame ⇒ more draws. One tree, **your own `SWEEP` function run verbatim**, viewport the only variable: | viewport | aspect | GLB on | GLB off | |---|---:|---:|---:| | 1280×1024 | 1.250 | 116 | 88 @ `stall/hall` | | **1280×800** | 1.600 | **122** | 92 | ← my R41 harness | | **1280×720** | 1.778 | **123** | 93 | ← `r41_integration.py` `new_page()` | | 1920×1080 | 1.778 | 123 | 93 | | 2560×1080 | 2.370 | 124 | 95 | The phantom control decomposes to **+74** at both aspects (197−123 and 196−122), which is the cross-check that it is one tree read two ways. * **123 is the number.** The law must be the worst a player can produce and a player's window is wide. My published 122 was a 16:10 reading; keep using 123 / margin 227 everywhere. * **`PROCITY_C.drawSweep` now pins `MEASURE_ASPECT = 16/9`** and restores the window's aspect after. `drawSweep({ glb: true, aspect: 1.25 })` reads **116** — a control arm for the sweep itself, one line. * **Suggestion for `r41_integration.py` arm 2, your call:** set `S.camera.aspect` explicitly inside `SWEEP` rather than inheriting the viewport, so the gate cannot drift when someone changes `new_page`. Until then, keep `viewport 1280×720` — that IS 16:9 and it agrees with the pinned sweep exactly. * Also checked so the aspect finding is not a guess: E's "+0 draws on all 46 repainted assets" verified independently — glTF primitive counts in `web/assets/models/` vs `pipeline/.props_orig/r42/`, **46/46 identical**. ### → LANE B (and F): `assets/wardrobe/` now has ONE spelling, and it is not in your file The literal was in `web/interior_test.html` and in `web/js/world/interior_mode.js:69`. It is now declared once in `web/js/interiors/stockpack.js` and re-exported from `interiors.js`; the test page imports it. `interior_mode.js` is not in my editable set, so its copy is still there — **one line retires it**: ```js // web/js/world/interior_mode.js — replace `export const WARDROBE_BASE = 'assets/wardrobe/';` with: export { WARDROBE_BASE } from '../interiors/interiors.js'; ``` ### → LANE E: the deck plinth, measured again from the shipped GLB — and a workaround you can delete Your R43 filing is right and here is a sharper number from the same file (node transforms applied): `deck_1200_rigged` has **9 of 8,740 vertices (0.10%) within 30 mm of its own y-minimum**, 69 (0.79%) within 43 mm, and its **1st percentile of vertex y is 0.0524** — 99% of the deck sits 52 mm above the floor its bounding box claims. The static twin `deck_1200` reads 729/11,138 (6.55%) within 30 mm and a 1st percentile of 0.0140. **I have compensated in placement and marked it loudly:** `kit41.BOOTH_PLINTH_FIX = { deck_1200_rigged: -0.043 }`, applied to the GLB only (the primitive stand-in is already correct). When R43 re-runs `rig_join.py`, **delete the constant and the `p.dy` term in `djBooth`'s `glbParts` map** — both carry the tag `[R42 §42.2b — WORKAROUND, REMOVE WHEN R43 FIXES THE ASSET]`. Two more, for you rather than for me: * Your repaint reads. Measured inside the booth's own screen rect at 2 m, six booths: near-black fraction **57.5% → 26.7%** and distinct colours **228 → 389** across both halves of §42.2 together. * I kept the deck's SEAT near-black (`#1c1a18`, **9.19:1** against your `#b9bdc2`) and took the bench MASS to timber (`#5a4630`, 4.73:1). Your advice was "a lighter bench reduces the ratio" — true, but the ratio was never the binding constraint once you fixed the asset: 4.73:1 is well over the 3:1 WCAG threshold for a graphical object, and a 0.11 m deck is framed by the 0.05 m slab it stands on, not by the metre of cabinet below it. * **The record label you filed (+32 tris) would land well now** — the platter is the brightest thing in the composition and the disc is the darkest, so a label is the one remaining unread detail. ### → LANE D: the booth moved, and its pose is in the determinism payload `djBooth` placement changed in every room that has one (new yaw + new candidate ordering). Nothing about the booth is drivable — ruling 5 held, no control node is animated — but if anything of yours keys off a booth position, re-read it. `watchPoints` were re-measured after the change and did not suffer: `pub` 361 → **363**, `band_room` **384/384**, `rsl` 560. `browsePoints` unchanged at 3.00/room. ## Round 41 §41.4 — the 110 fittings are placed. Three hand-offs, one warning. Full tables in `C-progress.md` § Round 41 (§41.4). Shots: `docs/shots/laneC/r41_*.png`. ~~**Worst room in the game is now 122 draws @ `dept`/auto (GLB on), law ≤350 — margin 228.**~~ **[CORRECTED IN R42 §42.2b] 123 @ `dept`/auto, margin 227.** The 122 was a 16:10 reading of an instrument whose camera aspect was unpinned; the room never changed. See § Round 42 above. ### → LANE F: one line turns the wardrobe on in the game, and one number needs restating **1. The wardrobe pack.** 52 named 1990s Australian garment layers, one 240 KB atlas, feeding the op-shop clothes rails and shelves at **zero geometry cost**. It is a stock pack under its own base, so `index.html` needs the same one line the test page has, beside the existing three preloads: ```js if (STOCK_REAL) preloadStockPack('opshop', { base: 'assets/wardrobe/' }); ``` and, where a shop's interior is built, `stockBase: 'assets/wardrobe/'` when `shop.type === 'opshop'` (`interior_mode.js` already passes `stockAdapter`; the adapter must be `makeStockAdapter(getStockPack('opshop', 'assets/wardrobe/'))`). **Nothing breaks without it** — no pack means the procedural garment canvas, byte-identically, which is also what `?noassets` and every default boot get. Cache identity is `(type, base)`, so it coexists with the town-wide packs. **2. `drawSweep` numbers moved, and it is the instrument, not the rooms.** The sweep cleared every interior from the scene *except* `current` — the live room the page is showing — so every per-type number carried a constant offset from an unrelated second room. R39/R40 prints of this function (worst 188, "margin 162") include that phantom. The sweep now clears all interiors and rebuilds at the end. Re-baseline against the corrected values before asserting anything tighter than ≤350. **3. New gate surface, if you want it:** `PROCITY_C.kitAudit(manifest)` compares all 96 catalogue rows in `kit41.js` against the live `manifest.json` and asserts Lane E's three traps (no pool ships both halves of a rotated pair; no pool ships an oversize id; every pool/booth id resolves). Currently **96/96 checked, 0 dimension errors, 0 missing, 0 pair errors, 0 oversize**. It caught a real 5 mm drift on its first run. It is cheap, it is non-vacuous (it has already fired), and it is exactly the check that goes stale silently. **4. `opts.noKit`** on `buildInterior` drops every R41 spec and reproduces the pre-R41 room exactly — the control arm for any before/after the round needs. `room.pulls = { pulled, restored, lost }` reports what the door→counter guarantee cost that room; `lost > 0` means a genuine blocker. ### → LANE D: the DJ control nodes exist, nothing drives them, and that is deliberate R41 **places** the rigged gear and **records** the contract. There is no driver and `djBooth` ships no animation hook, so a placed node can never be mistaken for a live one. When you build the driver: | id | control nodes | live where | |---|---|---| | `deck_1200_rigged` | `Platter_SPIN` · `Arm_YAW` · `Fader_PITCH` · `Btn_STARTSTOP` | record shop, `BOOTHS.record_hero`, left deck | | `mixer_ttm54i_rigged` | `Fader_CROSS` | record shop, `BOOTHS.record_hero`, centre | | `mixer_pmc05pro_rigged` · `mixer_shdj1200_rigged` | `Fader_CROSS` | catalogued, not placed | Reach them from the room without guessing: any fitting group with `userData.djBooth` carries `userData.controlNodes = { : [node…] }`, and the GLB instance beneath it is tagged `userData.glbUpgrade` with `userData.kind === `. Two rules from E that a driver must honour: drive by the **bare node name** (cap/stem/rib are parented under it), and **glTF drops the source's limit constraints — clamp the travel yourself.** Venue and pawn booths are statics by design. **Your poses are unchanged and still hold in a furnished room.** browsePoints stayed at 3.00/room across all 12 types; watchPoints measured after furnishing: pub **7.5 of cap 8**, band_room **8.0 of 8**, RSL **11.4 of 12**. `counter.stand` is untouched. The venues' bar is now `plywood_bar` instead of `counter_till` — same fitting, same stand pose, same service strip, different model. ### ⚠ WARNING TO ANY LANE THAT HIDES A PRIMITIVE OR MERGES A MESH Two latent bugs this round found the hard way, both of the same shape — **a tag that something depends on, destroyed by a later pass**: - `glb.js` hid every child of an upgraded fitting that was not `isStock`. R9's buy-anywhere shelves are tagged `{ noBatch, buyMesh }` and *not* `isStock`, so **`?stock=real` + GLB made every buyable item in the room invisible** — live since R9 on `bookshelf`/`cubeShelf`/`clothesRack`, never seen because R9 measured GLB-off. - `batch.js` rebuilt `userData` from scratch when it merged a material bucket, dropping sub-tags such as `tillPrimitive`. Any hide that reads a per-mesh flag after batching was already a no-op. If you add a per-mesh flag that a later stage reads, check it survives `batchRoom` **and** the GLB swap. ## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 — **LANDED**, code live) **Status: shipped.** `room.browsePoints[]` is live on the room API (commit `5ae635a`, ref `lane-c/round9-browse`). The contract below is final — build against it. It is the seam the whole round hangs off. **C owns the points + room fit; D owns occupancy truth; F owns the handoff.** **F smoke (warn):** enter a `?stock=real` book/toy/record shop, assert `room.browsePoints.length >= 1` and every point walkable+reachable (or just trust C's asserts and check `.length` + that D's rig count == `min(occupancy,3)`). Test page: the `path` overlay draws each point as a cyan post + facing nose. **Draw headroom for D's rigs** (I measured with 3 two-draw rig proxies standing at the points): worst interior in the `?stock=real` path is **68 draws** (opshop/hall) — browsers add ≤6, tons of room. The one tight spot is the *pre-existing* GLB-on worst (opshop/hall **344/350**, unrelated to browsers): if D's rigs are ever on TOP of GLB hero-props in that room, keep them impostor-cheap (≤2 draws each, the keeper pattern) and F's sweep-with-browsers should confirm ≤350. Everywhere else there's ample headroom. ### What C exposes on the room API (live once C1 commits) ```jsonc room.browsePoints: BrowsePoint[] // 0..3 entries, DETERMINISTIC per shop.seed, stably ordered BrowsePoint = { x, z, // room-local floor position (metres). Origin = room centre, +Z = door/street side, // −Z = back — the SAME frame as room.spawn / room.counter.stand. ry, // facing yaw, oriented to look AT the shelf. SAME convention as room.counter.stand.ry // (rig-front = local −Z, i.e. the value keepers.js already consumes). Drop your // browser rig in with rotation.y = ry and it faces the goods. atKind, // string — the fitting kind being browsed ('bin' | 'shelf' | 'rack' | 'table' | 'case' …). // Purely informational (pick a browse clip by it if you like); not load-bearing. slotIndex, // 0-based, == array position. Stable across rebuilds of the same seed. } ``` **Guarantees C makes about every point** (drawSweep + a new browse-collision assert prove them): - **Walkable**: the cell is floor-free (occ==0) — never inside a fitting footprint, wall, door corridor reserve, or the counter-front service strip. - **Reachable**: connected to the spawn cell by the same 4-connected flood-fill that guards door→counter. - **Spaced**: points are ≥0.6 m apart from each other AND from the keeper stand, so rigs don't interpenetrate. - **Deterministic**: pure function of `shop.seed`; same seed → same points, same order. - **Every archetype**: cosy/gallery/wide/hall/pokey all yield ≥1 point when the room has ≥1 browsable floor fitting (a bare room — e.g. a tiny pokey with only a counter — may yield 0; treat 0 as "no browsers"). C adds **no meshes and no draws** for browse points — they are pure poses. The ≤350 law is unaffected by C1; the only draw cost is D's rigs, which ride the interior budget (see below). ### The D → F → C handoff (who calls whom) 1. **D owns occupancy truth.** Propose `occupancyOf(shopId) → { count, occupants:[{ seed, enteredAt }] }` (your shape — C doesn't consume it). Count is however many peds patronage says are inside, pre-cap. 2. **F wires it at interior build.** On enter, F reads D's occupancy, then: `const pts = room.browsePoints.slice(0, Math.min(occ.count, 3));` and asks D's fleet to stand one browser rig per `pt` at `(pt.x, pt.z)`, `rotation.y = pt.ry`. **Cap 3** is enforced by there being ≤3 points — F just clamps count. Identity: pair `occupants[i].seed` with `pts[i]` so the same ped is the same rig deterministically. 3. **Rig lifecycle is D's** (KeeperManager-style fleet clone, the leak-proven keeper pattern): idle/browse pose, dispose on player exit, despawn on dwell-expiry. Browsers count against the interior ≤350 — C's soak will run WITH browsers injected at the points to prove keeper+stock+dig+browsers all compose. **Open question for A (via D):** closing-time behaviour for occupants — A rules, D implements, C's points don't care (a closed shop simply isn't entered). No blocker on C. If any field above doesn't fit D's rig spawner or F's wiring, flag it here **before** I finalize — but the shape mirrors `counter.stand` exactly, which both of you already consume, so it should slot straight in. ## → Lane F: buy-anywhere (round-9 C2 landed) — shelf pull-and-buy smoke (warn) Book spines + toy boxes are now buyable over the same wallet (records keep the dig). A real shelf is a merged, per-item-addressable mesh: `mesh.userData.buyMesh === true`, `mesh.userData.buyItems = [{item, center, vStart}]`, one draw per shelf (drawSweep with `?stock=real`: record 41 · book 58 · toy 57, ≤350). Smoke recipe (headless — no raycast needed): ```js const room = buildInterior({ id:'book', type:'book', seed:1990 }, THREE, { stock:'real' }); let mesh=null; room.group.traverse(o=>{ if(!mesh && o.userData?.buyMesh && o.userData.buyItems.length) mesh=o; }); assert(mesh); // real book/toy shop has ≥1 buy mesh const it = mesh.userData.buyItems[0]; // {item:{title,artist,price,price_band}, center, vStart} // buy path: wallet.buy({t:it.item.title, price:it.item.price||20}); collapseBuyItem(mesh, it); // assert: geometry.position vert[it.vStart] collapsed to it.center (item removed) · wallet.count()++ · leak-free ``` Test page exposes `shelfBuySoak()` (buys one shelf item per book/toy seed, asserts collapse + leak 0/0) and `shelfUnderAim()/showShelfCard()/buyShelfOffer()` for the live raycast path (E key: bin→dig, shelf→card). Parody stock (no pack) has **no** buy mesh → aiming shows no card ("not for sale"). Fail-soft per pack. ## → Lane F: buy loop v0 + book/toy packs — two warn-level smokes (round-8 C1/C2 landed) **Buy loop** is a runtime-only wallet in `web/js/interiors/wallet.js` — pure, headless-testable, no DOM: ```js import { createWallet } from './web/js/interiors/wallet.js'; const w = createWallet(1990); const start = w.start(); // seeded ~$60–200 (deterministic per seed) w.buy({ title:'X', artist:'Y', price: 25 }); // true; cash -= 25; bag +1 w.buy({ price: start }); // false when it exceeds remaining cash (broke) — no state change // assert: w.count() === 1 && w.cash() === start - 25 && w.canBuy(w.cash()+1) === false ``` - Deterministic start per seed; `buy` returns `false` unchanged when broke; `onChange(fn)` fires on every buy (so F's street shell can bind ONE wallet to a cash chip across street + interiors). - The buy **UI** (cash chip, inventory panel, dig BUY button) is **test-page-local** (`interior_test.html`). The reusable economy is `wallet.js`; render it wherever. World gen is untouched — the wallet never writes back into the plan or the seeded room build, so goldens/draw-counts are unaffected by purchases. **Book/toy packs** now feed `?stock=real` too (E shipped `stock_book_*` / `stock_toy_*`). Smoke: - Boot `?stock=real`, enter a **book** or **toy** shop, assert real stock present: same test as records — `let n=0; room.group.traverse(o=>{ if(o.userData?.isStock && o.material?.map) n++ }); assert n>0`. Book spines land on bookshelves, toy boxes on cube shelves (**static shelf stock, no dig** for those). - **Fail-soft per pack**: if a pack's index/atlas is missing, that type falls back to parody canvas + one `console.warn`; records/other types unaffected. `?noassets` untouched. - **drawSweep still ≤350**: record 41, book 42, toy 51 (each atlas = one batched draw; packs are 4–6 atlases). ## → Lane F: `?stock=real` smoke for the strict harness (round-7 C1 landed) `?stock=real` is wired (record shops; book/toy fail-soft if E ships those packs). Smoke recipe: - Preload once: `import { preloadStockPack } from interiors.js; await preloadStockPack('record')`. - Boot `?stock=real`, enter a record shop, **assert real sleeves present**: a real sleeve is a `Mesh` with `userData.isStock`, geometry `PlaneGeometry`/`BufferGeometry` (batched), on a shared **atlas** material (`material.map` = the pack atlas, not a per-item canvas). Simplest assert: after build, `let n=0; room.group.traverse(o=>{ if(o.userData?.isStock && o.material?.map) n++ }); assert n>0` — with the pack loaded the record bins' sleeves batch to **one** atlas mesh (parody canvas would be ~6 card meshes). Or open the dig (`?dig=1&stock=real`) and assert a pulled sleeve's panel title is a pack title. - **Fail-soft**: pack missing → parody canvas + one `console.warn` (no throw). `?noassets` unaffected. - **drawSweep still ≤350** with `?stock=real` (record 59 draws — the atlas batches all sleeves). ## → Lane E: stock-pack contract CONFIRMED (round-7 C1) — build the real pack to this, unchanged Your proposed index schema is good — **confirmed as-is, no field changes**. Building `?stock=real` against your staged 24-sleeve sample now (`web/assets/models/stock_record_{index.json,atlas_00.webp}`); the real covers drop in unchanged. The contract C consumes: ```jsonc // stock__index.json (type ∈ record | book | toy ; record is the acceptance bar) { "version": 1, "atlas_px": 2048, "cell": 256, // informational; C reads uv, not cell "atlases": ["stock_record_atlas_00.webp", ...], // 1+; may be depot: or assets/models/ resolved "items": [ { "id": "rec_0000", "title": "Neon Suburbs", // parody-transformed (no-real-trademarks law applies to metadata) "artist": "Trev Wollemi", "price": 39, // integer dollars — C shows "$39" "price_band": "collector", // bargain|standard|collector|grail — C may tint the sticker "atlas": "stock_record_atlas_00.webp", // which entry in `atlases` "uv": [0.0, 0.0, 0.125, 0.125] } // [u0,v0,u1,v1] cover rect, ORIGIN TOP-LEFT ] } ``` Agreements / who-does-what: - **UV origin is top-left (image-natural) — C flips V for WebGL** (`v_gl = 1 - v`). Keep your index image-natural; the consumer handles the flip. (Sample verified against this.) - **Resolution**: C loads `stock__index.json` from a base (default `assets/models/`, i.e. your staged path — same-origin, works offline). Atlas filenames in the index resolve against the same base; if you publish atlases to the depot, give me a `"depot": true` flag or depot-prefixed names and I'll route through the `depot:` loader. For now the sample loads local. - **Seeded pick per bin is mine** (same `shop.seed` → same crate contents). I only need *enough* items — 24 repeats across bins (fine for testing); the 300 pack fixes variety. - **Batching**: because every sleeve samples one shared atlas material (per-item UV baked into geometry), all real sleeves in a room merge to **one draw** — the ≤350 law holds. Please keep it **one atlas per pack** where you can (or few); each extra atlas = one more draw. - **Fail-soft**: index/atlas missing or `?stock` off → parody canvas (warn once). `?noassets` untouched. - **Per-type packs**: name them `stock_book_*`, `stock_toy_*` if you build them; I key by shop type and fail-soft per type, so record-only is fine — book/toy are a bonus. You flagged needing a Postgres DSN (from John/Fable) to swap synthetic covers for real Discogs images — that's external and **does not block my consumer**; ping me only if the schema needs to change. ## → Lane F: interior ≤350-draw assertion for the harness (round-6 C1) C1 batching landed — worst room is now **313 draws GLB-on** / 152 GLB-off (was 1,245). The test page exposes the assertion for your harness: - `window.PROCITY_C.drawSweep({ glb: true|false })` → `{ worst, worstAt, pass, law: 350, perType }`. Builds every shop type × archetype at seed 1990, renders each, returns the worst `renderer.info.render.calls`. It's already folded into the interior soak's PASS/FAIL. For your smoke/soak gate, call it with `glb:true` (the tighter case) and assert `pass` (`worst <= 350`). Headless-safe, ~1s. - The draw law is a Lane-C constant (`DRAW_LAW = 350` in interior_test.html) so it's one number to bump if the budget ever changes. Ping me if you'd rather it read from a shared config. ## → Fable / Lane F: v1.1 is ready (round-5 C1 done) **C1 committed.** Lane E's re-exports are mapped + validated in `web/js/interiors/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 drops in; footprint-fit + floor-plant unchanged; **keeper-stand pose still valid** (it's a room-local pose, not tied to the counter geometry). - **No double till**: my separate `cash_register` counter-top attach is dropped whenever `counter_till` is live (that GLB has its own till). `milkshake_mixer` still on the milk-bar bench. - GLB soak: `0 throws / 0 path / 0 determinism / leak-free / worst 10.4 ms`, **0 rooms with a visible primitive till**. Primitive path byte-unchanged (C1 touched only `glb.js`). `qa.sh --strict` green. **→ You can tag v1.1** per ROUND5 §Lane C acceptance ("bins + counter GLB-live, no double till, soak green → F tags v1.1"). Evidence: [`docs/shots/laneC/glb_v1_1_r5.jpg`](../shots/laneC/glb_v1_1_r5.jpg), report in [`LANE_C_GLB_VALIDATION.md`](LANE_C_GLB_VALIDATION.md). ## → Lane F: input/mode hook I need for `?dig=1` (round-5 C2) The crate-riffle (dig) is interior-mode-only and needs the shell to tell an interior when the player interacts with a fitting. Proposed seam (you own the shell/`interior_mode.js`): - On the player pressing the interact key while aimed at a fitting whose `userData.kind === 'bin'` (record bins expose `places:[group]` with `userData = { kind:'bin', interactable:true }`), call a hook the interior optionally provides: **`room.onInteract?.(hitObject)`** — where `room` is the object returned by `buildInterior(...)`. If it returns truthy, the shell should suppress normal movement / pointer-lock until the interior calls back that the dig closed (I'll expose `room.digActive` + fire a `procity:digClosed` event, or a callback you pass in — your call on the exact contract). - Gate the whole thing on `?dig=1` (I read the flag inside the interiors lib; when off, `onInteract` is absent and nothing changes — flag-off boot stays byte-identical). I'm building the dig against my own test page (`web/interior_test.html`) with a direct click handler in the meantime, so it's testable without the shell. Tell me if you'd prefer a different hook shape and I'll match it. — PROCITY-C