PROCITY/docs/LANES/LANE_F_NOTES.md
m3ultra 8ac3b18b36 Lane F R22 (v4.0 — THE EPOCH CLOSE): the re-measure, the selector, the release tour, the tag
The blocking gate is CLOSED, and the epoch closes green.

- #1b THE BLOCKING RE-MEASURE: F's R21 re-measure made the tri diet blocking; B diagnosed layer-by-layer,
  the driver traced A -> E (A's own commit: "B's numbers say the fix is E's"), E fixed it, F re-measured:
  katoomba venue_night 221,935 (+11%) -> 108,365 (-51%); bendigo 201k -> 124,682; fremantle -> 88,397.
  Worst 124,682 — the 200k stress edge is closed. The measure-first loop worked end to end.
- #3 DBG.enterShop(selector) (Lane G's ask): id · registry type · exact name · name substring, resolved
  most-specific-first so an id-shaped string can't be shadowed. One-liner QA on any of 23 towns.
- #4 The release tour -> docs/shots/release_v4/: the Fremantle hero · the Fitzroy mecca · the Toowoomba thin
  tail · the synthetic control · the ?classic=1 covenant. Every real-town frame poses through F's R21
  cluster primitive; B's R22 bookmarks anchor on the same retail heart, so tour and bookmarks agree by
  construction. HUD hidden throughout (F's R21 ruling corollary).
  The primitive earned itself twice: the first cut posed via street_noon and the MECCA CAME OUT AS BARE
  BITUMEN — the same trap as R21. Re-posed through the cluster, Fitzroy reads.
- HONEST FINDING (filed v4.x): the brief asked for the thin tail as "proof the floor holds". It does not,
  and the frame says so — Toowoomba reads as an empty road. The floor is a RATIO, not a count: 0.008
  shops/edge vs the heroes' 0.06-0.08. Shipped labelled honestly rather than swapped for a flattering town.
- #6 Docs: README v4 section (the towns ARE the product), LANE_F_NOTES §22, F-progress.

qa.sh --strict --matrix GREEN 7/7; MATRIX GREEN — 10 towns × 7 gates (the budget gate green everywhere for
the first time); selfcheck 161,300/161,300 (A's 36 goldens + the 23-town pack).

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

62 KiB
Raw Blame History

LANE F — NOTES (integration runbook + QA gates)

Lane F is integration + QA. It runs after AE 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 AE 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.

v2.0 FROZEN (round 9). Nine rounds: seed → walkable town → weather, a shopping crowd, follow-a-ped-into-a-shop presence, and real stock you can buy. Current gate reality + the final v2 flags table live in F-progress.md; qa.sh --strict GREEN 5/5 (scaffold+determinism · consistency · citygen selfcheck · manifest --depot · v2 flags harness STRICT). CityPlan v2 contract frozen by Lane A in CITY_SPEC; validated live by selfcheck (1727/1727) + goldens.


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:[<full filename>.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 CitizenSimnew 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 KeeperManagernew 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 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 to new CitizenSim({…,fleet}) and (via createInteriorMode({…,fleet})) to KeeperManager. Peds + keeper upgrade placeholder→shared GLB rigs once fleet.whenReady resolves (verified: citizens.mode==='rig', keeperKind==='rig', detailed characters on the street + behind counters).
  • Interior GLB: preloadManifest() at boot + buildInterior(shop,THREE,{useGLB:!NOASSETS}) → Lane C glb.js swaps fitting primitives for depot GLBs (depot:<file> → 3GOD, reachable). Verified: bookshop fittings load real GLBs (+8 meshes, dense shelves). Not all kinds mapcounter is intentionally primitive; record_crate is 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 loadGLB cache (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=140 until then (raising pop adds near rigs = more tris). See Cross-lane requests §9.

3.5 Hours / closed shops DONE (R3, verified live)

  • currentHour() parses lighting.getClock().hour ('HH:MM'→float); isOpen(shop) = open ≤ H < close. Both exposed on window.PROCITY. enterShop gates on it: closed → 🔒 CLOSED · opens HH:00, no build.
  • Lane A ships exactly one openLate:true landmark/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) 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.0 criterion met, R4). tools/qa.sh --strict is the static umbrella.

Measured against the fully-committed R4 tree (seed 20261990, "Boolarra Heads"), verified live + by a 6-auditor adversarial v1-readiness workflow (2026-07-14) → GO.

# gate criterion result (R4)
1 Determinism seed → identical plan (deep-equal + golden fingerprint) + selfcheck scaffold_check green; selfcheck 1301/1301, fingerprint 0x3fa36874 (intended A2 shift, not drift); two node processes → identical sha256(plan) per seed; chunkIndex covers every lot
2 Soak walk ≥30 chunks, ≥15 shops; geo/tex baseline after each interior; heap stable; 0 console errors 37 chunks, 20 shops, heap 38→38 MB, 0 errors, leak-free
3 Budget busiest intersection: ≤300 draws, ≤200k tris (settled clean measure) draws 138 · tris ~31k (baseline 372k; peds decimated 924k→46k ≤3k each, D2 sign-off)
4 Asset-free ?noassets=1 full playthrough, zero crashes, zero network heap 13→13 MB, 0 errors, 0 asset requests
5 Selfchecks selfcheck.js + validate_manifest.py --depot green via qa.sh qa.sh --strict 4/4; manifest --depot: 23 GLBs live, 0 err
6 Shots docs/shots/v1_tour/ 10 beauty shots + contact sheet 10 full-frame 1280×720, no letterbox, 3 new poses resolve; incl. §3.5 night closed-facade (CLOSED plates + lit openLate video) + real record/opshop interiors (keeper + hero props)

Note: soak peak-draw/tri warnings are transient teleport/impostor-atlas-bake sampling spikes; gate 3's settled busiest-intersection measure is the authoritative budget check (draws 138 / tris 31k).

DBG hook — LANDED (Lane B web/js/world/dbg.js, ?dbg=1)

window.DBG exposes ready / shot(name) / teleport(x,z,ry) / setSegment(seg) / enterShop(id) / exitShop() / info() and drives tools/shots.py + tools/soak.py (mirrors 90sDJsim's window.DBG). Bookmarks: street_noon, arcade, market_square, milkbar_dusk, night_neon, crossroads_busy, residential_collar, warehouse_fringe. Interior tour shots (record/opshop) are captured by enterShop(id) directly (not via shot(), which forces street mode) — see tools/shots.py.


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 + rig-fleet) and §3.5 (hours) — all verified live (R3).
  6. ◑ 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.
  7. tools/qa.sh --strict green + 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 facadeMat built assets/gen/facade-<name>.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?

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 ≤~1015k 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:

  1. shots.py bookmark poses (gate 6). dbg.js BOOKMARKS defines street_noon/arcade/market_square/ milkbar_dusk/night_neon, but F's v1_tour also uses warehouse_fringe / residential_collar / crossroads_busy — these fall back to street_noon (identical frames). Please add those 3 poses.
  2. 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 explicit setSize/reset on first shot(). (In-game, real window sizes render fine.)
  3. §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 in hud.js — is your facade/HUD render. Predicate is ready on window.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).


10. Round 5 — v1.1 close-out + v2 flag seams (Lane F tracking)

Round 5 (v1.1 polish + v2 foundations behind flags). v2 prime law: default boot stays v1-identical; GOLDEN.hash 0x3fa36874 must not move; qa.sh --strict green at every commit.

v1.1 tag — WATCH (blocked on C1 + E1)

Tag v1.1 once Lane C re-maps counter_till + record_crate (C1) and Lane E bakes the hero props ≤8k (E1). F then: verify record/milkbar/opshop interiors (GLB counter+till correct, bins upgraded, baked props look right), re-capture ONLY the 2 interior tour shots if improved, qa green → git tag v1.1 (local). Nothing to tag yet — HEAD 039c179, no round-5 lane commits.

v2 flag seams — table (F wires web/index.html + tools/ as lanes document them)

Every flag default-off + independent. Wire only from the owning lane's NOTES call-site spec.

flag owner what F wiring state
?plansrc=osm A second plan source (OSM fixture) behind the CityPlan contract select in shell plan bootstrap A did not run R5 — task carries to R6
?winmap=1 B parallax interior-mapping window shader none needed (self-contained in buildings.js) landed 2a923b6
?dig=1 C dig.js crate-riffle on procedural stock wired by F (26d68cd): DIG_ONinteriorMode, Esc/pointer-lock guards landed 7131f53 + F2
?roster=stream D chunk-streamed citizen roster none needed (self-contained in sim.js) landed 93a3168

(table updated by Fable in the R5 review — F3 harness extensions below did NOT land in R5; they are the top Lane F item for R6.)

v2 QA harness extensions (F3) — build as flags land

  • Determinism gate: pin plan-hash + first-interior-hash per (seed, plansrc). Synthetic stays 0x3fa36874; add the osm-fixture hash when A lands.
  • Flags-off regression check: boot no-flags → assert plan hash + a settled draws/tris snapshot within tolerance of the v1 baseline (catches a v2 flag leaking into the default path). This is the enforcement arm of the v2 prime law — build early, before flags land.
  • v2 smoke: boot each flag on, walk 2 chunks, enter 1 interior (+1 dig if C landed), 0 console errors. Wire into qa.sh as a non-strict (warn) gate this round; strict next round.

Marshal

Shared-tree arbiter this round: F owns web/index.html + tools/; if two lanes collide on a file, F arbitrates. Cross-lane asks → the owning lane's NOTES, not silent edits.


12. Round 12 — v3.0-alpha gig layer (?gigs=1). F wires it last; findings below.

What F built. web/js/world/gig_state.js (new, F-owned) — the Friday-night state machine; the ?gigs=1 seam + custom_bands.json bootstrap in index.html; the cover charge at the door; the band + crowd + gig-bed wiring in interior_mode.js; smoke_gigs in tools/flags_check.py (6 gates) + the v3 arm of the flags-off regression.

The state machine (gig_state.js) — the two things that are load-bearing

  1. It listens, it doesn't poll. lighting.js dispatches procity:segment on every segment change; the latch rides that. A poll-only machine sees only the segments it is sampled on — driving NIGHT→DAWN→MORNING→NIGHT with nothing reading in between (player inside the pub, audio muted, so neither F's street loop nor B's audio rAF is reading) skipped the night roll entirely: the night never advanced and the cover was never due again. Caught live, fixed, re-verified.

  2. state is a getter, not a field. B's audio engine self-ticks on its own rAF and reads PROCITY.gigs.state while the player is inside a shop — i.e. when F's street loop is not running. A pushed field goes stale there and the spill lies about the gig.

  3. The night rolls on WHERE the clock lands, not on watching done go by. First cut incremented the night when leaving 'done' forwards — which meant a clock jump straight from NIGHT to MORNING (never touching DAWN) never rolled the night at all, and a broke punter walked in on last night's paid stamp. smoke_gigs caught it; stepping with [/] always passes through DAWN, so only a jump (DBG/shots/soak all jump) exposed it. Now: the latch clears wherever it clears, and landing in the daytime (MORNING…ARVO) is what rolls the night; landing on the doors means we rewound out of the gig — same night, stays paid.

done is real, not decorative: the segment→hour map tops out at NIGHT (22:00) so the gig owns the whole night segment, and "after the gig" can only be DAWN (closing time — band packing up, crowd draining per A's closing-time ruling). Latch clears on any non-DAWN segment so rewinding the clock ([) doesn't leave done stuck on a midday town — it did exactly that on the first verify.

⚠ Finding 1 (Lane C ↔ Lane E) — the gig bed key never met. F bridged it; needs a contract line.

LANE_C_PUB.md asks E to name the live bed gig-pubrock (C emits room.audio.gigKey = 'gig-' + genreKey). E shipped it as pubrock-live (gig:true, types:['pub']) — which is also the key B's street spill hardcodes. Neither is wrong; they just never met. Unresolved, AUDIO.music['gig-pubrock'] is undefined and the pub plays to a silent room — and because the audio law fails soft, nothing errors: you just get a band miming. F resolves it at the glue (interior_mode.js GIG_BED + a generic gig-<genre><genre>-live fallback). This is the R1 facade-skin filename trap again (registry stored filenames, skins.js expected bare keys). → C/E/Fable: pick one and put it in CITY_SPEC as a named contract, so genre #2 doesn't rediscover this. F's smoke now asserts gigKey resolves.

⚠ Finding 2 (Lane D + Lane E) — E's instrument GLBs landed; nothing wires them.

E shipped 5 instrument GLBs this round (electric_guitar, bass_guitar, drum_kit, guitar_amp, mic_stand, all in manifest.fittings with footprints + thumbs). D's band holds primitives and published spawn(..., { instrumentFor }) as the injection seam — D-progress says E's GLBs "drop in via opts.instrumentFor(role) when they land". They have landed. Nobody owns the join. Not blocking (asset law: a primitive band is still a gig, and it reads fine in the shot), but it is a dangling R12 deliverable. F did not do it silently: picking manifest ids and orienting/scaling instruments inside D's rig is D's call, and the R12 no-drummer question below wants answering in the same pass. → R13.

⚠ Finding 3 (ROUND12 spec ↔ Lane C) — the band has no drummer.

ROUND12 §Lane D.1 specifies "guitarist/bassist strum-sway, drummer seated bob", and E was asked for "drum kit (one merged kit)". C's stage.bandPoses are guitar / vocal / bass — a front-line trio; the drum riser is set-dressing with no pose. D built to C's poses and flagged it. So the shipped alpha band is 3-across-the-front with a mic stand where the spec expected a kit. Cosmetic, non-blocking, deterministic — but it is a spec/implementation divergence that no gate can catch. → Fable/John: rule for R13 (add a 4th drums pose on the riser, or amend the spec to a front-line trio).

Gate lesson — "no giants" must measure STATURE, not world crown.

The R10 gate asserts head-bone world Y ∈ [1.4, 2.0]. The band stands on C's 0.32 m stage deck, so a perfectly human 1.73 m guitarist has a world crown of 2.05 m and a naive world-Y check fails a correct band (it did, first run). smoke_gigs measures crown base.y for the band/crowd and keeps the world-crown assert only for "never clips the ceiling", which is the real R10 failure mode. tools/qa/interior_scale_check.py is unchanged (keepers/browsers stand on the floor, base.y = 0) — but if anyone ever puts a keeper on a platform, that gate inherits this bug.

→ Lane B (small, R13): no venue bookmark for shots

dbg.js BOOKMARKS has no venue pose, and DBG.shot(name) forces street mode and re-poses/re-segments — so there is no way to drive a scripted "lit pub frontage at night, gig on" street shot (the alpha's other money shot; your marquee + posters + spill are the whole street story of this round). F verified the frontage lights correctly (emissiveIntensity 1.7 at on, 71 draws / 29.6k tris at the venue) but shot it by hand. Ask: add a venue_night bookmark (pose at the pub frontage, seg:5) — then F's tools/qa/gig_shot.py can capture the street side too. This is the same "3 missing bookmark poses" ask from R3 §9, so it may be worth doing the set in one pass.

Harness note — where the browser gates actually run

This session's box had no Chromium and no Chrome, so every Playwright gate (flags harness + scale gate) is un-runnable locally until tools/.venv/bin/python -m playwright install chromium. The repo at /Volumes/m3ultra/Documents/procity is a mount of m3ultra's ~/Documents/procity — same tree, so ssh m3ultra@100.89.131.57 runs the real gates on the real files. Gotcha: node is not on the non-interactive ssh PATH — env PATH=/opt/homebrew/bin:$PATH tools/qa.sh --strict, or every node gate reports a bogus FAIL (node: command not found).

R12 numbers (measured, seed 20261990 → "Boolarra Heads", venue = The Thornbury Hotel)

thing number
gig-night interior, rig fleet 4250 draws · ~40k tris (budget ≤350)
gig-night interior, ?noassets=1 placeholders (worst) 171 draws (budget ≤350)
gig-night street at the venue 69 draws · 29.6k tris (budget 300 / 200k)
band / crowd 3 on the deck · 8 at 8 watch points · 3 dancing / 5 standing
figure scale band stature 1.681.77 m (crown ≤2.09 m under a 4.0 m ceiling); crowd 1.601.77 m
leak, 4 gig enter/exit cycles geo 66→66 · tex 63→63 (zero)
tonight's covers (7 nights) 10, 0, 0, 3, 2, 0, 9 — 3 free of 7 (~half, as seeded)
custom band names surfacing 7 of John's 10 fill all 7 nights; ?noassets=1 → pure generator

13. Round 13 — v3.0-beta THE DISTRICT (?gigs=1). F wires it last; findings below.

What F built (all 7 tasks from ROUND13 §Lane F). All committed Lane-F-only; verified green on m3ultra (qa.sh --strict, the focused district gate, the scale gate). A single WARN — the queue — is a documented handoff to D, not a hole (below).

  1. Per-venue state machine (gig_state.js, rewritten). One latch PER VENUE keyed by venueShopId; a town of 24 venues runs 24 independent latches off the SAME procity:segment spine. All three hard-won R12 laws are preserved verbatim inside createVenueLatch (listens-not-polls · state is a getter · night rolls on WHERE the clock lands). New runtime surface on window.PROCITY.gigs: .byVenue{ [id]: state } (B's venue.js/audio.js read it), per-venue stateOf/onOf/openOf/gigOf/coverOf/ bandNameOf/paidOf(id) + markPaidOf(id) + .venueShopIds. The R12 single-venue alias (.state/.on/.venueShopId/.cover/.paid/… → the primary venue = gigs[0]'s) is kept one round so B's mid-round single-venue callers don't break — it retires next round. Contract pinned in CITY_SPEC §v3. Tonight per venue = its night-0 gig (matches A's posters + B's frontage, all night-0-keyed); a dark-tonight venue latches 'quiet' all session, which is true to the seeded week.

  2. Walla + applause (interior_mode.js). While a venue's gig is on, the crowd bed ambience['crowd-walla'] rides the interior TONE layer under the live band bed (you hear the room, not the fridge hum) — it reuses B's existing playInterior({musicKey,toneKey}), no new audio API. On the on → done edge while the player is inside (crowd's last-song → the clock rolls NIGHT→DAWN), a one-shot sfx['applause'] sting fires ONCE (applauded latch, re-armed per enter). Fail-soft throughout (no-op muted / ?noassets / pre-gesture). Verified live: iMusic=gig-pubrock, iTone=crowd-walla, edge fires, 0 errors.

  3. Cover charge PER VENUE (index.html). The door seam now keys off the shop being ENTERED (openOf/coverOf/paidOf/markPaidOf(shopId)), so a night at two venues is two covers; each latch holds its own runtime paid-stamp. Verified: paid ($10 debited once, re-entry free), broke-knockback, free night.

  4. Debt #1 CLOSED — and the R12 bridge was a LIVE BUG. E renamed the pub bed pubrock-live → gig-pubrock this round (the canonical gig-<genreKey>), so F's R12 GIG_BED/gigBedKey bridge — which rewrote gig-pubrock → pubrock-live — was now pointing at a manifest key that no longer exists: the band would have mimed in a silent room. DELETED. The room's audio.gigKey (already the manifest key) feeds the engine directly, zero mapping. Verified across two genres live: gig-pubrock (pub), gig-covers (rsl).

  5. Gate hardening — stature into the shared gate (interior_scale_check.py, debt #6). The human-size assert now measures stature = crown foot (feet→crown span, platform-independent), with the world-crown "never clips the ceiling" assert kept as its OWN line. The day a keeper stands on a platform, the gate won't fail a correct figure. Floor figures foot≈0 ⇒ stature≈crown ⇒ backward-compatible. Verified: 24 figs across 6 shops, statures 1.601.86 m.

  6. District smokes (flags_check.py smoke_gigs, extended). band 3→4 · a new §2b DISTRICT loop that enters EVERY playing venue and asserts its gigKey resolves to a real manifest bar (debt #1 across 3 genres), per-venue crowd ≤ its own watchPoints, 4-piece, no-giants-by-stature · §2c street budget at the busiest venue block (settled-floor sample) · queue probe (WARN-pending, below) · plus the R12 determinism / cover / noassets / flags-off arms. All green.

⚠ Cross-lane ask (Lane F → Lane D) — the QUEUE seam. F's smoke is READY; it WARN-skips until D exposes it.

D owns the outdoor queue (web/js/citizens/queue.js VenueQueue, charter item, landing this round). B has already published the zone: window.PROCITY.venuePresentation.queueZoneFor(id) → {x,z,ry,len} and .queueZones. F drives the surge per venue via citizens.setGig(id, open) (done). What F's smoke needs to verify "queue ≤ cap and drains by done" is a read seam on the sim — proposed contract: window.PROCITY.citizens.queueInfo(venueShopId) → { count, cap } (live count while doors/on; 0 once the gig is done). Wire the queue's spawn to queueZoneFor(id) at doors and expose queueInfo, and F's smoke flips WARN→assert with no further change. (If D picks a different accessor name, tell F and F adjusts the one line in smoke_gigs §2b.)

R13 numbers (measured, seed 20261990 → "Boolarra Heads", 3 venues)

thing number
district 3 venues — 2 playing tonight (pub #116 The Exchange Hotel · rsl #404), 1 dark (reads true)
live beds resolve pub → gig-pubrock · rsl → gig-covers (both real manifest bars, debt #1 across genres)
band / crowd 4-piece each venue · pub crowd 8/8 watch points · rsl crowd 12/12 (the cap stress case)
gig-night interior (rig) 62 draws · 34k tris (budget ≤350) · ?noassets still a full 4-piece gig
street @ venue block, night 99 draws · 28k tris (budget 300 / 200k) — lit frontage + posters + queue zone in
figure scale band+crowd stature 1.611.81 m, tallest crown 2.12 m under the 4 m ceiling
cover, per venue $10 debited once · re-entry free · broke knockback · free night (seed 1234) — all hold
flags-off prime law fingerprint 0x3fa36874 unchanged · gig layer wholly absent · 0 errors

Status: F's wiring is done, committed, and green. Waiting on Lane D for (a) the queue seam (queueInfo) so the queue smoke goes live, and (b) the band polish (E's instrument GLBs in hand, drummer facing/RY_FLIP) — the band already lands at 4, human-sized, 0 errors, so this is polish not a blocker. Once D commits: re-run the gates, flip the queue assert, capture the district money shots, tag v3.0-beta.

(R13 epilogue: D landed mid-session; F wired the queue (D shipped VenueQueue, left the street-side lifecycle to F), the queue smoke went live, both money shots shipped, and v3.0-beta was tagged + pushed. The queueInfo proposal above was superseded by D's VenueQueue.count() + F's shell queueCountOf.)


14. Round 14 — F never ran; the brief carried in full to R15.

R14 was the "v3.0 release round." AE landed excellent hardening (D's identity-continuity seam, B's alias-reader retirement + queue_night hero + 19-bookmark tour audit, A's 400-seed invariant sweep + §v3 freeze, C's adversarial venue audit, E's provenance freeze) but Lane F never ran — so v3.0 was not tagged and F's whole release brief (continuity wiring, alias deletion, week soak, the tour, the tag) was untouched. Fable carried it verbatim into R15. Nothing F-authored landed in R14.

15. Round 15 — THE v3.0 RELEASE CLOSE (?gigs=1). F ran last; the six-item brief, all green.

A first (poster flip + golden re-pin) → B/C verify → D one-line amp move → F closes. All deps were in before F ran (verified against origin). The six F items (ROUND15 §Lane F), each committed atomically by pathspec:

  1. Continuity wiring + smoke (ledger #3) — D published the seam in R14; F wired its three touches: crew.spawn(…, { roster: rosterOf(shop.id) }) in interior_mode.js (front crowd slots become tonight's roster), rosterOf:(id)=>citizens.tonightRoster(id) + onAdmit:(e)=>citizens.recordVenueEntry(id,e) on the queue in the shell (the clear is automatic on setGig(id,false)). New smoke_continuity asserts the seam on identities (crowd ⊇ roster ∩ cap · every member.pedIndex ∈ tonightRoster · crewInfo.fromRoster), the empty-roster path injects nobody (shell side of D's byte-identical law), and settled tris (ledger #5). Commit 687d23f.
  2. Alias deletion (ledger #2) — the R12 single-venue scalar alias is gone; every read is keyed by venueShopId. Deleted the alias block + primaryId/P in gig_state.js; migrated flags_check.py + gig_shot.py (.venueShopId→venueShopIds[0] · .state→stateOf(id) · .cover→coverOf(id) · .bandName→bandNameOf(id)); deleted the two CITY_SPEC §v3 alias sentences + added the omitted nightOf(id). B's R14 sweep left the cross-lane readers clean; only F's tools remained. One atomic commit 146b5f3.
  3. Full-week soak (tools/qa/week_soak.py, opt-in qa.sh --soak, non-strict) — 7 nights × every playing venue in one context: latch state per plan.gigs (dark venues quiet), cover stamped per venue per night (debited once, re-entry free, due again next night), roster clears per night (no accumulation), and geo/tex return to a warmed baseline across ≥20 cycles — a leak is unbounded per-cycle growth; warm-up cache residue plateaus (the memTrail proves it). Commit edc0c47.
  4. The money-shot tour (tools/qa/tour_shots.py, ledger #4/#5) — one deterministic run → docs/shots/ release_v3/ + contact.html: venue_night, district_posters, queue_night (John's hero — A's flipped frontage poster now reads from the street), gig_interior (4-piece + real instruments + a roster crowd), rain_verandah, tram_stop. Settled discipline: measured after the async instrument GLBs / posters / chunks land (shoot-twice), clean per-group loads; queue driven live (explicit admits, not throttled rAF auto-drain). Commit 6a35b29.
  5. Docs assembly — README v3 section (what shipped · flags table · the tour), this §14/§15 runbook, F-progress current, the five freezes confirmed committed (A a3911a4/35da83c, B eee60e0/a0b5784, C 62e38c5/2446ae3, D 9a65f51/66aec0f, E 55f6698).
  6. Acceptance + tagqa.sh --strict 6/6 + smoke_continuity + week_soak.py green → tag v3.0.

Numbers (measured, seed 20261990 → "Boolarra Heads", 3 venues)

thing number
district 3 venues — 2 playing (The Exchange Hotel gig-pubrock + an RSL gig-covers), 1 dark
gig interior, settled 4-piece · crowd 8 (2 from the roster) · 56 draws · 35k→182k tris (pre-load→settled; gate is draws ≤350)
street @ venue block, night queue_night 121 draws / 62k tris · district_posters 186 / 134k (busiest) — all ≤300/200k
week soak 28 enter/exit cycles · $60 covers · geo/tex flat across the back half (115/86→115/84) · 0 errors
continuity seam 2 queue admits carried into the crowd · empty roster 0-injected · member.pedIndex ∈ tonightRoster
settled-tris honesty (ledger #5) C was right: the interior settles to ~182k tris after the 5 instrument GLBs load, not the ~34k measured pre-load

The gotcha ledger closed this round

  • Alias retirement is a three-party move: B swept cross-lane readers (R14), A left the spec sentences for F (avoiding a doc-ahead-of-code race), F deleted the code + migrated its own tools + the spec in ONE commit. No half-renamed state ever existed.
  • Settled measurement (ledger #5): a headless smoke that reads renderer.info right after enterShop sees the room BEFORE its async instrument GLBs decode — ~34k, not the ~182k settled truth. The tour + the continuity smoke both wait/shoot-twice now; the interior gate stays on draws (which is load-order stable), so there was never a budget violation, only a dishonest number.
  • Drive live-spawned things explicitly: automation throttles rAF, so the queue's seeded auto-drain may not fire on schedule — smoke_continuity/week_soak/tour_shots all call admitOne() / step the loop explicitly rather than trust the timer.

16. Round 16 — v3.1 THE FLIP (?classic=1 is the new covenant). F bookends; opening below, close after.

The town shows its best by default. After three v3 stages the gig layer flips ON, and the mature v2 flags (weather, winmap, tram) flip with it. F owns the flip; AE landed their v3.1 polish against it (D's seated drummer, B's town-wide pools + default re-baseline, C's backline unification, A's corner-poster + no-spine fixture, E's sit.glb).

The flip (ledger #1) — the covenant MOVED from "flags off" to ?classic=1

  • Shell (index.html): const CLASSIC = has('classic') && get('classic')!=='0', const flagOn = name => !CLASSIC && get(name)!=='0'. gigs/weather/tram construct via flagOn; loadPedFleet({sit:!CLASSIC}) gives the drummer his clip while keeping classic zero-fetch; window.PROCITY.flags publishes the intent.
  • buildings.js: winmap reads the SAME rule (default-on, classic-off) — the one cross-lane flip line, because winmap's flag lives in B's file. Marked [Lane F R16 THE FLIP].
  • Two goldens, two gates: classic → 0x3fa36874 (byte-identical v2 forever, the frozen regression target); default → the gig golden 0xb1d48ea1. flags_off_regression became classic_regression (boot ?classic=1); a new default_boot_gate proves the no-flag boot is the full experience (all four flags on, gig layer present, gig golden green, budget). Every gig smoke re-points to the DEFAULT boot.

Seated-stature gate (ledger #3) — published in the brief, no mid-round handshake

A seated figure fails the standing no-giants floor by design. Contract (in the round brief so D built to it): D tags seated figs fig.userData.procitySeated = true; F's interior_scale_check.py + flags_check.py accept them at stature ∈ [0.9, 1.5] m (still < ceiling, feet planted); standing stays [1.4, 2.0]. Landed in the opening commit. Verified live: the seated drummer passes at 1.32 m stature.

Numbers (measured, seed 20261990 → "Boolarra Heads")

thing number
?classic=1 byte-identical v2: fingerprint 0x3fa36874, gig layer absent, streamed roster on, 160 draws / 21k tris
default boot all four flags on · gig golden 0xb1d48ea1 · 14 gigs, 15 posters · 112 draws @ the venue block (≤300)
district (default) 3 venues, 3 playing (corner-poster re-pin shifted the schedule) · 3 genres live
seated drummer 1.32 m stature — passes the seated band [0.9,1.5]; the standing crew 1.681.81 m

The gotchas closed this round

  • A flag whose parse lives in another lane's file (winmap in buildings.js) still flips as part of F's structural change — one marked line, same !classic && !=='0' rule as the shell.
  • selfcheck prints the base fingerprint in its summary but asserts the GIG golden silently, so the default-boot gate gates on the ALL-GREEN run (which includes the gig-golden assert), not a stdout substring.
  • P.winmap isn't exposed — the default gate verifies winmap intent via window.PROCITY.flags; the actual shader is B's own winmap smoke.
  • ?classic must zero the fetch delta, so sit.glb is gated on !CLASSIC (not just on the fleet) — a raw sit fetch under classic would break the covenant even though the drummer never spawns there.

17. Round 17 — v3.2 THE TAIL + THE SCOUT. F: the town matrix + the close (ledger #7).

The tail drained (D's drummer lean + bench-sit, B's wind sway, C's backline verify, E's --verify fix) and the real-map scout ran (E's 5 real AU town caches + A's cache contract + hardened plan_osm). F wired the last mile and gated the whole matrix.

Real towns boot in the engine (the F wiring)

E's caches sat on disk (web/assets/towns/*.json) + registered in the node selfcheck, but the shell never loaded them?plansrc=osm&town=bendigo_real silently fell back to Melbourne. F closed the gap: the barrel (index.js) re-exports registerTownCache/validateTownCache; the shell fetches assets/towns/<TOWN>.json

  • registers it before generatePlanFor when the town isn't a checked-in fixture. Fail-soft — 404 / invalid / ?noassets → plan_osm's fixture fallback; the scout data is opt-in, never on the default or ?classic path. Verified: ?plansrc=osm&town=bendigo_realBendigo, 9 shops, a 3-venue district, 0 errors.

The town matrix (tools/qa/town_matrix.py, opt-in qa.sh --matrix) — the v4 evidence

Boots every town on the DEFAULT boot and gates boot / determinism / budget / district / ?noassets. All 10 × 5 green:

town shops budget (draws/tris) district
synthetic/20261990 · 1234 493 · 486 124d/57k · 147d/71k 3v ✓
osm/melbourne · katoomba · silverton 95 · 19 · 12 216d · 70d · 70d 3v ✓
real/bendigo · castlemaine · fremantle · katoomba · newtown 9 · 6 · 21 · 20 · 18 82 · 86 · 159 · 69 · 93 d 3v ✓

The headline: the existing CityPlan contract eats real Australian geography — Castlemaine at the 6-shop floor still lands a 3-venue district; every real town is deterministic, in-budget, and silent under ?noassets. No new game systems — exactly the bounded scout the round asked for. This table is the evidence John charters v4 = THE REAL MAP on.

Gotchas

  • A published cache + selfcheck ≠ a bootable town. The node sweep proved the plan generates; the browser boot needed the shell to fetch+register the cache. Classic integration seam — F owns it.
  • ?noassets can't fetch a real cache, so a real-town + noassets boot correctly falls back to a fixture (still silent-and-fine). The matrix's noassets gate tests that fallback, not the real town — as it should.

18. Round 18 — v4.0-alpha REAL ROADS. F correctly did NOT run — the gate would have failed by design.

The alpha gave katoomba_real its real OSM street geometry (A's schema v2 roads[] + graph lift; E's road fetch). F's charter deliverable was "as much the failure list as the town" — and F, running the gate, found the alpha not green, so F held the tag and filed the list rather than shipping a red release. This was the round working as designed (Fable's R18 verdict agreed: "F never ran — correctly").

What F caught (all Lane A plan-gen, surfaced with measurements):

  • qa.sh selfcheck REDkatoomba_real's real-roads golden UNPINNED (the A→E→A finalization handshake never fired: E's roads landed after A's commits), and the 4 trailing towns' goldens stale (pinned on marched output, now roads).
  • Poster-kerb-clearance RED on all 5 real towns — the real edge explosion (232 "main" edges) put spine posters 1.43.9 m into the road.
  • Fragmentation — 20 shops scattered over 799 real edges / 607 m ⇒ no walkable strip; the money-shot attempt was a vast empty field (D's finding: 105 components, 42% dead street-metres). And B's separate finding: the BIG_CITY heuristic misfired (20 shops < 120 ⇒ small-town streaming on a 799-edge graph ⇒ 280k tris, breaching the 200k gate).

What was already green (the isolation held): real roads ride the cache schema, not a flag, so classic + default + synthetic + fixtures were untouched (0x3fa36874 frozen) — the alpha changed nothing outside the flagged path. The pipeline worked; the geometry needed the hardening the alpha existed to reveal.

19. Round 19 — the v4.0-alpha CLOSE. F: B's budget one-liner (early) + the gate (last).

A resolved the findings this round (fragmentation ruling — main component + joins, cull shopless islands; poster clamp; goldens pinned; junction-protected DP), D re-measured and verified, C re-soaked, B shipped the town selector. F ran two things:

  1. B's BIG_CITY one-liner (index.html, landed EARLY per the order so D/B re-measures saw it): BIG_CITY = shops > 120 || (streets.edges > 200). A real town can have few shops but a huge road graph; the graph now trips big-city streaming. Verified: real Katoomba 799 edges → 61 draws / ~12k tris (was 280k), 0 errors. B proved it; F owns index.html so F landed it.
  2. The alpha gate + the roads dimensiontown_matrix.py grew a roads gate: a real cache (schema v2 with roads[]) must build the REAL street graph (norm.mode === 'roads', read via the plan_osm opts.report sink), while synthetic/fixture towns stay marched/synthetic by design. Plus the classic + default gates prove the alpha is still isolated to the cache path. Money shot: Katoomba's real main street — asphalt + lane markings, footpaths, a pedestrian crossing, a walking crowd, gig posters, streetlamps, B's town selector in-frame — PROCITY's look on real bones, in budget.

The honest notes on the alpha (two, both filed for v4.0-beta)

  1. Density. Real Katoomba's 19-shop cache is genuinely spread ~1 shop per real edge (shopsOnBest = 1), so the alpha is a real street with the town's life on it, not a dense retail strip — that density is a beta concern (more shops per cache, or focusing the retail spine).
  2. One open budget finding (non-blocking). The matrix's worst-case view (venue_night — NIGHT, the lit venue block, all posters up) measures 200,965 tris on katoomba_real, +965 over the 200k soft ceiling (0.5%). Driver: the poster count scales with the real edge count (438 posters for 19 shops), not shop count. Normal play (the midday money shot) is 121k — well under. qa.sh --strict is GREEN; this rides the opt-in --matrix warn-gate, not a blocking gate. Beta hardening: A caps posters per real town. The alpha's job was real roads that boot, stream in budget in play, place a district, and read as a real place — that shipped; the venue_night stress view is the one filed edge.

20. Round 20 — v4.0-beta THE FIVE TOWNS (density). F never ran — the honest record.

R20 widened the towns (E: katoomba 20→80, five towns to 292 shops, "subtle" enforced by construction), proved the directive from the crowd side (D's A/B: heroes kept footfall 17→19 while the street got 3.8× busier), capped posters 438→31 (A), and ruled the tram by shop adjacency (B: the alpha tram fronted zero shops on 4 of 5 real towns — "it really was a highway bus"). Excellent round.

F did not run, and there was no tag. Unlike R18 — where F ran the gate and held on evidence — R20's F slot simply never executed. The round also left C's density door-soak undone, B's selector still hardcoding hud.js:11 instead of E's index, and D's relocation finding (katoomba's pub 1,000 m from the retail cluster — a gig with no passing crowd) without an owner. R21 closed all four.

21. Round 21 — the v4.0-beta CLOSE. F: the beta gate + the tag (ledger #6).

Waves ran [A, B, E] → [C, D] → [F]. A biased pickVenues toward retail-cluster adjacency (D's finding) and re-pinned once; B derived the selector from E's index.json; C soaked the five towns' doors at density; D verified the relocation (A's bias PASSES); E pulled 22 towns / 1192 shops forward as non-gating data.

The matrix — 10 towns × 7 gates (--matrix)

boot · determ · district · roads · tram · noassets green on all ten. Two budget flags (below). Selfcheck 154141/154141, qa.sh --strict GREEN.

Tram (#6c) — B's R20 ruling verified exactly

town verdict
newtown_real runs, 16 stops (B's phantom-stop fix: was 149)
castlemaine_real runs, 10 stops
katoomba / fremantle / bendigo fenced — 3 / 2 / 1 shops fronted, all < the 5-shop floor

New seam: the shell now exposes window.PROCITY.tram — B built routeInfo for F's smoke but nothing published it. F owns index.html, so F wired it. The matrix reads {fenced, stops, shopsFronted}.

⚠ The venue_night re-measure (#6b) — RECORDED, and the premise came out false

The brief expected A's poster cap to close F's R19 +965-tri edge. It did not — it widened:

R19 R21
katoomba_real venue_night 200,965 (+965, +0.5%) 221,935 (+21,935, +11%)
bendigo_real venue_night 201k (+0.5%)
fremantle / newtown / castlemaine 184k / 179k / 112k ✓

A's cap worked (posters 438→111). The cause is the density widening: katoomba 19→72 shops + D's 3.8× busier street more than ate the savings. Draws are fine everywhere (151/300 — half budget); this is tris-only, at the worst night stress view. Not a blocking gate (it rides the opt-in --matrix warn), and the brief's instruction was "record the new number" — recorded. Filed as R22's first item, where the tri-diet backlog (instrument LOD · tram tri diet) already lives. katoomba is the outlier: a 966-edge graph carrying 72 shops.

Ruling (#6d) — selector gating: always in the HUD, not ?dbg-gated

THE REAL MAP's product is the towns; gating their discovery behind a debug flag would hide the epoch's headline feature. It's unobtrusive, shows friendly names, and lives in the HUD overlay. Corollary (F's own gap): money shots must hide the HUD — the R19 alpha shot had the selector in-frame because F's harness screenshotted the full viewport. The beta shot hides it.

The beta money shot (#6e)

docs/shots/v4_beta/fremantle_mainstreet.png — Fremantle's real street at golden hour: buildings lining both sides down the corridor, footpaths, a crossing, gum trees, streetlamps, and a genuinely busy crowd (the density widening, visible). 126 draws / 167k tris, HUD hidden. Posed by projecting the densest spatial shop cluster (10 shops within 60 m) onto its front edge and standing on the road — per-edge shop counts are ~1 on a real graph, so "densest edge" is the wrong pose heuristic for real towns.


22. Round 22 — v4.0: THE EPOCH CLOSE. F: the re-measure, the selector, the tour, the tag.

The blocking gate — CLOSED (#1b)

F's R21 re-measure made the tri diet blocking ("release rounds don't ship known breaches"). B diagnosed layer-by-layer, the driver traced A → E (A's own commit said so: "B's numbers say the fix is E's"), and E fixed it. F re-measured:

venue_night R21 R22
katoomba_real 221,935 (+11%) 108,365 (51%)
bendigo_real 201k (+0.5%) 124,682
fremantle_real 184k 88,397

Worst 124,682 — the 200k stress edge is closed. Selfcheck 161,300/161,300 (A's 36 goldens + the 23-town pack). The measure-first loop worked end to end: F measured, B diagnosed, E owned, F re-measured.

DBG.enterShop(selector) (#3 — Lane G's ask)

Grew from id-only to a selector: id · registry type · exact name · name substring, resolved most-specific first so an id-shaped string can't be shadowed. DBG.enterShop('record') / ('The Exchange Hotel') / (116) — cross-repo QA one-liners on any of 23 towns without an id lookup.

The release tour (#4) — docs/shots/release_v4/

Five frames: the Fremantle golden-hour hero · the Fitzroy mecca · the Toowoomba thin tail · the synthetic control · the ?classic=1 covenant. Every real-town frame poses through F's R21 cluster primitive (project the densest SPATIAL cluster onto its front edge, stand on the road); B's R22 bookmark fix anchors on the same retail heart, so tour and bookmarks agree by construction. HUD hidden throughout.

The primitive earned itself twice. First cut of the tour posed via DBG.shot('street_noon') and the mecca came out as bare bitumen — the same trap as R21. Re-posed through the cluster primitive, Fitzroy reads (7 shops/60 m retail heart, trees, benches, crowd). Per-edge is always the wrong heuristic on a real graph.

⚠ The honest thin-tail finding (filed for v4.x)

The brief asked for a thin-tail frame as "proof the floor holds." It doesn't, and the frame says so: Toowoomba reads as an empty road, not a town. The cause is a RATIO, not a count —

town shops / edges shops per edge
fremantle (hero) 79 / 1,056 0.075
fitzroy (mecca) 139 / 2,408 0.058
toowoomba (thin tail) 12 / 1,459 0.008 — 79× sparser

Toowoomba is a real city whose secondhand cache is 12 shops, spread across its full road graph. The floor isn't "≥N shops" — it's shops-per-street-metre. v4.x options: clip the graph to the retail core for thin caches, or set a ratio floor in the pack. The frame ships labelled honestly rather than swapped for a flattering town — same discipline as the R21 re-measure that got worse and said so.