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

24 KiB
Raw Blame History

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
1280×720 1.778 123 93
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:

// 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:

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)

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):

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:

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:

// 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, report in 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