D moved the backline plant (66aec0f) to C's §3 corrected pose exactly
(band.js:236 = stage.x + stage.w*0.36, deckY, stage.z - stage.d*0.22).
Verified fresh (port-isolated context, seed 20261990, D's real GigCrew + GLB backline):
- matches §3 both venues; bass clearance 0.87 m (pub) / 0.92 m (rsl) — the R14
interpenetration (0.29 m) is resolved; on-deck; flanks C's primitive ampStack
(4.4 m pub / 3.5 m rsl) as a clean up-stage backline; no band/crowd clash.
- rendered pub gig confirms the read (amp up-stage behind the bassist).
LANE_C_PUB §3 updated RESOLVED; freeze otherwise stands. No code change (docs only).
Nothing else blocks on C — F runs the release (continuity wiring, alias delete, week
soak, tour, tag v3.0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
44 KiB
LANE C — INTERIORS · progress (PROCITY-C)
Status: v1 complete & verified. Standalone interiors library + test page. Every shop door opens into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.
Last updated: 2026-07-16 (round 15) · owner: PROCITY-C · reviewer: Fable
Update 2026-07-16 (round 15, v3.0: closing the release) — verified D's amp fix
R15 §Lane C (one item): verify D's guitar_amp reposition (ledger #4). D moved the plant (commit 66aec0f)
to C's §3 corrected pose exactly — band.js:236 amp.position.set(stage.x + stage.w·0.36, deckY, stage.z − stage.d·0.22). Verified (fresh port-isolated context, seed 20261990, D's real GigCrew + GLB backline):
matches §3 (both venues); bass clearance 0.87 m (pub) / 0.92 m (rsl) — the R14 interpenetration (0.29 m)
is gone; on-deck within footprint; flanks C's primitive ampStack (opposite side, 4.4 m pub / 3.5 m rsl) as a
clean up-stage backline; no clash with band/crowd/watchPoints. Rendered pub gig confirms the read (amp up-stage
behind the bassist, primitive up-stage-left, band clear). LANE_C_PUB §3 updated RESOLVED. Freeze otherwise
stands; no other C change this round. Nothing else blocks on C — F runs the release (continuity wiring + smoke,
alias deletion, week soak, tour, tag v3.0).
Update 2026-07-16 (round 14, v3.0: the release) — venue audit + docs freeze (light round)
R14 §Lane C: interior release audit, the guitar_amp ruling, and the docs freeze. C's interior code was
unchanged since R13, so venue geometry is stable by construction; the audit re-verified it against the
current tree (D's R13 continuity + RY-fix + real instrument GLBs landed) and froze the contracts. Ran an
adversarial audit workflow (10 agents: conformance + D-integration/amp + docs-freeze edit list, then a
verify pass) — it earned its keep (caught the amp defect I'd first waved through + two stale comments).
Empirical re-verify (fresh context, port-isolated server):
- Venue contract byte-identical to R13: caps 8/8/12, deckY 0.32/0.20/0.40, riserY 0.48/0.36/0.56, gigKeys gig-pubrock/grunge/covers, quiet-night = no gigKey, determinism true.
drawSweepglb-off: band_room 124 · pub 126 · rsl 135 (worst 161 opshop/hall) ≤350 — unchanged.- Gig-night draws with D's real
GigCrew+ GLB instruments (localdepot): pub 60 · band_room 52 · rsl 71 ≤350. Asset-free worst RSL 237. All 4 instruments load (guitar/bass/mic/drums). - Release-law confirmed (workflow, CONFIRMED): D's continuity + the RY fix ride a disjoint rng namespace
(
rng(citySeed,kind,id)) from C'sctx.stream(salt); the RY fix is a constant Y-rotation inbuildFigure(no rng). Cannot perturb C's placement or any golden.
guitar_amp ruling (debt for C) → Lane D: NOT blessed as-is — reposition up-stage. D plants the GLB
at (stage.x+stage.w·0.32, deckY, stage.z+0.15) — that +0.15 is down-stage, landing the 0.5 m amp 0.29 m
from the bass rig (interpenetrates the bassist, confirmed by render + 3 audit agents), and it's redundant
with C's own primitive ampStacks (placeStage plants 1–2 up-stage at (±stage.w·0.28, deckY, stage.z − stage.d·0.18)). Corrected pose in LANE_C_PUB.md §3: move up-stage-right ≈
(stage.w·0.36, deckY, stage.z − stage.d·0.22), clear of the bass and offset from C's primitives → a clean
flanking backline. Full amp unification (C exposes stage.backline[], GLB replaces primitive) = v3.1.
Cosmetic + ?gigs=1 opt-in → does not block the tag.
Tri observation → E/F (not a C gate): E's decimated instrument GLBs are 14k tris each (5 on a stage = 70k; peds ~2.7k each), so a settled RSL gig-night interior is ~182k tris. The interior gate is draws (passes at ≤71); the 200k tris is the street budget, so no violation. But F's gig-night smoke reports ~34k because it measures before the async instrument GLBs load — the real settled scene is ~5× heavier. A camera-distance instrument LOD is a v3.1 candidate.
Docs freeze (v3.0-FROZEN markers added):
LANE_C_PUB.md— retitled + frozen marker; §0 ry convention reframed past-tense (the +Z fleet was fixed at the source in D'srigs.js buildFigure, R13 — no per-consumer flips); 4-piece stated as shipped (veto language removed); the amp ruling added (§3); budgets updated to R14; in-flight handoffs removed.LANE_C_AUDIO.md— frozen marker + a scope note (this covers base interior audio; gig audiogigKeylives in LANE_C_PUB); the deferredaudioEmitteridea re-targeted to v3.1.
Code (comment-only, zero runtime/golden/rng impact): fixed two stale R12 inline return-shape comments in
interiors.js (watchPoints 6..8 → 8/8/12 caps; stage bandPoses[3] → [4] + riserY) + one in
layout.js; added a note at the gigKey hand-build explaining it's deliberately standalone (byte-identical to
citygen's gigKeyFor, not imported — keeps the interiors lib citygen-free). theme.js untouched.
Update 2026-07-15 (round 13, v3.0-beta: the district) — two more rooms + the drummer
R13 §Lane C. The district: two new venue archetypes + the 4-piece band + the RY-facing audit. Interfaces (re)published in docs/LANES/LANE_C_PUB.md — read §0 first (the pinned ry convention + the keeper/browser back-to-front finding for Lane D). Committed C-only by pathspec (the tree had E/B/D changes landing concurrently — procity-shared-tree-hazard).
Shipped (C-owned files only): theme.js, layout.js, interiors.js.
band_room+rslrecipes (theme.js, bothvenue:true), reusing the pub venue machinery with a newvenueSpecfield.band_room= small/scuzzy tin shed (cosy/pokey bias, low 3.4 m ceiling, deck barely a riserdeckY 0.2, near-empty standing floor).rsl= carpet-and-flock club (hall/wide bias, tall 4.0 m, proper stagedeckY 0.4, bistro tables, the biggest crowd cap). A converts a shop withshop.type = kind; C keys the recipe off it.SHOP_TYPESnow auto-enrols both indrawSweep/soak.- 4-piece band (debt #3):
placeStagegrowsstage.bandPosesto 4 — front-line trio (guitar/vocal/bass,y=deckY) + a drummer (role:'drums',seated:true, up-stage on the drum riser,y=riserY=deckY+0.16). Each pose now carries its owny;stage.riserYadded. Applies to all three archetypes (the pub gains a drummer too). Pure geometry — no new rng draw, determinism intact. - Per-kind venue tuning:
placeStage/placeWatchPointsreadrecipe.venueSpec→ deck size/height + crowd cap (pub/band_room 8, rsl 12 — the crowd-cap stress case). Pub values == the R12 constants, so the pub stage geometry is byte-identical (bar the new drummer). - Robust gigKey (debt #1):
audio.gigKey = 'gig-'+genreKey, genre resolvingopts.gig.genreKey→shop.genreKey(A sets it) → a kind default. Matches E's renamed manifest bedsgig-pubrock/gig-grunge/gig-coversexactly (verified: manifest has all three, zero stalepubrock-live). One key, no mapping.
RY-facing audit (debt #4) — see LANE_C_PUB.md §0 for the full write-up + fix. Headline:
- Canonical convention pinned: rig-front = local −Z at ry=0 (every C pose + the box placeholder use it).
- The GLB fleet mesh faces +Z (opposite). Verified in a fresh context two ways: toe-bone probe
(
toe−foot = (0,+0.99)on 5 peds) and dotting a fleet rig planted atcounter.stand.ryagainst the intended customer-facing (−0.99= 180° backward;+π→+0.99). - Finding: keepers + browsers have been facing 180° backward whenever the GLB fleet is loaded —
keepers.jsspawns atpose.rywith no flip, and the mesh front is +Z. The R12 band reads right only viaband.js RY_FLIP=π. Placeholder (−Z) and GLB (+Z) are opposite, so it's inconsistent both ways. - Recommended fix for Lane D (visual-only, no golden moves): normalise at the source — rotate the clone
innerby π inrigs.js buildFigureso every GLB rig faces −Z; thenkeepers.jsstays flip-free (now correct) andband.jsDROPSRY_FLIP. Repro below.
→ Lane F (flags_check.py): two smokes are RED by design — bump band == 3 → == 4. My 4th bandPose
makes the band a 4-piece (D's band.js already iterates bandPoses, so it spawns 4 today), which trips F's
R12 assertions verbatim as the round-13 instructions anticipated ("F's band==3 smoke moves to 4"):
tools/flags_check.py:674—if night['band'] == 3→== 4.tools/flags_check.py:794—if na['inRoom'] and na['band'] == 3 and na['crowd'] > 0→== 4. (The noassets gig still happens — band 4, crowd 8, 0 fetches, 0 console errors; silent-and-happy holds. It only "fails" because 3 is hardcoded.) These are the ONLY qa reds from C; the other 5 gates pass.
→ Lane D: lift each band member to pose.y (front line deckY unchanged; drummer sits at riserY);
seated:true on the drums pose → seated bob; raise band.js CROWD_CAP to the watchPoint cap so the RSL's 12
fill (I measured the full 12). And apply the §0 facing fix. Repro for the audit (paste in the interior test page):
const rigs = await import('./js/citizens/rigs.js'); const THREE = await import('three');
const fleet = rigs.loadPedFleet('models/peds/'); await fleet.whenReady;
const sp = rigs.spawnRig(fleet.all[2], { ry: 0, height: 1.75 }); sp.fig.updateWorldMatrix(true, true);
let foot, toe, wp = new THREE.Vector3();
sp.inner.traverse(o => { if (o.isBone){ o.getWorldPosition(wp);
if (/LeftFoot$/i.test(o.name)) foot = wp.clone(); if (/LeftToeBase$/i.test(o.name)) toe = wp.clone(); }});
console.log('fleet forward at ry=0:', toe.clone().sub(foot).setY(0).normalize()); // → (0, +1) = +Z
Budgets (measured, fresh context, seed 20261990):
- Empty shells (
drawSweepglb-off): band_room 124 · pub 126 · rsl 135 (worst town-wide 161 opshop/hall) ≤350. - Gig-night incl. full crowd + 4-piece: worst RSL 237 draws asset-free (16 rigs) / 77 GLB, 40.9k tris. pub 176/56, band_room 173/53. ≤350 draws / 200k tris. ✓
- 90-build venue soak: 0 determinism · 0 carve · 0 path-fail · worst 8.3 ms; leak geo0/tex0.
soak(60)in the page: PASS 60 rooms, leak 0/0, determinism identical, worst draws 85. qa figure-scale gate: 24 figs human-sized. Shot: docs/shots/laneC/rsl_gig_r13.jpg — RSL, 4-piece facing the crowd, crowd facing the stage (facing reads correct via band.js's flip).
Drummer veto (John): default is Fable's 4-piece ruling (executed). To revert to a front-line trio, drop
bandPoses[3] in layout.js placeStage (1 line) + F reverts the smoke to == 3 — say so before D wires it.
Update 2026-07-15 (round 12, v3.0-alpha) — the pub venue: archetype + watchPoints
R12 §Lane C (venues & gigs, behind ?gigs=1). Interface published in
docs/LANES/LANE_C_PUB.md — the "publish interface first" deliverable D/F build on.
pubarchetype (theme.js,venue:true): low stage + PA cabinets + amps at the back wall (newstage/paSpeaker/ampStackfittings, GLB slotsstage/pa_speaker/ampfor E), bar counter (corner— the barkeep is just a keeper), sticky-carpet floor, the blocked back doorway = the green room.room.watchPoints[]— 6–8 seeded audience floor poses{x,z,ry,dance,slotIndex}facing the stage centre (counter.standry convention).danceseeded ~⅓ true (John: "a bit of both"). Walkable, reachable, clear of keeper/browse/stage.room.stage={x,z,w,d,deckY,frontZ,bandPoses[3]}so D drops the band on the deck.room.audio.gigKey— F passesopts.gig(gig state); a gig-on pub returnsgigKey='gig-'+genreKey(gig-pubrockfor the alpha), else a quiet-night interior. F prefers gigKey over musicKey. (→ E: name the live bedgig-pubrock.)- Flags-off byte-identical: venue code runs only for
recipe.venue, on its ownstage/watchsub- streams — non-pub rooms havewatchPoints:[],stage:null, unchanged audio + placement. - Verified: 40-seed pub sweep — 0 throws · 0 carves (PA
areaFree-guarded off the counter strip) · 0 path-fails · 8 watchPoints each · dance 0.38. 60-room mixed soak (incl. pub) — 0 throws/det/path fails, leak geo0/tex0, worst 19 ms. Non-pub rooms confirmed unchanged.
Update 2026-07-15 (round 11, audio) — room.audio contract
R11 §Lane C ("one contract answer"). Confirmed Fable's proposal: buildInterior now returns
room.audio = { musicKey, toneKey }, seeded per shop — F's interior_mode plays it, B's audio.js
resolves the keys. Full contract + F wiring recipe: docs/LANES/LANE_C_AUDIO.md.
toneKey→ amanifest.audio.ambienceinterior room-tone (always set);musicKey→ amanifest.audio.musicbed, or null (no music). Keys, not files — buildInterior stays synchronous and asset-free; silence on missing/?mute=1/?noassets=1(house audio law).- Maps mirror the manifest's per-type
typesarrays (one source of truth): record/milkbar/video/dept/ arcade always play their bed; opshop/book/toy/pawn/stall are room-tone-only, except a seeded ~1-in-3 keeps a general radio on (thegeneral-tagged milk-bar bed). - Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches manifest
typesmembership; deterministic per shop.qa.sh --strictgreen. - Optional visual-source prop (
userData.audioEmitter) deferred to v2.2 (in the note) — out of round scope.
Update 2026-07-15 (round 9) — ship v2.0: browse points (C→D→F seam) + buy-anywhere
Round-9 §Lane C. The round hangs off C1 (two lanes build on it), so it shipped first as a handshake + working code; C2 widens buying beyond the dig.
C1 — browse points (the round's one cross-lane seam). layout.js placeBrowsePoints() exposes
room.browsePoints[] — 1–3 deterministic floor poses in front of browsable fittings, facing the goods,
where Lane D stands browser rigs (F wires occupancy → points). Same coord frame + ry convention as
counter.stand, so D's fleet clones drop straight in. Pure data — no meshes, no draws; the ≤350 law
is untouched by C1. Guarantees (all asserted headless): walkable (occ-free, not reserved), reachable from
spawn (the door→counter flood-fill), ≥0.6 m apart + clear of the keeper stand, deterministic per
shop.seed, ≥1 point in every archetype that has a browsable fitting. A rich candidate ring (4 sides × 2
gaps + diagonals) + a nearest-free-cell fallback covers tight/mismatched rooms. Interface published in
LANE_C_NOTES.md before code (decision #1 half-day handshake) → committed
first so D/F unblock. Validated: 180 type×archetype×seed combos → 0 empties (177×3 pts, 3×1 via
fallback), determinism 0 fails, min-sep 0.7 m, 0 keeper clashes. Test page: cyan browse-post markers on the
path overlay + a HUD count.
C2 — buy-anywhere (decision #2). Book spines + toy boxes get pull-and-buy over the same wallet;
records keep the deep dig. New stockpack.buildBuyableShelf() builds each real shelf as merged,
per-item-addressable meshes — one mesh per atlas (all covers on a shelf drawn from a single seeded
atlas → one draw/shelf), tagged noBatch so batch.js leaves them intact. Aiming a spine/box raises a
pull card (title / author-or-maker / $price / band from the pack index); BUY debits the wallet, adds to
inventory, and collapseBuyItem() zero-areas that item's quad in place (removed from the shelf, one
draw, no geometry churn). Parody stock has no buy mesh → no card ("not for sale"). Fail-soft per pack.
Validated: raycast→card→BUY works end-to-end (item collapses to its centre, cash/inventory update, card
closes); buy-soak 6/6 bought, collapseOk, leak geo 0 / tex 0; draws with ?stock=real record 41 ·
book 58 · toy 57 (≤350); full drawSweep unchanged (GLB-off 168 · GLB-on 344, both pass); placement
determinism 0 fails. Shot: browse_buy_r9.jpg.
→ D: consume room.browsePoints (contract in LANE_C_NOTES) — stand rigs at the first
min(occupancy, 3) points. → F: two new warn smokes (hooks in LANE_C_NOTES): browse-points present +
valid, and shelf-buy (aim → card → wallet debit → collapse). C's soak covers keeper+stock+dig+buy together.
Update 2026-07-15 (round 8) — the town comes alive: buy loop v0 + book/toy real stock
Round-8 §Lane C, theme "the town comes alive". Two deliverables + a verify pass.
C1 — buy loop v0 (new web/js/interiors/wallet.js). A shallow, runtime-only economy: seeded
starting cash, purchases, an in-memory bag. Reload resets; world generation is untouched (purchases
never enter the plan or the seeded room build — the wallet is a separate object the shell reads, never
writes back into gen).
createWallet(seed)→{ cash, start, canBuy, buy(offer), inventory, count, onChange }. Start is seeded ~$60–200 (mulberry32).buydeducts + records on success, returns false unchanged if broke.onChangelets F's street shell subscribe the same wallet to a cash chip.- Test page (
interior_test.html): a#cashChipHUD ($163 · 🛍 N, street + interior) + an inventory panel toggled with I. The dig's pull card gets a BUY IT button (price + band); buying pulls the sleeve into the bag, decrements cash, updates the chip, and depletes that bin (bought bins offer fewer sleeves on re-riffle). Broke → the button no-ops (can't buy).digSoaknow buys up to 2/visit. - Offer shape is the dig offer (
{a,t,price,s}) OR a pack item ({title,artist,price,price_band}) — onebuy()eats both.
C2 — book spines + toy boxes wired to ?stock=real. E shipped the generalized packs
(stock_book_* 311 items / 4 atlases, stock_toy_* 273 items / 5 atlases, alongside stock_record_*
350/6). stockpack.js now keys each pack to its slot via SLOT_FOR = {record:'sleeve', book:'spine', toy:'box'}; stock.js shelfLine consults the adapter and builds real cover planes for book spines
(bookshelves) and toy boxes (cube shelves) — static shelf stock, no dig for those, fail-soft per pack.
Every real mesh samples its atlas material with a baked per-item UV, so a whole shelf still batches to one
draw per atlas.
C3 — validated (all three packs live, ?stock=real):
- Draws ≤350 (the law): record 41, book 42, toy 51 worst over seeds {1990,7,42(,101)}.
- Determinism: same seed → identical draw calls + mesh count, all three types (0 fails).
- Leak-free: 0 geometry/texture delta over 20 build/dispose cycles; buy-soak 12 record-dig visits,
5 bought (wallet depletes to broke → exercises the can't-buy path),
leakGeo 0 / leakTex 0; resting geo back to baseline after close. Atlas textures shared/cached (not per-room); per-item geo ctx-tracked & disposed. - Reload resets the wallet (fresh boot → seeded start again); flag-off +
?noassetsuntouched. - Shot: buyloop_r8.jpg — toy boxes, book spines, record dig → BUY card.
→ F: two new warn-level smokes (hooks in LANE_C_NOTES.md): (1) buy loop —
createWallet deduct/broke/inventory is pure and headless-testable; (2) book/toy packs — enter book or
toy shop with ?stock=real, assert isStock meshes on a shared atlas material (drawSweep ≤350). Buy UI is
test-page-local; the reusable piece for the street shell is wallet.js + onChange.
Update 2026-07-14 (round 7) — ?stock=real: real GODVERSE sleeves in the crates
Round-7 §Lane C: the stockAdapter seam built in v1 finally eats. Lane E's GODVERSE stock pack (parody-
transformed Discogs metadata + cover atlas) feeds record-shop bins + the dig riffle behind ?stock=real.
- Contract handshake (task 1, unblocked E): confirmed E's index schema as-is in
LANE_C_NOTES.md —
{ items:[{id,title,artist,price,price_band,atlas,uv}] }, UV origin top-left (C flips V for WebGL), fail-soft, per-type packs. - New
stockpack.js:preloadStockPack/getStockPack(cached) +makeStockAdapter. Atlas texture + material are shared/cached (like depot GLBs); sleeves are cover PLANEs sampling the atlas at each item's baked UV → all real sleeves in a room batch to one draw (record: 59 draws). - Consumers:
stock.js binFanbuilds real cover planes (else parody canvas);dig.jsriffles real covers and the pull card shows title / artist / $price / band;interiors.jswiresopts.stock='real'through the seam (warn-once + parody fallback if the pack is absent); test page reads?stock=real. - Validated (sample 24-pack): determinism 0 fails/30, leak-free rooms AND real dig (0/0 over 25 cycles —
atlas shared, sleeve geo disposed), draws ≤139 all rooms (record 59), fail-soft on no-pack types (opshop
ok → parody),
?dig=1&stock=realleak-free. Flag-off +?noassetsuntouched (procedural path byte-identical). - Built against E's staged sample (real index format, synthetic covers); real Discogs covers drop in unchanged when E gets the DB (external — John/Fable). Shot: stock_real_r7.jpg.
→ F: new smoke for your strict harness — ?stock=real enter record shop + open dig → assert real
(non-parody) sleeves; the record bin's real sleeves are cover planes with userData.isStock on a shared
atlas material (drawSweep stays ≤350). Hook + adapter in LANE_C_NOTES.
Update 2026-07-14 (round 6) — C1 interior draw batching (≤350 draws/room is law)
Round-6 §Lane C task 1 (the review debt: book room was 1,245 draws GLB-on). Diagnosed the two
drivers — (a) multi-material stock boxes cost one draw PER face (6×), (b) one mesh per stock item —
then batched (new web/js/interiors/batch.js):
- stock.js: boxes are now single-material; every stock mesh tagged
userData.isStock. - batch.js (
batchRoom, called from interiors.js before the GLB upgrade): two-tier merge — room-level merge of allisStockacross every fitting (grouped by material, world transforms baked)- per-fitting merge of fixture frames (kept in-group so bins stay raycast targets and the GLB
upgrade can hide the fixture).
mergeGeometriesfrom vendored BufferGeometryUtils.
- per-fitting merge of fixture frames (kept in-group so bins stay raycast targets and the GLB
upgrade can hide the fixture).
- glb.js: frame-hide is now by
isStocktag (not frame-count), so batched fixtures hide and the room-level stock rides on the GLB.
Result (drawSweep, all 9 types × 6 archetypes, seed 1990):
- GLB-off worst 152 (opshop/hall), GLB-on worst 313 (opshop/hall) — book 1,245 → 189. ≤350 ✓.
- Determinism 0 fails / 810 builds · leak-free · dig still riffles batched bins (bins keep frame as hit target; dig regenerates its own sleeves) · worst warm build 38ms (<50) · visually identical.
- Sweep now ASSERTS ≤350 (
drawSweep()in the test page, folded into the soak;window.PROCITY_C. drawSweepfor F's harness — see LANE_C_NOTES for the hook).
C2 — map the 3 orphaned fittings (published R5, unconsumed). In glb.js: glassCase→glass_case,
magazineRack→magazine_rack, and a NEW crateStack primitive (stack of crates) → crate_stack.
Recipe placements: crateStack in record + opshop (back-room dressing), glassCase added to milkbar,
magazineRack added to pawn (+ moved milkbar's magazineRack window→wall to dodge the door-counter).
Also fixed placeNearCounter — it picked one random side and gave up, so counter-zone fittings
(the glass case) rarely landed; now it tries both sides + offsets, then falls back to a free aisle spot.
All 3 load + render planted/scaled/facing right (yaw 0). Appearance: glassCase 12/12 (toy/pawn/milkbar),
magazineRack 12/12 (milkbar/pawn), crateStack ~11/12 (record/opshop). Contact sheet:
glb_orphans_r6.jpg. drawSweep still ≤350 with them on (worst 177
GLB-on). C3: dig soak re-run after batching + new fittings — riffle works on batched bins, leak-free.
Round 6 complete for Lane C: C1 (batching, 35981ad) + C2/C3 (orphans + dig re-verify) committed.
Draw law satisfied (≤313 was the tightest; ≤177 after C2), determinism/leak/dig all green, qa strict green.
Update 2026-07-14 (round 5) — C1 v1.1 re-map (record_crate + counter_till) · F can tag v1.1
Round-5 §Lane C task 1 (the v1.1 gate). Lane E's re-exports mapped + validated in glb.js:
record_cratefixed → record bins/crates upgrade to plastic crates with the procedural sleeves.counter → 'counter_till'(the real ~1.6 m counter-with-till). Keeper-stand pose unaffected.- Double-till resolved: cash_register attach dropped when counter_till is live (it has its own till); falls back on the primitive; milkshake_mixer stays on the milk-bar bench (lifted to the GLB height).
- Sweep + soak (GLB on):
0 throws / 0 path / 0 det / leak-free / worst 10.4ms; 0 double tills. Primitive path unchanged (glb.js-only edit). Contact sheet: glb_v1_1_r5.jpg. - Report + decision detail: LANE_C_GLB_VALIDATION.md; F note in LANE_C_NOTES.md. → F: C1 committed, v1.1 can be tagged.
C2 — ?dig=1 crate-riffle (v2, behind flag). Ported 90sDJsim dig.js → web/js/interiors/dig.js:
walk to a record bin, press E, riffle its sleeves (scroll/drag), click the front to pull, BUY IT
(STUBBED — toast + remove; getCash constant; real economy is round 6+). Riffle items are seeded per bin
via the stockAdapter(shop,'sleeve') seam (procedural fallback now — parody 90s-AU sleeves). Own scene +
camera, procedural fwip/thunk audio, leak-free (per-open resources freed on close → dig soak 20×
leakGeo 0 / leakTex 0). Flag-off (?dig=0) is byte-identical — buildInterior is untouched; the dig
is wired only in the test page (F owns the shell seam — hook documented in LANE_C_NOTES §"input/mode hook").
Shot: dig_riffle_r5.jpg.
C3 — interior re-measure after Lane E's bake (c522464). E baked the 6 heavy hero props to ≤8k tris.
Re-measured every interior (cache-busted to bypass the dev browser's stale HTTP cache): record 43.6k
(the C3 target, ~273k pre-bake), milkbar 38.9k (was the flagged 468k → over-budget flag CLOSED), all
rooms ≤107k tris. book is the draw-call outlier (1,245 draws from ~9 bookshelf GLBs + primitive spines) —
tris fine, interiors render one-at-a-time so gate 3 unaffected; instancing spines is the future lever.
Full table in [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md §C3).
Round 5 complete for Lane C: C1 (v1.1 re-map) + C2 (?dig=1) + C3 (re-measure) all committed.
Update 2026-07-14 (round 4) — hero props into rooms
Round-4 task (ROUND4_INSTRUCTIONS §Lane C): map the 4 new hero props + cash_register (manifest now 14
fittings), sweep/soak with them on, screenshot each, delete the stale _local_glb/, measure a hero
room. All done. Full report for Lane E: LANE_C_GLB_VALIDATION.md;
contact sheet docs/shots/laneC/glb_heroprops_r4.jpg.
- New mappings (
glb.js):arcade_cabinet→newarcadeCabinetprimitive (video focal prop),listening_booth→listeningCorner(record),drinks_fridge→fridge(milkbar). All load,glb_lawcompliant, yaw 0 (no correction). A new counter-top attach (attachCounterTop) putscash_registeron every counter's till slot (hides the primitive till) andmilkshake_mixeron the milk-bar benchtop — the counter primitive + keeper stand are untouched. - Focal-prop reliability fix: the door-counter path loop was pulling priority-4 focal props (arcade appeared in only 5/24 video rooms). Raised the 3 focal props to priority 6 + placed-first → 24/24 appearance, still 0 carves / 0 path-fails.
- Validation: primitive sweep 810 builds
{throws 0, pathFail 0, detFail 0}; GLB-on soak{throws 0, path 0, det 0, leakGeo 0, leakTex 0, worst 7.3ms}; all 5 new props confirmed in-room. - Room tris measured (task 4): milk-bar GLB-on ≈ 468k tris (over budget) driven by
drinks_fridge(77k) +milkshake_mixer(73k) — reported to Lane E as the #1 bake-pass target (not fixed, per integrator decision #2; interiors render one-at-a-time so gate 3 / the street budget is unaffected). - Cleanup: deleted
web/assets/_local_glb/(R3 validation copy); test page now loads GLBs via the livedepot:path (?localdepot=1still works for offline).localBasehook kept inglb.js. - Standby (C task 5): when Lane E re-exports
record_crate(broken) + a ~2 m counter-with-till, I re-map both. Not landed at commit time.
Update 2026-07-14 (round 3) — GLB hero-prop visual validation
Round-3 task (ROUND3_INSTRUCTIONS §Lane C): flip useGLB on and validate Lane E's fitting GLBs in real
rooms. Done — full report for Lane E in docs/LANES/LANE_C_GLB_VALIDATION.md.
Verdict: 5 of 9 fitting GLBs are good and now live behind useGLB (wire_shelf, clothes_rack,
bookshelf, cube_shelf_wide, work_table); 2 fall back to primitive with a report filed
(record_crate broken — won't load; counter too long/no till → primitive keeps the till +
keeper stand). record bins stay primitive until Lane E re-exports the crate.
Soak with useGLB on (50 rooms, all types): throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 · leakTex 0 · worst build 10.5ms · 256 upgrades applied — GLB resources dispose leak-free on room exit.
glb.js changes (my file):
manifest.localBase— load GLBs from a served local dir for offline validation (production still usesdepot:<file>; forward-compatible with Lane E's local-depot mode).- Real-metre scale, clamped to the reserved footprint (was fit-to-width, which squashed tall props);
matches
glb_law(metres). Per-kind yaw correction (KIND_TO_YAW) for the Z-major assets (bookshelf/cube/work_table need +90°) per the manifestfacingconvention ("Lane B/C rotate directional props"). room.glbReady(interiors.js) — a promise that resolves when GLB swaps settle, so callers can await before screenshot/leak-check (Lane F can use it in the §3.4 wiring).- Test page (
interior_test.html) gains a GLB props toggle + aGLB props: N upgradedHUD line. Local validation needs the GLBs copied in:cp pipeline/_normalized/procity_fit_*.glb web/assets/_local_glb/(untracked; real GLBs live on the depot). Screenshot grid: docs/shots/laneC/glb_validation_grid.jpg.
Update 2026-07-14 (b) — keeper stand pose (resolves Lane F §8 · unblocks §3.2/§3.3)
Lane F's runbook asked (LANE_F_NOTES §8): "does places tag the counter slot with a stable
kind:'counter' so keepers find it?" Yes — the counter interactable has always carried
mesh.userData.kind === 'counter' (+ interactable:true). But Lane D's keeper needs more than a
tag: keepers.spawn(group,{x,z,ry}) needs where the shopkeeper stands and which way it faces
(keepers.js:28 — "ry = the counter's outward facing"). I computed the customer-facing side
internally but never surfaced it — so Lane D would have read the bench's raw transform and stood a
keeper embedded in the counter, possibly facing a wall. Closed that gap (my files only, purely additive):
- New:
room.counter = { mesh, pose:{x,z,ry}, stand:{x,z,ry} }(interiors.js).poseis the bench;standis the keeper pose — behind the bench on the service side, facing the customer, clamped inside the room, in room-local space (the same space Lane D adds the keeper into viainterior.group).ryuses the rig-front = local −Z convention keepers.js expects. - The same
{x,z,ry}also rides the counter interactable:place.userData.keeperStand— for theplaces-iteration path Lane D/F described. Deep-equal toroom.counter.stand.
Lane D/F wiring (drop-in for §3.2/§3.3):
const room = buildInterior(shop, THREE);
const s = room.counter.stand; // or: room.places.find(p=>p.userData.kind==='counter').userData.keeperStand
keepers.spawn(room.group, { ...s, shopId: shop.id, type: shop.type });
// on exit: keepers.remove(handle) / keepers.disposeAll(), then room.dispose()
Verified in-browser (9 types × 4 seeds = 36 builds, + 50-room soak):
stand in-bounds ✓ · faces-customer ✓ (dot>0 for all) · places-carries-keeperStand ✓ (deep-equal) · stand deterministic ✓ · placement determinism unchanged ✓ · soak avg 3.6ms / worst 7.7ms · leak geo0 tex0 · pathFail 0 · every room has a counter+stand. No change to layout/placement of existing rooms.
Minor known cosmetic (corner counters): a corner counter sits ~0.55 m off the east wall, so the service gap (~0.25 m) is narrower than a person — the keeper stands at the till with a small hidden overlap into the solid waist-high bench (torso reads above the counter, legs behind it). Facing and side are correct; it reads as "keeper at the till." If Lane D/Fable want real standing clearance I can nudge corner counters ~0.2 m off the wall — but that changes those rooms' committed placement + screenshots, so it's a decision, not a bug.
Two contract-drift corrections for the F runbook (§2's Lane-C line was written against an earlier
draft of my API): (1) there is no opts.registry — registry override is the one-time global
mergeRegistry(registry) (a per-build registry mutation broke same-seed determinism; removed in the
review pass). (2) recipe.counterPos is a placement-strategy string (door|forward|back|corner),
not a world position — use room.counter.stand for the keeper pose.
What shipped
All files under my lane's ownership (web/js/interiors/*, web/interior_test.html) — no other lane's
files touched. Lane A's core/registry.js hadn't landed, so the lane is fully standalone (its 9
type recipes live in theme.js) with a mergeRegistry() hook for Lane F to wire the registry later.
| file | role |
|---|---|
| interiors.js | public API — buildInterior(shop, THREE, opts) |
| theme.js | 9 shop-type recipes (archetype/wallpaper/floor bias, clutter, counter pos, fittings mix, stock kind, signage) + aliasing + registry-override hook |
| shell.js | room shell: floor/walls/ceiling from lot×archetype, glazed shopfront + street backdrop, blocked back doorway, interior lighting |
| fittings.js | parametric fittings kit (ported from 90sDJsim + extended): bins, crates, racks, 4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner racks, armchair, escalator, pegboard, barred window, returns slot, art frames |
| layout.js | the placer: per-archetype zones, shuffled wall-slot system, occupancy grid, guaranteed door→counter flood-fill path |
| stock.js | v1 visual stock: pooled canvas-texture sleeves/spines/boxes/garments/snacks with price stickers (dig.js trick) + stockAdapter hook for BaseGod data |
| context.js | build/dispose/seed backbone: independent seeded sub-streams, shared-geometry cache, leak-free disposeAll() |
| glb.js | optional GLB hero-prop upgrade — reads Lane E's manifest.json, swaps primitives for depot GLBs, placeholder-persists. OFF by default; primitive fallback always |
| interior_test.html | standalone test page (below) |
Public API (the contract Lane B/F build against)
import { buildInterior } from './js/interiors/interiors.js';
import * as THREE from 'three';
const room = buildInterior(shop, THREE, opts?);
scene.add(room.group);
// … player walks; on hitting room.exits[i], fire procity:exitShop …
room.dispose(); // frees ALL per-room GPU resources; removes group from parent
// room = {
// group, // THREE.Group, self-contained (lights included)
// spawn: { x, z, ry }, // enter just inside the door, facing −Z (into the shop)
// exits: [ { x, z, w, toStreet } ], // door back to street
// places: [ …meshes w/ userData ], // interactables: counter, bin, case, fridge, returns, exit
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = keeper (Lane D) spawn pose
// dims: { W, D, H, archetype, type },
// placement, // deterministic summary (deep-equal per seed)
// pathOK, buildMs, counts(), dispose()
// }
shop is tolerant — recognized fields { id, type, name, seed, storeys, lot:{w,d} }:
- seed (uint32) is the ONLY randomness source; falls back to a hash of id/name/type if absent.
- type — any CITY_SPEC type (
record opshop toy book video pawn milkbar dept stall) or a thriftgod/Overpass alias (music charity pawnbroker market video_games …); defaults toopshop. - lot
{w,d}metres — the room adapts to it (never exceeds it); absent → archetype ranges decide. - storeys drives ceiling height 3.2–4.5m.
opts:{ archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, useGLB?, manifest?, manifestUrl? }. Registry override is a one-time global —import { mergeRegistry } from interiors.js; mergeRegistry(registry)at init (NOT a per-build opt — a per-call mutation of the shared recipe table would break same-seed determinism).
Coordinate convention (shared with a future Lane B wiring): +Z = door/street side, −Z = back,
origin = room centre, floor y = 0. Everything is in the returned group's local space.
For Lane B (door wiring, later)
buildInterior is a pure function — call it on procity:enterShop, add room.group to the scene,
teleport the player to room.spawn. On procity:exitShop (player reaches room.exits[0]) call
room.dispose() and restore the street. The street window fake is Lane B's; I own the inside view
(a bright canvas street backdrop plane is already behind the glazing).
For content phase (BaseGod / GODVERSE)
Pass opts.stockAdapter = (shop, slotKind) => ({ texture }) | ({ mesh }) | null. Returning a texture
skins that stock item's face; a mesh replaces the item; null → procedural placeholder. No layout/
fittings changes needed. slotKind ∈ { sleeve, spine, box, snack, magazine, garment, treasure }.
Test page — web/interior_test.html
Serve: cd web && python3 -m http.server 8130 → open /interior_test.html.
- seed input · type dropdown (9) · archetype override (auto + 5) · lot
w×d/auto · re-roll · seed ± - first-person walk (PointerLock + WASD) with occupancy-grid collision + room clamp
- debug toggles: wireframe (F), occupancy grid (G), path (spawn/counter markers)
- ▶ 50-room soak test: builds+disposes 50 seeded rooms, asserts ms/room, leaked geometries vs baseline, and determinism (same seed → identical placement)
Acceptance — all met
| requirement | result |
|---|---|
| Same seed twice → identical room (deep-equal placement) | ✅ 0 mismatches / 810 builds |
| All 9 types × 5 archetypes render sensibly | ✅ contact sheet docs/shots/laneC/_grid_9types_5archetypes.jpg |
| Build < 50ms/room | ✅ steady-state ~4ms, clean-soak worst ~8ms (first-ever build ~60ms one-time cold-JIT; occasional ~60ms GC pauses only under back-to-back torture loops — not a real per-room cost) |
| Dispose leak-free (info.memory → baseline) | ✅ soak leak: geo 0, tex 0 |
| Flood-fill door→counter always exists | ✅ 0 path-fails, 0 carves / 810 builds |
| Runs with zero assets & zero network | ✅ seeded flat-colour fallback under every texture; map only assigned on load |
Full sweep (9 types × {auto+5 archetypes} × 3 seeds × {null,4×4,5×14,20×20,3×3} lots = 810 builds):
throws 0 · pathFail 0 · determinismFail 0 · carved 0. Warm perf: all < 50ms (worst 33.2ms).
Soak (50 rooms): avg 4.0ms · worst 9.6ms · leakGeo 0 · leakTex 0 · detFail 0 · pathFail 0.
Key decisions & notes for review
- 90sDJsim
fittings.jsported (it was written to be lifted):clothesRack,shelfUnit,recordBin,table,counter,pegboard, garment-silhouette canvas trick. Extended with the fittings CITY_SPEC's registry needs (cube/metal/book/VHS shelving, glass case, fridge, spinner, magazine rack, armchair, escalator prop, returns slot, barred window, listening corner). - thriftgod
buildShopported: the 5 archetypes, the shuffled wall-slot system, bins with fanned sleeves wearing covers, corner counter+till, crooked wall art, blocked back doorway. - Determinism via independent seeded sub-streams keyed by a salt string (
ctx.stream('wallpaper')etc.) so adding a fitting in one subsystem never shifts another subsystem's picks. - Leak-free via a tracked build context: per-room geometry/material/CanvasTexture/tiled-file-texture
are freed in
dispose(); globally-cached file textures (core/loaders.loadTex) are shared and not disposed (bounded cache). Dispose-before-texture-load race is guarded (_disposed). - Perf: shared-geometry cache (
ctx.boxGeo/planeGeo/cylGeo) collapses the hundreds of identical stock items in a room down to ~80–110 geometries; spine density tuned so a book barn is ~600 meshes. - GLB upgrades:
web/assets/manifest.json(Lane E) is read if present, primitive fallback always. Not present yet → 100% primitives, which is why the test page runs asset-free.
Integration with sibling lanes (landed in parallel)
- Lane A
core/registry.js— itsSHOP_TYPE_IDSmatch my 9 recipe keys exactly. I kept the interior recipes local (the registry doesn't carry archetype-bias/clutter/counter-pos/stock detail), withmergeRegistry()as the one-time override seam for Lane F. - Lane E
web/assets/manifest.json— itsfittingssection (9 depot GLBs with footprints) is now wired throughglb.js(opt-inuseGLB, primitive fallback). Depot GLBs may not be uploaded yet; verified the fallback (unreachable depot → primitives persist, no crash, no leak). - Lane D
citizens/keepers.js— itskeepers.spawn(group,{x,z,ry,shopId,type})now has a clean source:room.counter.stand(also onplace.userData.keeperStand). See the Update (b) section at the top. Verified the pose is on the service side, faces the customer, and is deterministic.
Adversarial review — 5 findings, all fixed
Ran a 5-dimension adversarial multi-agent review (determinism / disposal-leaks / path-guarantee / three-api / edge-robustness) with a per-finding verify pass. 5 real defects surfaced; all fixed and re-verified (830-build sweep + 50-room soak green after each fix):
| # | sev | file | defect | fix |
|---|---|---|---|---|
| 1 | med | interiors.js | per-build opts.registry permanently mutated the shared recipe table → latent same-seed determinism break across differing registry states |
removed per-build option; registry override is now a one-time global mergeRegistry() (Lane F setup) |
| 2 | low | interiors.js | explicit null opts threw (opts = {} default only covers undefined) |
normalize opts = opts || {} |
| 3 | low | shell.js | empty archetypeBias array → bias[0][0] on [] throws |
fall back to uniform bias when missing or empty |
| 4 | med | layout.js | fittings pulled by the path loop left phantom interactables in places (dangling after dispose) |
collect places from survivors at the end, not incrementally at placement |
| 5 | low | layout.js | rebuildOcc re-stamped wall-mounted fittings that placeAtWall never stamped → phantom floor obstacles after a blocker-pull |
mark wall-mounted noStamp; skip in rebuildOcc and exclude from the removable pool |
Verify evidence (post-fix): nullOpts ok · phantomPlaces 0/332 · determinismAcrossBuilds identical · emptyBias ok · sweep 810 {throws 0, pathFail 0, detFail 0} · soak {avg 4.2ms, worst 8ms, leakGeo 0, leakTex 0}. (Reviewers also confirmed no disposal/leak or three.js-contract defects.)
Not done (out of scope for v1 / depends on other lanes)
- Real item data / economy (content phase —
stockAdapterhook is ready). - GLB hero props are wired (
glb.js, opt-in) but not visually validated end-to-end — needs the depot GLBs actually uploaded (Lane E) +useGLBflipped on by Lane B/F. Primitive path is the tested baseline. - Interior-mapping shader for street windows (Lane B stretch goal).
- Back-room beyond the blocked doorway (v2).