PROCITY/docs/LANES/LANE_C_NOTES.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

365 lines
24 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 — 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 (197123 and 196122), 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 = { <id>: [node…] }`, and the GLB instance beneath it is tagged
`userData.glbUpgrade` with `userData.kind === <id>`. 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 ~$60200 (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 46 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_<type>_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_<type>_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