# LANE F — NOTES (integration runbook + QA gates) *Lane F is integration + QA. It runs **after** A–E land. This file is the durable technical spec: the exact wiring edits, the contracts F needs from each lane, and the QA gate procedures. It is written **before** A–E finished so the wiring is a fast, mechanical pass the moment they do — and so each lane can see the small hook F will ask of it and build it in up front.* Status of this file: **living**. Update the ✅/⏳ markers as wiring lands. Snapshot of lane readiness lives in [`F-progress.md`](../../F-progress.md); run `node tools/qa/scaffold_check.mjs` for the live matrix. --- ## 0. Ground truth / environment - Repo root on this machine: `/Users/m3ultra/Documents/procity` (the lane prompts say `/Users/jing/Documents/PROCITY` — that path is from the authoring machine; **use the real path above**). Reference repos are cloned alongside: `../90sDJsim`, `../thriftgod`. Ultra box (existing 3D library / Blender / clip bank): `ssh johnking@100.91.239.7`. - Serve: `cd web && python3 -m http.server 8130`. Node 26, Python 3.14 present. - `web/package.json` has `"type":"module"` (Lane A) so `node web/js/**/*.js` runs as ESM. - **F never edits another lane's internals.** F adds *wiring* — new files it owns, and small, clearly-marked hook edits at the seams. Where a hook must live inside a lane's file, F first looks for the hook that lane already exposed (see contracts below); the lane owns its file, F owns the glue. ## 1. Files Lane F owns / creates | path | purpose | |---|---| | `tools/qa.sh` | the QA gate runner (scaffold + citygen selfcheck + manifest validator) ✅ | | `tools/qa/scaffold_check.mjs` | scaffold + PRNG determinism law + **live lane-readiness matrix** ✅ | | `tools/shots.py` | Playwright tour-capture → `docs/shots/v1_tour/` + contact sheet (ported from 90sDJsim `tools/shots.py`) | | `tools/soak.py` | Playwright 10-minute scripted-walk soak (chunk/heap/leak/error gate) | | `web/js/citygen/index.js` | barrel so B's `import('./js/citygen/index.js')` resolves to A's `plan.js` ✅ (§3.1) | | `web/js/world/interior_mode.js` | **interior bridge** — B's shell ↔ C's `buildInterior`; owns the `interior` mode branch ✅ (§3.2) | | `docs/V2_IDEAS.md` | parked-scope catalogue ✅ | | `docs/shots/` | reference shot tree (`laneA…E/`, `v1_tour/`, `before/`, `references/`) | | `F-progress.md` | status report for fable | The wiring is kept as **focused F-owned modules** (e.g. `interior_mode.js`) that the shell imports, rather than logic sprinkled through B's files. Each seam edit inside `index.html` is one line, commented `[Lane F integration]`, and reversible. That keeps ownership clean: B owns the shell + mode machine, C/D own their libraries, F owns the glue modules and the marked seams that call them. --- ## 2. Contracts F depends on (verify these as lanes land) Pulled from CITY_SPEC + each lane prompt. The scaffold check's readiness matrix tracks presence; these are the **shapes** F wires against. If a lane ships a different shape, fix it here and in the wiring, not by guessing. > **⚠ Corrected against the REAL landed code (2026-07-14).** The bullets below are what the lanes > actually shipped — several differ from the spec-derived guesses in an earlier draft (verified by > reading each file + the Lane F self-audit workflow). **Lane A — `web/js/citygen/plan.js`** *(landed, verified)* - `generatePlan(citySeed) → CityPlan` (schema in CITY_SPEC §Layer 1). <3ms, deterministic, JSON-clean. - `chunkIndex(plan) → { chunkSize, chunks }` where `chunks["cx,cz"] = { lots:[id…], shops:[…], edges:[…] }` (a **plain object, not a callable**; a lot may bucket into several chunks). Also exports `chunkKey(cx,cz)`. - `web/js/core/registry.js` exports `SHOP_TYPES` — per type `{ label, facades:[.jpg…], sign, interior, fittings:[…], storeys:[min,max], hours:{open,close} }`. **`facades` are full filenames** (`facade-timber-teal.jpg`), not bare keys — see the skins seam in §7. - `web/map.html` — 2D plan viewer (verified rendering real towns). `web/js/citygen/index.js` **is the barrel Lane F added** so the shell's `import('./js/citygen/index.js')` resolves (§3.1). **Lane B — `web/index.html` + `web/js/world/*`** *(landed, verified live)* - Mode state machine `MODE ∈ {map, street, interior}` (interior branch not yet used), one renderer. - **`createChunkManager(plan, scene, ctx)`** — a **factory**, not a class; returns `{ update(pos), warmup(pos), getColliders(x,z), setNight(bool), getDoorMeshes(), count, dispose() }`. **No `onChunkBuilt/onChunkDisposed` lifecycle hooks** — so §3.3 must drive citizens off the street graph, not chunk lifecycle (or F adds a hook; prefer graph-driven). - Door pick is wired via HUD: `createHUD({ onEnterShop: (shopId,name)=>enterShop(...) })`; `enterShop()` dispatches `procity:enterShop` and is otherwise **a stub** (`hud.showToast('interiors coming')`) — this is the seam F completes in §3.2. Interior returns via `procity:exitShop`. - HUD shows draws / tris / fps / seed / time-of-day (F reads these for the budget gate). - Exposes `window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins }` (F builds the `window.DBG` harness hook on top of this — §4). **Lane C — `web/js/interiors/interiors.js`** *(landed, verified)* - `buildInterior(shop, THREE, opts={registry,archetype,stockAdapter}) → { group, spawn:{x,z,ry}, exits:[…], places:[…], dims, recipe:{counterPos,…}, dispose() }`. Matches the wiring plan. - Caller must attach lot dims: pass `{ ...shop, lot: plan.lots.find(l=>l.id===shop.lot) }`. `dispose()` frees tracked GPU resources (soak gate asserts baseline). **Lane D — `web/js/citizens/*`** *(landed, verified)* - `sim.js` exports **`class CitizenSim`** — `new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet, group })`. It is **street-graph driven, not per-chunk**: there is no `spawnRoster/despawn/setDaySegment` — build it once from the street graph and tick it in the loop. - `keepers.js` exports **`class KeeperManager`** — `new KeeperManager({ camera, citySeed, fleet })`, then `spawn(target, { x, z, ry, shopId, type }) → handle`, `remove(handle)`, `disposeAll()`, `update(dt, playerPos)`. (Not `spawnFor/disposeFor`.) **Lane E — `web/assets/manifest.json` + `pipeline/validate_manifest.py`** - Manifest: `{depot, fittings:{id:{file,footprint,thumb}}, furniture:{…}, skins:{facade,sky,ground,wall,interior}}`. - `python3 pipeline/validate_manifest.py` green (F's gate 5 runs it). - B/C read the manifest for GLB upgrades; **primitive fallbacks must still fire under `?noassets=1`**. --- ## 3. Wiring checklist (LANE_F §Wiring — expanded to exact steps) All of this lives in `web/js/world/wiring.js` (F-owned) + the minimal hooks each lane exposes. ### 3.1 Plan source swap (A → B) ✅ DONE (verified live) - Lane B already reads `?seed=N` (default `20261990`), guards the citygen import, and surfaces the town name + seed in the start panel and HUD. **But it imported `./js/citygen/index.js`, which did not exist** (Lane A shipped `plan.js`), so it silently fell back to the 14-shop fixture. - **Fix:** Lane F added `web/js/citygen/index.js` (barrel re-exporting `generatePlan/chunkIndex/chunkKey` from `plan.js`). Verified live: shell logs "using Lane A generatePlan", HUD shows the real town ("Boolarra Heads", 523 shops), 31 chunks stream. - Map key **M** is wired by Lane B to an in-shell minimap (not map.html). Fine for v1. - **Gate:** plan determinism asserted green by `scaffold_check.mjs`. Map-PNG determinism pending the shot harness + DBG hook (§4). ### 3.2 enterShop → interiors (B ↔ C) ✅ DONE (verified live, 2026-07-14) Implemented as an **F-owned bridge module `web/js/world/interior_mode.js`** (`createInteriorMode`), plus a thin marked seam in the shell. The bridge renders the interior into its **own dark THREE.Scene** — the street scene is simply left frozen (not disposed → instant return, CITY_SPEC L3 "pause, not dispose"), which is cleaner than hiding B's ground+chunk meshes in a shared scene. ``` enterShop(shopId, name): // shell seam (was the toast stub) shop = plan.shops.find(s => s.id === shopId) // id is numeric; matches door.shopId lot = plan.lots.find(l => l.id === shop.lot) // attach {w,d} — Lane C sizes the room to it interiorMode.enter({ ...shop, lot:{w,d} }, name); setMode('interior') interiorMode.enter(shop): // F module current = buildInterior(shop, THREE); scene.add(current.group) // Lane C doorReturn = {x,y,z,ry of the player on the street} camera → current.spawn (x, 1.6, z, ry); exitArmed = false loop [MODE==='interior']: if interiorMode.update(dt,keys) leaveShop() update: walk via current._debug.grid occ/bounds (mirrors interior_test.html) → renderer.render(ownScene) exit arms once player steps >2m from the door, then fires within 1m of exits[].toStreet leaveShop() / 'procity:exitShop' / Esc(unlock): // three exit routes, all → leaveShop interiorMode.exit(): current.dispose() (frees GPU + unparents); camera → doorReturn setMode('street') // pointer stayed locked → walking resumes at once ``` - **Verified live** (seed 20261990 "Boolarra Heads"): door → **Toy Shop "Numbat Playthings"** renders (ivy wallpaper, stocked shelves, counter, exit doorway); exit state-machine correct (no instant-exit at spawn; arms then fires at the door); **leak-free** — GPU memory 134 geo/37 tex → 217 loaded → **back to 134/37 after `dispose()`**. Swept **all 9 shop types** (toy/video/pawn/ opshop/book/record/stall/dept/milkbar), every one `ok+pathOK+leftClean`, worst build **8.4ms** (budget 50), zero console errors. - **Gate:** determinism (byte-identical placement per seed) is asserted by Lane C's own soak page; memory-to-baseline after `dispose()` confirmed above. Real pointer-lock walk-in is the one bit the automated browser can't drive — needs a human (or the DBG hook in §4). ### 3.3 Citizens + keepers (D) **Keepers ✅ DONE (verified live).** Wired into the interior bridge (`interior_mode.js`), not the shell: `new KeeperManager({ camera, citySeed: plan.citySeed })` — **no `fleet` passed → asset-free placeholder actor** (rig-fleet upgrade is later, with §3.4). On `enter`, find the counter interactable (`places.find(p => p.userData.keeperStand)` — Lane C tags `{x,z,ry}` there) and `keepers.spawn(interior.group, { x, z, ry, shopId, type })`; tick `keepers.update(dt)` each interior frame (playerPos defaults to the camera); `keepers.disposeAll()` on `exit`, before the room disposes. - **Verified:** placeholder keeper (11-mesh humanoid) stands at the counter pose (record shop: `{2.55,-2.46, ry 1.571}`), **turns to greet** a nearby player (0.21 rad) and eases back when they leave (~0), disposes to 0. **Leak-free**: 5 enter/leave cycles over 5 shop types → after-leave memory constant at 96 geo/16 tex, **net growth 0**; 0 console errors. **CitizenSim (street peds) ✅ DONE (verified live).** Happy surprise: **`plan.streets` already IS the graph** — `{ nodes:[{id,x,z}], edges:[{id,a,b,width,kind}] }` — so `graph: plan.streets` passes straight in, **no adapter needed** (the runbook's earlier `streetGraphFrom` is unnecessary). In the shell: `new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets })`, `setPopulation(140)` (`?pop=` tunable), then in the **street** branch each frame: `setTimeOfDay((clk.seg + clk.frac)/6)` + **`update(dt)`** — note `update` takes **only dt** and reads `this.camera.position` internally (the runbook's `update(dt,pos)` was wrong). `visibilitychange → setPaused`. No fleet → asset-free placeholder(near)/impostor(mid) tiers; sim self-manages NEAR/MID/FAR LOD off camera distance (not chunk-driven — chunks.js has no lifecycle hook). - **⚠ gotcha (fixed):** `getClock().hour` is a **display string** (`'12:30'`), so `hour/24` → NaN → 0 peds. Use the numeric day fraction `(seg + frac)/6` instead. - **Verified:** 140 pop → **96 active midday** (density curve; `~7` at night), peds walk footpaths at ~1.5 m/s, deterministic per seed (Lane D property; F passes the same graph+seed). **Budget:** 1 near placeholder ≈ **11 draws** (11 meshes), bounded by `NEAR_MAX=24`; but a hamlet never fills it — worst across all 6 main/high edges was **near 3, ~191 composer draws** (budget 300). Coexists with interiors+keepers (round-trip regression clean); 0 console errors. - **Note for §3.4:** placeholder near-tier is the draw cost; the **rig-fleet upgrade** (shared meshes + impostor promotion) is what lets `?pop=` rise well past 140. Until then 140 is the safe lively default. ### 3.4 Point B/C at E's manifest ⏳ - Shell loads `web/assets/manifest.json` if present → `window.PROCITY.manifest`. B/C prefer manifest GLB upgrades over primitives; **registry.js (Lane A) stays the type→skin source of truth**, the manifest is the *GLB-upgrade + validated-skin* layer. - `?noassets=1`: shell sets `window.PROCITY.noassets = true`; manifest load is skipped and every loader call is short-circuited to the primitive/flat-colour fallback. (F defines the flag; B/C/E check `window.PROCITY.noassets` before touching `loaders.js`.) - **Gate:** asset-free — `?noassets=1` full playthrough, zero crashes, town still legible. ### 3.5 Hours / closed shops (A data → B facade + C gate) ⏳ - `isOpen(shop, seg)` from `shop.hours:[open,close]` vs the current day segment (map 6 segments → hour). Closed ⇒ B renders dark windows + CLOSED plate (facade state); enterShop → locked toast. - Exactly one late-night shop per town (Lane A seeds a shop whose `hours` run late). **F asserts it exists**: `plan.shops.some(s => s.hours[1] >= 22)` (tune threshold to A's convention). - **Gate:** at night, ≥1 open shop; closed shops visibly closed + un-enterable. --- ## 4. QA gates (all green ⇒ v1). `tools/qa.sh --strict` is the umbrella. | # | gate | how | tool | state | |---|---|---|---|---| | 1 | **Determinism** | seed 1234 → identical map PNG + identical first-interior placement list, 2 runs/machines | `scaffold_check.mjs` (plan) + `shots.py --seed 1234` (map PNG) + interior placement hash | ⏳ needs A,B,C | | 2 | **Soak** | 10-min scripted walk, ≥30 chunks, ≥15 shops; `renderer.info.memory` geo/tex back to baseline after each interior; JS heap stable; **0** console errors | `tools/soak.py` | ⏳ needs B,C | | 3 | **Budget** | busiest intersection, midday: HUD ≤300 draws, ≤200k tris | read HUD via `soak.py`/browser | ⏳ needs B | | 4 | **Asset-free** | `?noassets=1` full playthrough, zero crashes | `soak.py --noassets` | ⏳ needs B | | 5 | **Selfchecks** | `node web/js/citygen/selfcheck.js` + `python3 pipeline/validate_manifest.py` green | `tools/qa.sh` | ✅ runner ready; ⏳ inputs | | 6 | **Shots** | refresh `docs/shots/*`; add `docs/shots/v1_tour/` (10 beauty shots) | `tools/shots.py` | ⏳ needs B,C | ### DBG hook F requests from Lane B (for gates 1–4,6) The house shot/soak harnesses drive the game through a debug object. **Ask Lane B to expose**, on `window.DBG` (guarded, only when `?dbg=1`): - `DBG.ready` → true once first chunks built + assets settled (soak/shots wait on it). - `DBG.shot(name)` → snap camera to a named bookmark (`street_noon`, `arcade`, `market_square`, `milkbar_dusk`, `night_neon`, …); returns after the frame settles. - `DBG.teleport(x,z,ry)` and `DBG.setSegment(seg)` → drive the soak walk + time of day. - `DBG.enterShop(shopId)` / `DBG.exitShop()` → scripted interior visits (shot `record_interior`). - `DBG.info()` → `{drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode}` (budget/soak read this). This mirrors 90sDJsim's `window.DBG.ST` / `window.DBG.shot()` (`90sDJsim/tools/shots.py:31-33`). Until it exists, `shots.py`/`soak.py` degrade to booting the default view and warn. --- ## 5. Order of operations once lanes land 1. ✅ `node tools/qa/scaffold_check.mjs` — A's plan determinism + registry confirmed present. 2. ✅ Wire §3.1 (plan swap) → generated seed boots on the shell (barrel + skins fix, verified live). 3. ✅ Wire §3.2 (interiors) → door opens, enter/walk/exit + dispose verified across all 9 types. 4. ✅ Wire §3.3 (citizens + keepers) → keeper greets at the counter; peds walk the graph, budget-safe. 5. ⏳ Wire §3.4 (manifest + `?noassets`) — also the rig-fleet upgrade. Then §3.5 (hours). Commit. 6. ⏳ Run gates 1–6; capture `docs/shots/v1_tour/`; write final numbers into `F-progress.md`. 7. ⏳ `tools/qa.sh --strict` green ⇒ tag v1. ## 7. Live smoke-test findings (2026-07-14, real generated town, seed 20261990) Booted the wired game in a browser and drove the camera via `window.PROCITY`. State: `Boolarra Heads · 523 shops · 711 lots · 31 chunks live · 31 door meshes · 24.7k tris`. - ✅ **Seed → real town** works after the barrel fix (§3.1). Contiguous strip, awnings, verandah posts, doors, windows, gum trees, sky dome, 6-segment day cycle — all render. - ✅ **Facade skins seam (FIXED).** `skins.js facadeMat` built `assets/gen/facade-.jpg` but Lane A's `facadeSkin` is the **full filename** (`facade-fibro-blue.jpg`) → every facade requested `facade-facade-…jpg.jpg` (404 → flat colour). Lane F normalized the name at the `facadeMat` chokepoint (strip a leading `facade-` + image extension; idempotent for bare keys). Verified: facade JPEGs now return **200 OK**, town is textured. *Root cause is a cross-lane naming contract ambiguity — registry stores filenames, skins.js expected bare keys. Worth a CITY_SPEC line so ground/awning/interior skins don't hit the same trap.* - ⚠ **Budget:** ~**334 draw calls** at spawn (radius 3), over the CITY_SPEC ≤300. Tris fine (~25k). Options for Lane B: drop default radius to 2, or merge more per chunk. Gate 3 will fail until shaved. - ⏳ **Interiors not wired** (§3.2), **citizens not wired** (§3.3), **manifest/`?noassets` not wired** (§3.4) — the remaining integration passes. ## 8. Open questions for the lanes (surface, don't guess) - **B:** will you expose the `window.DBG` hook (§4) and the `ChunkManager` lifecycle callbacks (§3.3)? If not, F wires citizens via a MutationObserver-style scan — slower, please prefer callbacks. - **A:** which field marks the "open late" shop, and what's the `hours` encoding (24h ints? segment idx)? - **C:** does `places` tag the counter slot with a stable `kind:'counter'` so keepers find it? - **E:** is `?noassets` honored inside your manifest-consuming code, or does F gate it at the shell? - **D:** roster spawn/despawn API name + does it accept a chunk key directly?