§3.5 hours: isOpen() gates enterShop — closed shops locked; at night only the openLate video rental opens. §3.4: ?noassets (zero-network primitive path, verified via Performance API), interior fitting GLB upgrades from the 3GOD depot (useGLB), and the rig-fleet upgrade (models/peds/*.glb) so peds + keepers become shared GLB rigs, leak-free (shared loadGLB cache proven, not a leak). qa.sh manifest gate now --depot (16 GLBs live) — GREEN 4/4. Browser QA harness fixes so shots.py/soak.py actually run: DBG async-poll (was racing the async install), plan via window.PROCITY (DBG.plan unexposed), enterShop bare-id, shop.lot is a lot id, midday pin (§3.5 else closes shops mid-walk), GLB-cache warmup, budget peaks -> warnings (gate 3 clean pass is authoritative). Gate 2 soak PASS both asset modes; gate 4 asset-free PASS; docs/shots/v1_tour/ (10 shots) + contact sheet captured. Findings (LANE_F_NOTES §9): D/E decimate tri-heavy ped GLBs (gate 3 tris ~279k > 200k budget; draws fine ~200/300). B: add 3 shots bookmark poses, fix headless composer letterbox in DBG.shot(), render the §3.5 closed-facade visual (predicate window.PROCITY.isOpen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
23 KiB
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; 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.jsonhas"type":"module"(Lane A) sonode web/js/**/*.jsruns 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 }wherechunks["cx,cz"] = { lots:[id…], shops:[…], edges:[…] }(a plain object, not a callable; a lot may bucket into several chunks). Also exportschunkKey(cx,cz).web/js/core/registry.jsexportsSHOP_TYPES— per type{ label, facades:[<full filename>.jpg…], sign, interior, fittings:[…], storeys:[min,max], hours:{open,close} }.facadesare 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.jsis the barrel Lane F added so the shell'simport('./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() }. NoonChunkBuilt/onChunkDisposedlifecycle 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()dispatchesprocity:enterShopand is otherwise a stub (hud.showToast('interiors coming')) — this is the seam F completes in §3.2. Interior returns viaprocity: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 thewindow.DBGharness 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.jsexportsclass CitizenSim—new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet, group }). It is street-graph driven, not per-chunk: there is nospawnRoster/despawn/setDaySegment— build it once from the street graph and tick it in the loop.keepers.jsexportsclass KeeperManager—new KeeperManager({ camera, citySeed, fleet }), thenspawn(target, { x, z, ry, shopId, type }) → handle,remove(handle),disposeAll(),update(dt, playerPos). (NotspawnFor/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.pygreen (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(default20261990), 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 shippedplan.js), so it silently fell back to the 14-shop fixture. - Fix: Lane F added
web/js/citygen/index.js(barrel re-exportinggeneratePlan/chunkIndex/chunkKeyfromplan.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 oneok+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().houris a display string ('12:30'), sohour/24→ NaN → 0 peds. Use the numeric day fraction(seg + frac)/6instead. - Verified: 140 pop → 96 active midday (density curve;
~7at 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 byNEAR_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 Manifest + ?noassets + rig-fleet upgrade ✅ DONE (R3, verified live)
Wired in the shell (all gated by NOASSETS = ?noassets=1):
- Rig fleet:
loadPedFleet('models/peds/')(19 char GLBs + walk/idle clips exist locally) → passed tonew CitizenSim({…,fleet})and (viacreateInteriorMode({…,fleet})) toKeeperManager. Peds + keeper upgrade placeholder→shared GLB rigs oncefleet.whenReadyresolves (verified:citizens.mode==='rig',keeperKind==='rig', detailed characters on the street + behind counters). - Interior GLB:
preloadManifest()at boot +buildInterior(shop,THREE,{useGLB:!NOASSETS})→ Lane Cglb.jsswaps fitting primitives for depot GLBs (depot:<file>→ 3GOD, reachable). Verified: bookshop fittings load real GLBs (+8 meshes, dense shelves). Not all kinds map —counteris intentionally primitive;record_crateis a known-failing GLB → fail-soft to primitive. ?noassets=1:fleet=null, manifest not preloaded,useGLB=false. Verified zero network (Performance API: 0 GLB, 0 depot, 0 manifest, 0 ped-model requests); 100% primitive, still legible.- Leak-free: rig keeper + interior GLBs dispose clean — the persistent geometry is the intentional
shared
loadGLBcache (cold entry +N geo, re-entries flat: proven cycle2/3 = 189→189). soak.py pre-warms this cache so the gate is honest. - ⚠ finding (D/E): GLB character models are triangle-heavy (~45k each near rig) → busiest view
tris ~279k, soak peak ~1.0M, over the 200k soft budget. Draws are fine (~200/300). Decimate the
ped GLBs (and heavy fittings) to hit the tri budget; F holds
?pop=140until then (raising pop adds near rigs = more tris). See Cross-lane requests §9.
3.5 Hours / closed shops ✅ DONE (R3, verified live)
currentHour()parseslighting.getClock().hour('HH:MM'→float);isOpen(shop)=open ≤ H < close. Both exposed onwindow.PROCITY.enterShopgates on it: closed →🔒 CLOSED · opens HH:00, no build.- Lane A ships exactly one
openLate:truelandmark/town — always the video rental (Video Regal[11,23]etc.). Verified: midday toy shop[10,16]enters; night (22:00) toy shop blocked, only the video shop opens (the night destination). - Cross-lane (B): the facade visual closed-state (dark windows + CLOSED plate) + a "CLOSED" door
tooltip are Lane B's rendering —
window.PROCITY.isOpen(shop)is the predicate. See §9.
3.5 Hours / closed shops (A data → B facade + C gate) ⏳
isOpen(shop, seg)fromshop.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
hoursrun 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 (R3) |
|---|---|---|---|---|
| 1 | Determinism | seed → identical plan (deep-equal, golden hash) + identical interior placement per seed | scaffold_check.mjs + Lane C soak page |
✅ plan/interior determinism green (map-PNG delta pending shot poses) |
| 2 | Soak | scripted walk, ≥30 chunks, ≥15 shops; geo/tex back to baseline after each interior; JS heap stable; 0 console errors | tools/soak.py |
✅ PASS both modes (assets: 20 shops leak-free w/ GLB-cache warmup, heap 98MB stable; noassets: 37 chunks) |
| 3 | Budget | busiest intersection: ≤300 draws, ≤200k tris (clean single-pass) | browser clean measure | ⚠ draws PASS (~200); tris FAIL (~279k) with rig fleet — decimate note for D/E (§9) |
| 4 | Asset-free | ?noassets=1 full playthrough, zero crashes, zero network |
soak.py --noassets |
✅ PASS — 0 GLB/depot/manifest/ped requests, legible, no crash |
| 5 | Selfchecks | selfcheck.js + validate_manifest.py --depot green |
tools/qa.sh |
✅ GREEN 4/4 (manifest now --depot: 16 GLBs live, 0 err) |
| 6 | Shots | docs/shots/v1_tour/ (10 beauty shots) + contact sheet |
tools/shots.py |
◑ harness runs + 10 shots captured; letterbox + 3 fallback bookmarks = Lane B dbg.js (§9) |
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)andDBG.setSegment(seg)→ drive the soak walk + time of day.DBG.enterShop(shopId)/DBG.exitShop()→ scripted interior visits (shotrecord_interior).DBG.info()→{drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode}(budget/soak read this). This mirrors 90sDJsim'swindow.DBG.ST/window.DBG.shot()(90sDJsim/tools/shots.py:31-33). Until it exists,shots.py/soak.pydegrade to booting the default view and warn.
5. Order of operations once lanes land
- ✅
node tools/qa/scaffold_check.mjs— A's plan determinism + registry confirmed present. - ✅ Wire §3.1 (plan swap) → generated seed boots on the shell (barrel + skins fix, verified live).
- ✅ Wire §3.2 (interiors) → door opens, enter/walk/exit + dispose verified across all 9 types.
- ✅ Wire §3.3 (citizens + keepers) → keeper greets at the counter; peds walk the graph, budget-safe.
- ✅ Wire §3.4 (manifest +
?noassets+ rig-fleet) and §3.5 (hours) — all verified live (R3). - ◑ Gates: 2 & 4 PASS (soak/asset-free), 5 GREEN (
--depot), 3 tris-blocked on D/E decimation, 6 shots need Lane B bookmark poses.docs/shots/v1_tour/captured. - ⏳
tools/qa.sh --strictgreen + gate 3 tris ⇒ tag v1 (pending D/E ped-GLB decimation).
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 facadeMatbuiltassets/gen/facade-<name>.jpgbut Lane A'sfacadeSkinis the full filename (facade-fibro-blue.jpg) → every facade requestedfacade-facade-…jpg.jpg(404 → flat colour). Lane F normalized the name at thefacadeMatchokepoint (strip a leadingfacade-+ 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/
?noassetsnot wired (§3.4) — the remaining integration passes.
8. Open questions for the lanes (surface, don't guess)
- B: will you expose the
window.DBGhook (§4) and theChunkManagerlifecycle 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
hoursencoding (24h ints? segment idx)? - C: does
placestag the counter slot with a stablekind:'counter'so keepers find it? - E: is
?noassetshonored 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?
9. Cross-lane requests (Lane F → others, R3) — the integration surfaced these; not silent-editing your files
→ Lane D / Lane E — decimate the ped GLBs (blocks gate 3 tris). The rig fleet (web/models/peds/*.glb)
is wired and works, but the character models are triangle-heavy: ~45k tris each near rig, so the
busiest-intersection view hits ~279k tris (soak peak ~1.0M) vs the 200k CITY_SPEC budget. Draws are
fine (~200/300 — the shared-mesh sharing worked). Please decimate/LOD the 19 ped GLBs (target ≤~10–15k
tris each) so gate 3 passes; then F can raise ?pop= past 140. Same applies to any heavy interior fitting GLB.
→ Lane B — three dbg.js / shell items:
- shots.py bookmark poses (gate 6).
dbg.js BOOKMARKSdefinesstreet_noon/arcade/market_square/ milkbar_dusk/night_neon, but F's v1_tour also useswarehouse_fringe / residential_collar / crossroads_busy— these fall back tostreet_noon(identical frames). Please add those 3 poses. - shots letterbox. In headless (1280×720),
DBG.shot()→composer.render()renders into a square sub-region even though renderer/canvas/camera are correctly sized — the EffectComposer render targets don't rebuild on the initial resize. A real viewport toggle in shots.py didn't fix it; likely the composer needs an explicitsetSize/reset on firstshot(). (In-game, real window sizes render fine.) - §3.5 closed-facade visual. F gates enterability by hours (
window.PROCITY.isOpen(shop)); the visual closed state — dark windows + a CLOSED plate, and a "CLOSED" door tooltip inhud.js— is your facade/HUD render. Predicate is ready onwindow.PROCITY.
Note: F-owned harness bugs found + fixed this round (so the gates actually run): shots.py/soak.py
polled window.DBG before its async install (race → false "absent"); read DBG.plan (unexposed → use
window.PROCITY.plan); enterShop({shopId}) vs the bare-id contract; shop.lot treated as an object
(it's a lot id); soak now pins midday (else §3.5 closes shops mid-walk) + pre-warms the shared GLB
cache (else the one-time upload reads as a leak) + budget peaks demoted to warnings (DBG.info samples spike
during streaming; gate 3's clean single-pass is authoritative).