PROCITY/docs/LANES/LANE_C_PUB.md
m3ultra d2eb9df03f Lane C R23 (v5.0-alpha): the first real crate verified in the room (ledger #3)
Verified G's stock_godverse/3962749/ (120 real records, Monster Robot Party crate 550,
2 x 2048^2, cell 256) in a fresh context — deliberately BEFORE the merge, so a contract
failure would block it. It didn't.

PASS — the contract holds; G re-emits nothing:
- Resolves through the existing stockAdapter path via a base override (no C code change for
  the single-shop alpha). 120 items / 2 atlases / 8-of-8 stock meshes atlas-backed (real
  covers: peperecordings, DEEPCHILD, "39", mff FREAKS...).
- Ceilings hold: 118 draws with real covers (<=350 law), pathOK, 0 carves.
- Fail-soft holds (clean cache): missing atlas -> null -> parody (20 meshes, 110 draws);
  no adapter (?noassets shape) -> parody, zero fetch. 0 console errors.
- G's index compliant: items[] sorted by id (my determinism ask honoured), provenance, uv in
  [0,1], 2 atlases = exactly the cap, cell_w/h harmless (C reads uv, never cell).

CORRECTED MY OWN CEILING (LANE_C_PUB §7.2): I wrote "<=8 MB decoded" from an assumed ~16-item
"typical shop". A real record shop is 120 records => 2 x 2048^2 ~= 32 MB, 4x my number. The
structural rules held (<=2 atlases; 2048^2 for 60+ items — G landed on the cap); only the MB
figure was wrong, because it described a shop that doesn't exist. New: <=2 atlases @<=2048^2,
LRU = 1 shop's atlas set resident => <=32 MB peak (bounded; vs E's 181 MB unbounded today).
Do NOT shrink the cell below 256 to save VRAM — the dig renders covers close up.

G's ask #1 CONFIRMED and worse than reported (LANE_C_PUB §8 — C's spec, C's fix): stockpack
keys _packs/_resolved by type alone, so base is ignored on a cache hit (measured: the 2nd
preload returned the identical object). (1) two stocked shops collide; (2) it BREAKS FAIL-SOFT
— an unstocked shop of a cached type inherits that crate instead of parody. Seam: key by
type+base, per-shop preload on entry, LRU=1 + dispose; C consumes whatever identity field
lands, C does not invent it.

