diff --git a/F-progress.md b/F-progress.md
index 7b46e85..d98eb55 100644
--- a/F-progress.md
+++ b/F-progress.md
@@ -4,6 +4,37 @@
---
+## Round 9 (the v2.0 ship round) — DONE ✅ (v2.0 tagged)
+
+All lanes landed (C browse points + buy-anywhere, D browser rigs, A CITY_SPEC freeze + closing-time
+ruling, E VRAM audit + half-res book/toy atlases, B tram + tour bookmarks). F wired the one cross-lane
+seam, graduated the harness, shot the tour, soaked, and tagged the epoch.
+
+| task | status |
+|---|---|
+| **F1 — interior presence (C→D→F)** | ✅ `interior_mode` reads `occupancyOf(shop.id)` (closure to `citizens`) and spawns browser rigs at C's `room.browsePoints`, one per patronage occupant, **cap 3**; freed by `exit()`'s `keepers.disposeAll`. Door points carry `shopId`. Verified: enter occupied "Second Time Redfern" → 1 browser == `min(occ,3)`, disposed clean, 0 errors. |
+| **also wired** | ✅ **Tram** (`?tram=1`, `createTram`+update). **Buy-anywhere** (C2): E at a book-spine/toy-box `buyMesh` buys over the wallet (`collapseBuyItem`+toast); preload book+toy packs. Verified: bought a book spine, cash 191→177. |
+| **F2 — smokes to strict** | ✅ buy-v0 / patronage / book-toy / katoomba **graduated warn→STRICT**. New warn smokes: interior-presence, shelf-buy, tram. Fixed the book-stock check to count `isStock OR buyMesh` (C2 tags spines `buyMesh`). `qa.sh --strict` GREEN 5/5, harness 0 fails / 0 warns. |
+| **F3 — tour + soaks** | ✅ `tools/v2_tour.py` → **11 shots** (`docs/shots/v2_tour/`, un-letterboxed) incl. the **rain-verandah money shot** (rain + winmap-lit open video shop + CLOSED dark neighbours at night) + browsers + dig-real + katoomba + tram_stop + contact sheet. **Both soaks PASS** (full-default + all-on: 37 chunks, 20 shops, heap stable, 0 errors, leak-free). |
+| **F4 — docs freeze + tag v2.0** | ✅ Flags table finalized (below); **`v2.0` tagged** on a green tree. |
+| **Marshal** | C1 (the round's SPOF) landed early; all lanes committed; no slips. |
+
+### v2.0 flags — FINAL
+| flag | owner | status | default |
+|---|---|---|---|
+| roster | D | **graduated → DEFAULT-ON** (R7 flip); `?roster=v1` escape hatch | on |
+| patronage | D | default-on (`?patronage=0` off) | on |
+| `?dig=1` | C | opt-in (crate riffle) | off |
+| `?winmap=1` | B | opt-in (parallax window glass) | off |
+| `?stock=real` | C/E | opt-in (real GODVERSE covers/spines/boxes + buy-anywhere) | off |
+| `?weather=1` | B | opt-in (seeded rain/overcast/clear; `?weather=rain` forces) | off |
+| `?plansrc=osm[&town=]` | A | opt-in (real-OSM towns: melbourne, katoomba) | off (synthetic) |
+| `?tram=1` | B | opt-in (spine tram loop) | off |
+| `?noassets=1` | — | opt-in (primitive-only, zero-network) | off |
+Determinism goldens frozen: synthetic `0x3fa36874` · osm/melbourne `0x34cfdec0` · osm/katoomba `0x0f652510`.
+
+---
+
## Round 8 (catch-up + the town comes alive → v2.0-rc) — DONE ✅
All lanes landed (E packs, B weather, A 2nd town, C buy, D patronage). F wired the shell seams,
diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md
index e54190f..7d2595f 100644
--- a/docs/LANES/LANE_F_NOTES.md
+++ b/docs/LANES/LANE_F_NOTES.md
@@ -9,6 +9,12 @@ Status of this file: **living**. Update the ✅/⏳ markers as wiring lands. Sna
readiness lives in [`F-progress.md`](../../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`](../../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
diff --git a/docs/shots/v2_contact.html b/docs/shots/v2_contact.html
new file mode 100644
index 0000000..d638d7d
--- /dev/null
+++ b/docs/shots/v2_contact.html
@@ -0,0 +1 @@
+
PROCITY v2.0 tourPROCITY v2.0 — 11 shots
rain_verandah
night_crowd
tram_stop
rain_street
patronage_door
shopfront_detail
street_noon
crossroads_busy
katoomba_main
dig_real
browsers
\ No newline at end of file
diff --git a/docs/shots/v2_tour/browsers.png b/docs/shots/v2_tour/browsers.png
new file mode 100644
index 0000000..68f8ac6
Binary files /dev/null and b/docs/shots/v2_tour/browsers.png differ
diff --git a/docs/shots/v2_tour/crossroads_busy.png b/docs/shots/v2_tour/crossroads_busy.png
new file mode 100644
index 0000000..43cb8c3
Binary files /dev/null and b/docs/shots/v2_tour/crossroads_busy.png differ
diff --git a/docs/shots/v2_tour/dig_real.png b/docs/shots/v2_tour/dig_real.png
new file mode 100644
index 0000000..019a434
Binary files /dev/null and b/docs/shots/v2_tour/dig_real.png differ
diff --git a/docs/shots/v2_tour/katoomba_main.png b/docs/shots/v2_tour/katoomba_main.png
new file mode 100644
index 0000000..c67a90c
Binary files /dev/null and b/docs/shots/v2_tour/katoomba_main.png differ
diff --git a/docs/shots/v2_tour/night_crowd.png b/docs/shots/v2_tour/night_crowd.png
new file mode 100644
index 0000000..eed74cf
Binary files /dev/null and b/docs/shots/v2_tour/night_crowd.png differ
diff --git a/docs/shots/v2_tour/patronage_door.png b/docs/shots/v2_tour/patronage_door.png
new file mode 100644
index 0000000..160a910
Binary files /dev/null and b/docs/shots/v2_tour/patronage_door.png differ
diff --git a/docs/shots/v2_tour/rain_street.png b/docs/shots/v2_tour/rain_street.png
new file mode 100644
index 0000000..17c092f
Binary files /dev/null and b/docs/shots/v2_tour/rain_street.png differ
diff --git a/docs/shots/v2_tour/rain_verandah.png b/docs/shots/v2_tour/rain_verandah.png
new file mode 100644
index 0000000..614e904
Binary files /dev/null and b/docs/shots/v2_tour/rain_verandah.png differ
diff --git a/docs/shots/v2_tour/shopfront_detail.png b/docs/shots/v2_tour/shopfront_detail.png
new file mode 100644
index 0000000..d5829ff
Binary files /dev/null and b/docs/shots/v2_tour/shopfront_detail.png differ
diff --git a/docs/shots/v2_tour/street_noon.png b/docs/shots/v2_tour/street_noon.png
new file mode 100644
index 0000000..e7bd285
Binary files /dev/null and b/docs/shots/v2_tour/street_noon.png differ
diff --git a/docs/shots/v2_tour/tram_stop.png b/docs/shots/v2_tour/tram_stop.png
new file mode 100644
index 0000000..93b83c4
Binary files /dev/null and b/docs/shots/v2_tour/tram_stop.png differ
diff --git a/tools/soak.py b/tools/soak.py
index 8399406..c91b8b3 100644
--- a/tools/soak.py
+++ b/tools/soak.py
@@ -42,7 +42,9 @@ def main():
seed = flag('--seed')
minutes = float(flag('--minutes', 10))
noassets = '--noassets' in sys.argv
- qs = '?dbg=1' + (f'&seed={seed}' if seed else '') + ('&noassets=1' if noassets else '')
+ extra = flag('--flags') # arbitrary query flags, e.g. --flags 'winmap=1&dig=1&stock=real&weather=rain&tram=1'
+ qs = '?dbg=1' + (f'&seed={seed}' if seed else '') + ('&noassets=1' if noassets else '') \
+ + (f'&{extra}' if isinstance(extra, str) else '')
url = HOST + '/' + qs
fails, warns, chunks_seen, shops_entered, peak_draws, peak_tris = [], [], set(), 0, 0, 0
diff --git a/tools/v2_tour.py b/tools/v2_tour.py
new file mode 100644
index 0000000..8d898c6
--- /dev/null
+++ b/tools/v2_tour.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""PROCITY Lane F — v2.0 tour: 12 beauty shots across flag-groups → docs/shots/v2_tour/ + contact sheet.
+Boots each flag-combo once, shoots its bookmarks (Lane B dbg.js poses) + interior shots. Un-letterboxed
+1280x720 (dbg.js syncSize). Run: cd web && python3 -m http.server 8130 then tools/.venv/bin/python tools/v2_tour.py
+"""
+import sys, pathlib
+from playwright.sync_api import sync_playwright
+
+ROOT = pathlib.Path(__file__).resolve().parent.parent
+OUT = ROOT / 'docs' / 'shots' / 'v2_tour'
+OUT.mkdir(parents=True, exist_ok=True)
+HOST = 'http://127.0.0.1:8130'
+
+# flag-combo -> [street bookmarks] (each dbg.js bookmark sets its own pose + day segment)
+GROUPS = [
+ ('winmap=1&weather=rain&tram=1&stock=real&pop=200',
+ ['rain_verandah', 'night_crowd', 'tram_stop', 'rain_street', 'patronage_door', 'shopfront_detail',
+ 'street_noon', 'crossroads_busy']),
+ ('plansrc=osm&town=katoomba&pop=180', ['katoomba_main']),
+]
+# interior shots: flags, then enter + optional dig
+INTERIORS = [
+ ('dig_real', 'dig=1&stock=real', 'record', True), # enter record shop + open the dig (real covers)
+ ('browsers', 'stock=real&pop=200', 'occupied', False), # enter an occupied shop (browsers present)
+]
+
+def boot(pg, q):
+ pg.goto(f'{HOST}/?dbg=1&{q}')
+ pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
+ pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
+ try:
+ pg.wait_for_function("() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)", timeout=12000)
+ except Exception:
+ pass
+
+def main():
+ shot_names, errs = [], []
+ with sync_playwright() as p:
+ b = p.chromium.launch()
+ pg = b.new_page(viewport={'width': 1280, 'height': 720})
+ pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
+ for flags, marks in GROUPS:
+ boot(pg, flags)
+ for name in marks:
+ try:
+ pg.evaluate("(n) => window.DBG.shot(n)", name)
+ pg.wait_for_timeout(1100)
+ pg.evaluate("(n) => window.DBG.shot(n)", name) # settle bloom/segment
+ pg.wait_for_timeout(300)
+ pg.screenshot(path=str(OUT / f'{name}.png'))
+ shot_names.append(name); print('shot', name)
+ except Exception as e:
+ print('MISS', name, str(e)[:80])
+ for name, flags, kind, do_dig in INTERIORS:
+ boot(pg, flags)
+ ok = pg.evaluate("""(kind) => {
+ const P=window.PROCITY, D=window.DBG; D.setSegment(2);
+ let s=null;
+ if(kind==='occupied'){
+ const plan=P.plan, deg={}; for(const e of plan.streets.edges){deg[e.a]=(deg[e.a]||0)+1;deg[e.b]=(deg[e.b]||0)+1;}
+ let best=null,bd=0; for(const n of plan.streets.nodes){const d=deg[n.id]||0;if(d>bd){bd=d;best=n;}}
+ D.teleport(best.x,best.z,0); P.citizens.setPopulation(200);
+ for(let i=0;i<400;i++) P.citizens.update(0.05);
+ for(const sh of plan.shops){ const o=P.citizens.occupancyOf(sh.id); if(o&&o.count>0){s=sh;break;} }
+ if(!s) s=plan.shops.find(x=>x.type==='opshop'&&P.isOpen(x));
+ } else { s=(P.plan.shops||[]).find(x=>x.type===kind && P.isOpen(x)); }
+ if(!s) return false; D.enterShop(s.id); return true;
+ }""", kind)
+ if not ok:
+ print('MISS', name, '(no shop)'); continue
+ if do_dig:
+ # aim at a bin + open the dig for the real-cover shot
+ pg.evaluate("""() => { const P=window.PROCITY; const bins=[];
+ P.interiorMode.current.group.traverse(o=>{if(o.userData&&o.userData.kind==='bin')bins.push(o)});
+ if(bins.length){ const bp=new P.THREE.Vector3(); bins[0].getWorldPosition(bp);
+ P.camera.position.set(bp.x,1.6,bp.z+1.4); P.camera.lookAt(bp.x,bp.y+0.4,bp.z); P.camera.updateMatrixWorld();
+ window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyE'})); } }""")
+ pg.wait_for_timeout(900)
+ pg.screenshot(path=str(OUT / f'{name}.png'))
+ shot_names.append(name); print('shot', name)
+ b.close()
+
+ # contact sheet
+ cells = ''.join(f'
{n}' for n in shot_names)
+ (OUT.parent / 'v2_contact.html').write_text(
+ 'PROCITY v2.0 tour'
+ 'PROCITY v2.0 — ' + str(len(shot_names)) + ' shots
'
+ '' + cells + '
')
+ print(f'\n{len(shot_names)} shots → docs/shots/v2_tour/ · contact: docs/shots/v2_contact.html')
+ if errs:
+ print(f'⚠ {len(errs)} console error(s); first: {errs[0][:120]}')
+
+if __name__ == '__main__':
+ main()