PROCITY/docs/LANES/LANE_C_PUB.md
m3ultra ba901d4a75 Lane C R24: the contract fix — the runtime is the authority (ledger #1, the root cause)
F held the tag because my published atlas contract and my own loader were mutually
incompatible. That was mine. Closed, and published FIRST so G and E re-emit against it.

WHAT WAS WRONG: my R22 §7 invented `stock_shop_<godverseShopId>_index.json`; the loader
(stockpack.js:25) fetches `${base}stock_${type}_index.json`. An index named per the contract
can never be fetched by the code meant to load it. G built runtime-compatible (correct), E
built contract-compatible (also correct — the contract said so), and E's validator globbed
stock_shop_*_index.json -> zero files -> vacuous pass. Nobody was wrong; the contract was.

§7 REWRITTEN AGAINST THE CODE (LANE_C_PUB):
- Authority rule: stockpack.js IS the contract; doc vs loader disagreement = doc bug.
- Layout ratified as G shipped it: assets/stock_godverse/<godverseShopId>/stock_<type>_index.json
  (`type` is load-bearing — SLOT_FOR picks the slot). stock_shop_* RETIRED.
  -> E: the glob becomes stock_godverse/**/stock_*_index.json (the vacuous gate's actual cause).
- Runtime-read vs ignored stated exactly: reads atlases[] + items[{atlas,uv}] (uv top-left, the
  loader flips V); ignores version/atlas_px/cell*/count/kind/tier/shop/crate/provenance.
- Provenance drift dies: runtime reads none of it, so the authority is E's validator + the 23
  shipped town caches -> TOP-LEVEL `license` (US), attribution, generator, snapshot. British
  `licence` retired; G lifts+renames those four, keeps its rich nested provenance{} as colour.
  atlas_px stays REQUIRED (checked against real dims). cell* informational — C reads uv, never
  cell; do NOT shrink the cell for VRAM (the dig renders covers close-up).

CODE (F's filed item): stockpack cache identity is now `${base}|${type}`;
getStockPack(type, base = DEFAULT_STOCK_BASE); new opts.stockBase on buildInterior = the seam F
wires. Omitted => town-wide packs, byte-identical to today. Pre-v5 callers untouched.

VERIFIED FRESH — and I fixed my own metric first: R23's "8/8 atlas-backed" only checked that a
map exists, but a parody canvas has a map too (same species as the vacuous gate). Re-asserted
falsifiably: every sleeve's texture source IS stock_godverse/3962749/stock_record_atlas_00/01.webp,
pack items == index items by id; negative control — the parody room's textures are (canvas, no src)
and touch the atlas zero times, so the metric discriminates. Collision FIXED (shop 120 + town 350
coexist). Fail-soft FIXED with a populated cache (missing base -> null -> parody). 98 draws with
real covers, pathOK, 0 carves, 0 console errors.

SCOPED NOT SLID: the LRU+dispose (§7.6) lands at v5.0-beta — one stocked shop can't accrete, and
safe eviction needs a room-exit hook (a live room holds the pack's shared material) that arrives
with F's per-shop preload. Written into the contract so it can't slide silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:43:50 +10:00

22 KiB
Raw Blame History

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 REWRITE (2026-07-17, R24 ledger #1): §7 rewritten against the code — the runtime is the authority. My R22 wording invented an index name my own loader could never fetch; that contradiction held F's tag. §7 now states what stockpack.js actually implements, extended per-shop, and the type+base cache keying is landed. G's R23 atlas layout is ratified (G re-emits provenance field names only, §7.3). §0§6 unchanged.

The district ships three venue archetypes behind ?gigs=1pub, 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.typepub | 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)

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

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:

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

{
  "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/hardwaredept 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. Real-stock packs — THE RUNTIME IS THE AUTHORITY (v5.0-alpha, R24 ledger #1) → Lanes E/F/G

This section was the root cause of R23's held tag and has been rewritten against the code. My R22 wording invented an index name (stock_shop_<godverseShopId>_index.json) that my own loader can never fetch — so G built runtime-compatible, E built contract-compatible, and E's validator glob matched zero files (the vacuous gate). Nobody was wrong; the contract was. Rule going forward: web/js/interiors/stockpack.js is the contract. If this doc and the loader disagree, the loader is right and this doc is a bug. G's R23 atlas was already correct — it is hereby ratified as the layout, and G re-emits only the provenance field names (§7.3).

7.1 File layout (loader-exact)

The loader is preloadStockPack(type, { base }) → it fetches <base>stock_<type>_index.json.

  • typerecord | book | toy — and it is not cosmetic: type picks the stock slot (SLOT_FOR: record→sleeve, book→spine, toy→box). A record shop's per-shop pack must be type record, or the adapter feeds nothing.
  • Town-wide packs (unchanged): assets/models/stock_<type>_index.json.
  • Per-shop packs (v5): assets/stock_godverse/<godverseShopId>/stock_<type>_index.json — the base is the per-shop directory; the filename is the same. (This is exactly G's R23 layout.)
  • RETIRED: stock_shop_<id>_index.json — never loadable; do not emit, do not glob for it. → Lane E: validate_atlas.py's glob must become stock_godverse/**/stock_*_index.json (the current stock_shop_*_index.json is why it passed on 0 files).

7.2 Fields the RUNTIME reads (absence ⇒ pack null ⇒ parody)

  • atlases: string[] — resolved against the same base; depot:/https: entries pass through.
  • items: [...], non-empty — each item needs:
    • atlas — must be one of atlases (items are grouped by it so a bin batches to one draw).
    • uv: [u0,v0,u1,v1], origin TOP-LEFT — the loader flips V itself (uvRect). Do not pre-flip.
  • Read by the dig/buy consumers (not by the loader): id, title, artist, price, price_band.

7.3 Fields the runtime IGNORES — and the provenance ruling (the drift dies here)

version, atlas_px, cell/cell_w/cell_h, count, kind, tier, shop, crate, provenancestockpack.js reads none of them. So the authority for their shape is not C's loader but E's validate_atlas.py, and the house convention set by 23 shipped town caches:

  • Provenance lives at TOP LEVEL: license, attribution, generator, snapshot — US spelling license, exactly what E's validator reads and what every town cache already uses. British licence is retired. → Lane G: rename + lift those four to top level.
  • Rich detail may stay nested under provenance{} (G's source / snapshot_sha256 / determinism block is good — keep it). The four gate fields are the contract; the block is colour.
  • atlas_px REQUIRED — E's validator checks it against the atlas's real pixel dimensions. That is a genuine falsifiable check; keep it.
  • cell / cell_w / cell_h are informational. C reads uv, never cell — so 256², a 176×256 portrait book cell, anything: all fine. Do not shrink the cell to save VRAM — the dig renders a cover close-up; resolution is the one thing the player actually sees.

7.4 Cache identity = (type, base) — landed R24

stockpack.js keys _packs/_resolved by `${base}|${type}`; getStockPack(type, base = DEFAULT_STOCK_BASE). Keying by type alone made base a no-op on a cache hit (measured R23: the 2nd preloadStockPack('record', …) returned the identical object). That was two bugs:

  1. two stocked shops collide — the 2nd serves the 1st's crate; and
  2. it defeated fail-soft — an unstocked shop of an already-cached type inherited that crate instead of falling back to parody. (A correctness bug, not a mix-up.) Both fixed and re-measured (§7.7). Pre-v5 callers are unaffected: no base ⇒ the town-wide pack.

7.5 The seam (→ Lane F, ledger #6a)

buildInterior(shop, THREE, { stock: 'real', stockBase: `assets/stock_godverse/${shop.godverseShopId}/` })

opts.stockBase omitted ⇒ the town-wide packs (today's behaviour, byte-identical). Preload the shop's pack on entry with the same base. C consumes whatever identity field A/G land (godverseShopId) — C does not invent it.

7.6 Ceilings (C's, unchanged from the R23 correction)

  • Draws: one atlas = one draw; ≤2 atlases per shop (G's 120-record crate lands on the cap); a 60+ item shop may use 2048²; spill beyond 2 → the town-wide pack. Room law ≤350 stands.
  • VRAM: ≤2 atlases @≤2048²; LRU = 1 shop's atlas set resident ⇒ ≤32 MB peak. (R23 correction: my "≤8 MB" assumed a ~16-item "typical shop". A real record shop is 120 records.)
  • The LRU + dispose is C's, and it lands at v5.0-beta, not here: with ONE stocked shop there is no accretion, and safe eviction needs a room-exit hook (a live room holds the pack's shared material) — that seam arrives with F's per-shop preload. Stated so it can't slide silently.

7.7 R24 verification — falsifiable, per the new vacuous-gate law

Fresh context, against G's merged stock_godverse/3962749/ (120 real records):

  • Every sleeve samples G's atlas file — texture sources are exactly stock_godverse/3962749/stock_record_atlas_00.webp / _01.webp; pack items ≡ index items by id. (Not "covers render": a parody canvas also has a .map. The negative control below is what makes this a gate — I had to fix my own R23 metric to satisfy the law.)
  • Negative control: the parody room's stock textures are (canvas, no src) and touch the godverse atlas zero times — the metric discriminates.
  • Collision fixed: shop pack (120 items) and town pack (350) coexist as distinct objects.
  • Fail-soft with a POPULATED cache: missing base ⇒ null ⇒ parody (the R23 bug is gone).
  • Ceilings: 98 draws with real covers via opts.stockBase; pathOK; 0 carves; 0 console errors.