Two gaps block F's #7a, neither C's: the crate has no street (redhill_real absent; monsterrobot
not in any shops[] — G's ask #2 to E/Fable), and interior_mode.js:192 pins base to
assets/models/ (needs §8 + the identity field).

Committed from a temp git worktree on main — the shared checkout stayed on lane-G-godverse
(Fable's review) and G's branch keeps its disjoint namespace. Docs-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:42:30 +10:00

325 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 — interiors contract (venues + real-town class mapping) → Lanes A/D/E/F
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane D + Lane F. §0§5 describe the *shipped*
> venue state; changes require a version bump (CITY_SPEC amendment law). Verified R13 (build) + R14 (audit).
> **v3.1 amendment:** §3 became the `stage.backline[]` contract (ledger #5 — the amp unification).
> **v4.0-beta amendment (2026-07-16):** **§6 added — the real-town shop-class → archetype mapping**
> (ledger #2, the density widening; C→E handshake). The frozen venue contract (§0§5) is unchanged.
> **v4.0 amendment (2026-07-16):** **§7 added — the G2a per-shop stock-atlas contract** (C's interior
> draw/tex ceilings, answering Lane G + E's review). §0§6 unchanged.
> **v5.0-alpha amendment (2026-07-16):** **§7.2's VRAM number corrected + §7.8 added** — G's first real
> crate (120 records) proved my "≤8 MB" figure was derived from a wrong assumption, and the per-shop cache
> seam is now specified for G+F. **G's R23 atlas needs no re-emit.** §0§6 unchanged.
*The district ships **three** venue archetypes behind `?gigs=1``pub`, `band_room`, `rsl`. Lane A
converts a chosen shop to a venue kind in place (`shop.type = kind`); C keys the interior recipe off
`shop.type`. All three return the SAME shape (below), so Lane D drops rigs in and Lane F reads state with
no per-kind branching. Non-venue rooms are byte-identical (venue code runs only for `recipe.venue`, on its
own seed sub-streams). Verified fresh (seeds 20261990/1990/7/42/101 + 90-build soak): 0 throws · 0 carves ·
0 path-fails · 0 determinism-fails · leak geo0/tex0 · worst build 8.3 ms. Budgets in §3.*
---
## 0. The pose `ry` convention (pinned) — READ THIS FIRST
**Canonical convention (every C pose uses it):** a pose's `ry` is the yaw of a thing whose **front /
outward normal is local Z at ry=0**, i.e. world-facing `(sin ry, cos ry)`. `counter.stand`,
`browsePoints`, `watchPoints` and `bandPoses` ALL follow it, and the box placeholder (`placeholder.js`) is
built to it (toes at Z). `keepers.js` greet math assumes it. **Drop a rig in at a pose's `ry` and it faces
the right way — no per-consumer flip.**
**Why no flip (the R13 fix, for the record):** the GLB fleet meshes ship facing local **+Z** at ry=0 — the
opposite of the Z convention (verified: the anterior toe-bone vector `toe foot` reads `(≈0, +0.99)`).
Before R13 this was compensated per-consumer, inconsistently: `band.js` flipped by π (band/crowd correct,
placeholders backward) while `keepers.js` did not (placeholders correct, **keepers + browsers 180° backward
whenever the GLB fleet was loaded** — they'd been shipping back-to-front). **D fixed it at the source in
`rigs.js buildFigure`: the cloned `inner` is rotated π about Y so every GLB rig faces Z**, matching the
placeholder and every C pose. All consumer flips were then deleted. Net: keepers, browsers, band and crowd
all face correctly, GLB and placeholder modes are consistent, and C's pure-data poses never moved (no golden
touched). If you add a new rig consumer, plant it at the pose `ry` with no flip.
---
## 1. What `buildInterior` returns for a venue (`shop.type` ∈ `pub` | `band_room` | `rsl`)
Everything below is **pure data on the return** (room-local coords, the ry convention in §0). Lane D drops
rigs straight in; Lane F reads state — no new lifecycle vs. the R9 keeper/browser seam.
### `room.stage` — the band stage, a **4-piece** (null for non-venues)
```js
room.stage = {
x, z, // deck centre (against the back wall)
w, d, // deck size (m) — varies by kind (table below)
deckY, // deck top surface height (m) — the front line stands here
riserY, // drum riser top height (m) = deckY + 0.16 — the drummer sits here
frontZ, // z of the deck lip (the crowd stands in front of this, +Z)
bandPoses: [ { x, z, ry, role, y, seated? } ×4 ],
}
```
- `bandPoses[0..2]` = the **front line**: `role``guitar` / `vocal` / `bass`, across the deck lip, `y = deckY`.
- `bandPoses[3]` = the **drummer**: `role: 'drums'`, `seated: true`, up-stage centre on the drum riser,
`y = riserY` (= `deckY + 0.16`, the `fittings.js` stage riser box).
- **Lane D:** lift each member to its own `pose.y` (front line = `deckY`, drummer = `riserY`). `role` hints
the instrument — E's GLBs via `opts.instrumentFor(role)` (electric_guitar/bass_guitar/mic_stand/drum_kit),
primitives otherwise (asset law). `seated:true` → seated bob. `ry = π` already faces the audience (+Z) — no
flip (§0).
- The band is a **4-piece** for v3.0 (front-line trio + drummer). Reverting to a trio would be a version bump
(drop `bandPoses[3]`), not a runtime toggle.
### `room.watchPoints` — the audience, **cap by kind** (empty `[]` for non-venues)
```js
room.watchPoints = [ { x, z, ry, dance, slotIndex } ] // seeded floor poses, all facing the stage centre
```
- **Crowd cap by kind: `pub` 8 · `band_room` 8 · `rsl` 12** (the RSL is the crowd-cap stress case). The room
fills what geometry allows up to the cap; **the count IS the crowd cap** (F asserts crowd ≤ `watchPoints.length`).
- `dance` seeded **~⅓ true** ("a bit of both"). D: `dance:false` → stand-and-watch idle; `dance:true` → bob/sway
with a **seeded phase offset** (use `slotIndex`/coords). Every point walkable, reachable, clear of the keeper
stand + browse points + stage/PA. Deterministic per `shop.seed`.
### `room.counter` / keeper — unchanged
The bar is a normal counter; the barkeep is just a keeper at `room.counter.stand` (R9 seam). Faces the
customer correctly under §0.
### `room.audio.gigKey` — the live bed
`room.audio = { musicKey, toneKey, gigKey? }`. The manifest key **is** the gigKey, canonical form
**`gig-<genreKey>`** — no mapping table. F passes gig state via `opts.gig`:
```js
buildInterior(rslShop, THREE, { gig: { on: true } }) // → room.audio.gigKey === 'gig-covers'
```
- Genre resolves `opts.gig.genreKey` → the venue's own `shop.genreKey` (A sets it) → a kind default
(`pub`→pubrock, `band_room`→grunge, `rsl`→covers). Present **only when a gig is on**.
- **Prefer `gigKey` over `musicKey`** while the gig plays. E's beds are `gig-pubrock` / `gig-grunge` /
`gig-covers`; a missing key → silence (audio law), band still visible.
- C hand-builds `'gig-'+genreKey` (byte-identical to citygen's `gigKeyFor`, selfcheck-asserted) rather than
importing it — deliberately, to keep the interiors lib standalone (it never imports citygen).
### The venue table (C-owned tuning)
| kind | archetype bias | ceiling | deck `w × d`, `deckY` | crowd cap | genre → gigKey | dressing |
|---|---|---|---|---|---|---|
| `pub` | wide / hall | 3.6 m | `0.58·W × 2.0`, 0.32 | 8 | pubrock → `gig-pubrock` | pub tables, sticky carpet, bar |
| `band_room` | cosy / pokey / wide | **3.4 m (low)** | `0.62·W × 1.6`, **0.20** (barely a riser) | 8 | grunge → `gig-grunge` | near-empty standing room, tin-shed dingy |
| `rsl` | hall / wide | **4.0 m (tall)** | `0.50·W × 2.4`, **0.40** | **12** | covers → `gig-covers` | flock walls, club carpet, bistro tables, members' bar |
## 2. Quiet-night variant
No `opts.gig` → a normal interior: `stage` + `watchPoints` are still returned as data, but there's **no
`gigKey`** (room-tone + maybe the seeded radio `musicKey`), and D spawns no crowd. A seeded dark night reads
true — the venue is just a quiet pub/club.
## 3. `stage.backline[]` — the amp slots (v3.1, ledger #5)
`room.stage.backline = [ { x, z, ry, y } ×2 ]` — seeded up-stage amp/spare-cab poses, **pure data** (C plants
no amp meshes as of v3.1). This is the unified backline: **Lane D plants E's `guitar_amp` GLB here** (its own
primitive fallback under `?noassets`) — one amp system, GLB when present. C's old primitive ampStacks are
**dropped** from all three venue recipes.
- **slot[0]** = stage-right (the primary amp) at `(stage.x + stage.w·0.36, deckY, stage.z stage.d·0.22)`
identical to D's R15-verified plant, so wiring D's amp to `backline[0]` moves nothing on screen.
**slot[1]** = stage-left spare cab (mirror). `ry = π` faces the audience (+Z, the §0 convention); `y = deckY`.
- **Guarantees** (verified fresh, seed 20261990, pub/band_room/rsl): every slot is **up-stage of the front
line**, flanks the drum riser, **clear of all 4 bandPoses** (nearest-bass clearance **0.87 m pub / 0.70 m
band_room / 0.92 m rsl**) and **all watchPoints** (≥1.95 m), and **on-deck** (within the deck footprint).
Deterministic per `shop.seed`. **Lane D:** verify no interpenetration once wired (C's R15 clearance discipline).
- **Draw impact:** dropping C's primitives shaved ~4 draws off each empty venue shell (drawSweep glb-off:
pub 122 · band_room 120 · rsl 131 ≤350); D's GLB/primitive at `backline[0]` adds it back on the gig night.
- *v3.2 (parked):* the drummer sit clip, instrument LOD, and C's `audioEmitter` idea.
## 4. Budgets (measured R14, seed 20261990, fresh context)
- **Empty shells** (`drawSweep` glb-off): band_room **124** · pub **126** · rsl **135** — town-wide worst
161 (opshop/hall). ≤ 350. ✓ (identical to R13 — C geometry unchanged).
- **Gig-night interior draws**, D's `GigCrew` with the real instrument GLBs + amp (localdepot): pub **60** ·
band_room **52** · rsl **71**. Asset-free (placeholder crowd, no GLBs) worst RSL **237** (16 rigs). ≤ 350. ✓
- **Tris**: fleet peds ~2.7k each; E's instrument GLBs **14k each** (decimated to the ≤15k target), 5 on a
stage = 70k — the instruments dominate. RSL gig-night ≈ **182k tris** once the async GLBs settle. The
interior gate is **draws** (passes); tris are the *street* budget (200k), so this is not an interior gate.
*Note for E/F:* F's gig-night smoke reports ~34k because it measures **before** the async instrument GLBs
load; the settled scene is ~180k. A camera-distance LOD on instruments is a v3.1 candidate.
- **Determinism / release-law**: C's interior code is unchanged since R13; D's continuity + RY-fix ride
disjoint rng namespaces (`ctx.stream(salt)` vs `rng(citySeed,kind,id)`) and cannot perturb C's placement
or any golden. 90-build venue soak: 0 determinism · 0 carve · 0 path-fail · worst 8.3 ms; leak geo0/tex0.
## 5. The archetypes (C-owned)
`band_room` = a tin shed: small (cosy/pokey), low ceiling, barely-a-riser deck, near-empty standing floor,
bar in the corner — the PA louder than the room. `rsl` = carpet-and-flock club: the biggest floor in town
(hall/wide), a proper raised stage at one end, bistro tables round the edge, members' bar, the biggest crowd
cap. Both reuse the `pub` venue machinery (`theme.js venueSpec` + `layout.js` placeStage/placeWatchPoints);
only the dressing + `venueSpec` differ. Stage/PA keep GLB slots for Lane E (primitive fallback holds); the
backline amp is D's, planted at `stage.backline[0]` (see §3).
---
## 6. Real-town shop-class → interior archetype mapping (v4.0-beta, ledger #2) → Lane E
**Directive (John, kickoff — SUBTLE: texture, not takeover).** The secondhand shops stay the **heroes** of
the town (they *are* the game). The widening adds general retail as *texture* — the everyday main-street
staples a real Australian town has — mapped onto **existing** archetypes (**no new archetypes this round**).
The player should feel the town's more alive, not that the game changed.
**How it's applied (E owns the emission).** `plan_osm` keeps only **registry types** (`SHOP_TYPES` — unknowns
flatten to `opshop`). So E maps each fetched OSM `shop=*` / `amenity=*` value to a **registry type** below and
bakes it into the cache's `shop.type`. Every target is an existing type, so C's recipes, A's registry, and the
budgets are all unchanged. Unknown/unlisted classes degrade safely to `opshop` (R18-proven: 18 real OSM types
all built valid).
**Heroes — secondhand (unchanged; NEVER subsampled):**
| OSM class | → type |
|---|---|
| `charity`, `second_hand`, `antiques`, `vintage` | `opshop` |
| `books`, `bookshop` | `book` |
| `music` | `record` |
| `video`, `games` | `video` |
| `pawnbroker` | `pawn` |
| `toys` | `toy` |
**Widened — everyday main-street texture (general retail):**
| OSM class(es) | → type | wears it as |
|---|---|---|
| `bakery`, `cafe`†, `confectionery`, `pastry`, `chocolate`, `deli`/`delicatessen`, `butcher`, `cheese`, `coffee`, `tea` | `milkbar` | the food counter |
| `convenience`, `kiosk`, `general`, `dairy` | `milkbar` | the corner store |
| `newsagent`, `stationery` | `milkbar` | papers + magazines |
| `chemist`, `pharmacy`, `cosmetics`, `perfumery` | `milkbar` | dispensary counter |
| `clothes`, `fashion`, `boutique`, `shoes`, `bag`, `jewelry`/`jewellery` | `dept` | the dressed retail floor |
| `department_store`, `variety_store`, `supermarket` | `dept` | big general retail |
| `hardware`, `doityourself`, `trade`, `electronics`, `mobile_phone`, `furniture`, `houseware`/`homeware` | `dept` | shelved goods |
| `greengrocer`, `florist`, `farm`, `garden_centre` | `stall` | produce/plants on trestles |
`cafe`/`fast_food` are `amenity=*`, not `shop=*` — fetch them from the amenity tag; everything else is `shop=*`.
**Fallback:** any fetched class not listed → `opshop`.
**Copy-paste map for E (`build_towns.py`):**
```json
{
"charity":"opshop","second_hand":"opshop","antiques":"opshop","vintage":"opshop",
"books":"book","bookshop":"book","music":"record","video":"video","games":"video",
"pawnbroker":"pawn","toys":"toy",
"bakery":"milkbar","cafe":"milkbar","confectionery":"milkbar","pastry":"milkbar","chocolate":"milkbar",
"deli":"milkbar","delicatessen":"milkbar","butcher":"milkbar","cheese":"milkbar","coffee":"milkbar","tea":"milkbar",
"convenience":"milkbar","kiosk":"milkbar","general":"milkbar","dairy":"milkbar",
"newsagent":"milkbar","stationery":"milkbar",
"chemist":"milkbar","pharmacy":"milkbar","cosmetics":"milkbar","perfumery":"milkbar",
"clothes":"dept","fashion":"dept","boutique":"dept","shoes":"dept","bag":"dept","jewelry":"dept","jewellery":"dept",
"department_store":"dept","variety_store":"dept","supermarket":"dept",
"hardware":"dept","doityourself":"dept","trade":"dept","electronics":"dept","mobile_phone":"dept",
"furniture":"dept","houseware":"dept","homeware":"dept",
"greengrocer":"stall","florist":"stall","farm":"stall","garden_centre":"stall"
}
```
Default (not in the map) → `opshop`.
**Subtlety guardrails (E):**
- Fetch only these tasteful staples — **not** every OSM `shop=*`. Skip vehicle/tyres/fuel, funeral, storage,
industrial, and pure services the player never browses (`hairdresser`, `beauty`, `travel_agency`, `bank`,
`laundry`, `estate_agent`).
- **Never** subsample the secondhand heroes.
- If a town's general-retail count would visually **drown** the secondhand strip, seed-subsample the *texture*
classes (the milkbar/dept/stall targets) toward **~23× the secondhand count**, and count the drops in
`norm.dropped` like every other drop.
**Why these targets:** `milkbar` (counter + fridge + magazine rack + glass case) is the canonical Australian
counter shop — it wears cafe/bakery/deli/newsagent/chemist naturally; `dept` (cube shelves + clothes racks +
glass cases) wears clothes + hardware + big retail; `stall` (trestles + crates) wears produce + florist. Three
general-retail looks + the six secondhand heroes + the three venues = variety with no new archetypes.
**Pre-verified (seed 20261990, real-town lot 8.5×14):** milkbar/dept/stall/opshop all build valid (0 carves,
≤143 draws); `dept` reads as a clean **dressed-retail floor** (cube shelves + clothes racks + counter, the
escalator does *not* dominate the ~7.9×11.7 room), so `clothes`/`hardware` → `dept` is confirmed. C re-checks
the full result in the five-town door-soak (ledger #6) once E's caches land. (FYI: C's `theme.js canonicalType`/ALIAS already recognizes several
of these — `deli`/`corner_store`→milkbar etc. — for any direct `buildInterior(rawClass)`; the authoritative
mapping for the caches is this table.)
---
## 7. G2a — per-shop stock atlases: C's interior ceilings (v4.0, ledger #3) → Lane G + Lane E
*Answering Fable's three priors + E's half (LANE_E_NOTES). **Verdict: all three priors hold — atlas-per-shop
is the right shape, and it's 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 and I own the fix.*
**1. Draw ceiling — ONE atlas per shop (hard cap 2).**
Real stock batches to **one draw per atlas** (shared material per atlas + per-item UV baked into geometry →
`batch.js` merges). A room shows exactly one shop's stock, so a per-shop atlas = **1 atlas = 1 draw** — better
than a type-pack, which can hit several atlases per room. **Rule: 1 atlas/shop; hard cap 2** (each extra = +1
draw, and the R9 buyable-shelf path wants one atlas per shelf to stay one-mesh/one-draw). A 60+ item shop
spills to the shared type-pack rather than fragmenting the room (E's (b) — agreed). *Headroom: `?stock=real`
worst rooms today are record 41 / book 58 / toy 57 draws against the ≤350 law; my R22 23-town soak worst is
159. Draws are not the constraint.*
**2. Texture/VRAM ceiling — the real limit. E's dispose ask: GRANTED, and it's mine to land.**
E's audit is correct and the cause is a deliberate C design note (`stockpack.js:11`): atlas textures are
"shared/cached in the pack (**NOT per-room**), like depot GLBs". That is **right for 3 bounded type-packs**
and **wrong for unbounded per-shop atlases** (23 towns × N shops). The ceiling:
> **⚠️ CORRECTED v5.0-alpha — measurement beat my assumption.** I wrote "≤8 MB decoded" by assuming a
> "typical shop" is ~16 items @1024². **G's first real crate is 120 records** (Monster Robot Party, crate
> 550) — a real record shop is nothing like 16 items. At the contract's own 256² cell that's 2 × 2048²
> = **~32 MB decoded**, 4× my stated figure. The *structural* rules were right (≤2 atlases; 2048² for a
> 60+ item shop) — only the MB number was wrong, because it was derived from a shop that doesn't exist.
> **G's R23 atlas is compliant; no re-emit.** The corrected ceiling:
- **Per shop: ≤2 atlases, ≤2048² each** (unchanged — G's 120-item crate lands exactly on the cap).
- **Resident: ONE shop's atlas set** (LRU = 1 shop, not 2) ⇒ **≤32 MB decoded peak**. Still bounded, and
still far better than the **181 MB unbounded** accretion E measured today.
- **≤1024² whenever the crate fits it** (≤64 items at 256²) — don't hand a 6-item shop a 2048².
- Cell size is E/G's call: **C reads `uv`, never `cell`** (so 256², 176×256 portrait, etc. are all fine).
Do **not** shrink the cell below 256² to save VRAM — the dig renders a cover close-up; resolution is the
one thing the player actually sees.
- **C lands the LRU cap + dispose in `stockpack.js`** (evicted shop's atlas texture + material disposed).
Type-packs keep today's shared cache (bounded, reused across rooms of a type) — unchanged.
- **Scheduling:** today's 181 MB accretion is **pre-existing**, and `?stock=real` is **opt-in**
(`index.html:196` — `params.get('stock')==='real' && !NOASSETS`), so it cannot touch the default boot and is
**not a v4.0-tag blocker**. I land the cap as **C's first v4.x item — before G2a ships**. G: build to the
ceiling; the loader will honour it.
**3. Index schema — E's (c) confirmed VERBATIM, keyed per shop.**
`stock_shop_<godverseShopId>_index.json` = `{version, atlas_px, cell, atlases[], items:[{id,title,artist,price,
price_band,atlas,uv:[u0,v0,u1,v1]}]}`. `stockpack.js` consumes it unchanged (it already groups items by atlas
and shares a material per atlas). **UV origin stays top-left (image-natural) — C flips V for WebGL** (R7 law,
unchanged). Book cell 176×256 portrait is fine: **C reads `uv`, never `cell`**.
**4. Fail-soft + `?noassets` — unchanged law, confirmed.**
Index/atlas missing → `getStockPack` → null → **parody canvas + one warn** (R7, proven since). `?noassets`
fetches zero. A godverse town under `?noassets` stays silent-and-fine. `?stock=real` opt-in ⇒ G2a cannot
regress the default or classic boots.
**5. Determinism — one ask back to G.**
The seeded pick per bin is C's and stays: same `shop.seed` → same crate contents, byte-identical revisits.
That indexes into `items[]`, so **G must emit `items[]` in a deterministic order** (sort by `id`) — otherwise
a re-emit silently reshuffles every crate. Same discipline as E's byte-identical cache re-runs.
**6. Buy-anywhere (R9) — compatible, no change.**
`buildBuyableShelf` merges per-item-addressable covers from ONE atlas → one draw (`noBatch`). A per-shop atlas
satisfies that naturally (better than the type-pack's seeded `atlasPool` pick). Parody = not buyable stays.
**7. Parody law — E's (d), unchanged and not C's to relax.** Metadata deterministically parody-transformed;
cover art real; prices real-or-seeded. *(v5.0-alpha: John amended this at tier 1 — real titles/artists ship
in-house-green, flagged before public. Recorded in G's provenance; not C's to relax either way.)*
**8. The per-shop cache seam (v5.0-alpha — C's spec; G+F build to this). CONFIRMED BROKEN in R23.**
G's ask #1 is real and I reproduced it. `stockpack.js` keys `_packs`/`_resolved` by **`type` alone**, and
`preloadStockPack(type, {base})` **ignores `base` on a cache hit**. Measured (R23, fresh context):
- `preloadStockPack('record', {base:'assets/stock_godverse/3962749/'})` → the 120-record godverse pack ✓
- then `preloadStockPack('record', {base:'assets/models/'})`**returned the identical object**
Two consequences, the second worse than G's report:
1. **Two stocked shops collide** — the 2nd silently serves the 1st shop's crate (blocks v5.0-beta).
2. **It breaks the fail-soft law** — an *unstocked* shop of a cached type inherits that crate instead of
falling back to parody. (On a clean cache fail-soft is intact: missing atlas → `null` → parody, 0 errors.
The collision is what defeats it, so this is a correctness bug, not just a mix-up.)
**The seam (C owns it, C lands it):**
- **Cache key = `type + base`** (equivalently a `packKey`; a godverse shop's key is its `stock_godverse/<godverse_id>/` base). `getStockPack` takes the same key.
- **Resolution order** stays: explicit per-shop base → the type-pack base → `null` → parody.
- **Per-shop preload on entry** (interior build/enter), **not** a boot-time preload of every shop.
- **LRU = 1 shop's atlas set, disposed on eviction** (§7.2) — the load-bearing half of the §7 promise.
- **Identity link:** a stocked shop must carry its pack pointer on the shop record (G's `godverse_id`
`stock_godverse/<id>/`). Not in the town cache today — G flagged it ("monsterrobot must be added to the
godverse cache's `shops[]` as id 3962749"). C consumes whatever field lands; **C does not invent it.**
**9. R23 verification — the first real crate, in a room (ledger #3). PASS.**
Measured against G's `3962749` atlas (120 real records, 2 × 2048², cell 256²) in a fresh context:
- **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, not parody).
- **Ceilings hold:** **118 draws** with real covers (≤350 law); room `pathOK`, 0 carves.
- **Fail-soft holds** (clean cache): missing atlas → `null` → parody (20 parody meshes, 110 draws, pathOK);
no `stockAdapter` (the `?noassets` shape) → parody, zero fetch. **0 console errors** throughout.
- **G's index is contract-compliant:** `items[]` sorted by id (my determinism ask honoured ✓), provenance
present ✓, `uv ⊂ [0,1]` ✓, 2 atlases = the cap ✓, `cell_w/cell_h` harmless (C reads `uv`) ✓.
- **Verdict: the contract holds. G re-emits nothing.** The only C-side work is §8's seam.