diff --git a/C-progress.md b/C-progress.md
index 9ebc4a6..c1dbb47 100644
--- a/C-progress.md
+++ b/C-progress.md
@@ -3,7 +3,43 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
-Last updated: 2026-07-15 (round 8) · owner: PROCITY-C · reviewer: Fable
+Last updated: 2026-07-15 (round 9) · owner: PROCITY-C · reviewer: Fable
+
+---
+
+## Update 2026-07-15 (round 9) — ship v2.0: browse points (C→D→F seam) + buy-anywhere
+
+Round-9 §Lane C. The round hangs off C1 (two lanes build on it), so it shipped first as a handshake +
+working code; C2 widens buying beyond the dig.
+
+**C1 — browse points (the round's one cross-lane seam).** `layout.js placeBrowsePoints()` exposes
+`room.browsePoints[]` — 1–3 deterministic floor poses in front of browsable fittings, facing the goods,
+where Lane D stands browser rigs (F wires occupancy → points). Same coord frame + `ry` convention as
+`counter.stand`, so D's fleet clones drop straight in. Pure data — **no meshes, no draws**; the ≤350 law
+is untouched by C1. Guarantees (all asserted headless): walkable (occ-free, not reserved), reachable from
+spawn (the door→counter flood-fill), ≥0.6 m apart + clear of the keeper stand, deterministic per
+`shop.seed`, ≥1 point in every archetype that has a browsable fitting. A rich candidate ring (4 sides × 2
+gaps + diagonals) + a nearest-free-cell fallback covers tight/mismatched rooms. Interface published in
+[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) **before** code (decision #1 half-day handshake) → committed
+first so D/F unblock. Validated: **180 type×archetype×seed combos → 0 empties** (177×3 pts, 3×1 via
+fallback), determinism 0 fails, min-sep 0.7 m, 0 keeper clashes. Test page: cyan browse-post markers on the
+`path` overlay + a HUD count.
+
+**C2 — buy-anywhere (decision #2).** Book spines + toy boxes get pull-and-buy over the **same wallet**;
+records keep the deep dig. New `stockpack.buildBuyableShelf()` builds each real shelf as merged,
+per-item-**addressable** meshes — one mesh per atlas (all covers on a shelf drawn from a single seeded
+atlas → **one draw/shelf**), tagged `noBatch` so `batch.js` leaves them intact. Aiming a spine/box raises a
+pull card (title / author-or-maker / $price / band from the pack index); BUY debits the wallet, adds to
+inventory, and `collapseBuyItem()` zero-areas that item's quad in place (**removed from the shelf**, one
+draw, no geometry churn). Parody stock has no buy mesh → **no card** ("not for sale"). Fail-soft per pack.
+Validated: raycast→card→BUY works end-to-end (item collapses to its centre, cash/inventory update, card
+closes); **buy-soak 6/6 bought, `collapseOk`, leak geo 0 / tex 0**; draws with `?stock=real` **record 41 ·
+book 58 · toy 57** (≤350); full drawSweep unchanged (GLB-off 168 · GLB-on 344, both pass); placement
+determinism 0 fails. Shot: [browse_buy_r9.jpg](docs/shots/laneC/browse_buy_r9.jpg).
+
+**→ D**: consume `room.browsePoints` (contract in LANE_C_NOTES) — stand rigs at the first
+`min(occupancy, 3)` points. **→ F**: two new warn smokes (hooks in LANE_C_NOTES): browse-points present +
+valid, and shelf-buy (aim → card → wallet debit → collapse). C's soak covers keeper+stock+dig+buy together.
---
diff --git a/docs/LANES/LANE_C_NOTES.md b/docs/LANES/LANE_C_NOTES.md
index ecc7036..df47cb0 100644
--- a/docs/LANES/LANE_C_NOTES.md
+++ b/docs/LANES/LANE_C_NOTES.md
@@ -1,9 +1,14 @@
# LANE C — cross-lane notes (PROCITY-C)
-## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 handshake — AGREE BEFORE CODE)
+## → Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 — **LANDED**, code live)
-This is decision #1's contract, published *first* so D and F can build in parallel. It is the seam the
-whole round hangs off. **C owns the points + room fit; D owns occupancy truth; F owns the handoff.**
+**Status: shipped.** `room.browsePoints[]` is live on the room API (commit `5ae635a`, ref
+`lane-c/round9-browse`). The contract below is final — build against it. It is the seam the whole round
+hangs off. **C owns the points + room fit; D owns occupancy truth; F owns the handoff.**
+
+**F smoke (warn):** enter a `?stock=real` book/toy/record shop, assert `room.browsePoints.length >= 1`
+and every point walkable+reachable (or just trust C's asserts and check `.length` + that D's rig count ==
+`min(occupancy,3)`). Test page: the `path` overlay draws each point as a cyan post + facing nose.
### What C exposes on the room API (live once C1 commits)
@@ -52,6 +57,24 @@ don't care (a closed shop simply isn't entered). No blocker on C.
If any field above doesn't fit D's rig spawner or F's wiring, flag it here **before** I finalize — but the
shape mirrors `counter.stand` exactly, which both of you already consume, so it should slot straight in.
+## → Lane F: buy-anywhere (round-9 C2 landed) — shelf pull-and-buy smoke (warn)
+
+Book spines + toy boxes are now buyable over the same wallet (records keep the dig). A real shelf is a
+merged, per-item-addressable mesh: `mesh.userData.buyMesh === true`, `mesh.userData.buyItems = [{item,
+center, vStart}]`, one draw per shelf (drawSweep with `?stock=real`: record 41 · book 58 · toy 57, ≤350).
+Smoke recipe (headless — no raycast needed):
+```js
+const room = buildInterior({ id:'book', type:'book', seed:1990 }, THREE, { stock:'real' });
+let mesh=null; room.group.traverse(o=>{ if(!mesh && o.userData?.buyMesh && o.userData.buyItems.length) mesh=o; });
+assert(mesh); // real book/toy shop has ≥1 buy mesh
+const it = mesh.userData.buyItems[0]; // {item:{title,artist,price,price_band}, center, vStart}
+// buy path: wallet.buy({t:it.item.title, price:it.item.price||20}); collapseBuyItem(mesh, it);
+// assert: geometry.position vert[it.vStart] collapsed to it.center (item removed) · wallet.count()++ · leak-free
+```
+Test page exposes `shelfBuySoak()` (buys one shelf item per book/toy seed, asserts collapse + leak 0/0) and
+`shelfUnderAim()/showShelfCard()/buyShelfOffer()` for the live raycast path (E key: bin→dig, shelf→card).
+Parody stock (no pack) has **no** buy mesh → aiming shows no card ("not for sale"). Fail-soft per pack.
+
## → Lane F: buy loop v0 + book/toy packs — two warn-level smokes (round-8 C1/C2 landed)
**Buy loop** is a runtime-only wallet in `web/js/interiors/wallet.js` — pure, headless-testable, no DOM:
diff --git a/docs/shots/laneC/browse_buy_r9.jpg b/docs/shots/laneC/browse_buy_r9.jpg
new file mode 100644
index 0000000..0bd1ff2
Binary files /dev/null and b/docs/shots/laneC/browse_buy_r9.jpg differ
diff --git a/web/interior_test.html b/web/interior_test.html
index 37d2793..ee30576 100644
--- a/web/interior_test.html
+++ b/web/interior_test.html
@@ -72,6 +72,7 @@ import { PointerLockControls } from 'three/addons/controls/PointerLockControls.j
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPack, makeStockAdapter } from './js/interiors/interiors.js';
import { createDig, binSeed } from './js/interiors/dig.js';
import { createWallet } from './js/interiors/wallet.js';
+import { collapseBuyItem } from './js/interiors/stockpack.js';
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
@@ -420,6 +421,107 @@ async function digSoak(N = 15) {
return { rooms: N, bought, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT };
}
+// ── buy-anywhere (round 9 C2): aim at a real-stock book spine / toy box → pull card → BUY ──
+// Records keep the deep dig; shelves get a light pull-and-buy over the SAME wallet. Reuses the
+// batched-but-addressable buy meshes from stockpack.buildBuyableShelf. Parody stock has no buy mesh
+// → no card ("not for sale"). UI is test-page-local; the reusable pieces are the mesh data + wallet.
+const _buyRay = new THREE.Raycaster();
+const _lp = new THREE.Vector3();
+const shelfPanel = document.createElement('div'); shelfPanel.id = 'shelfBuy';
+const shelfCss = document.createElement('style'); shelfCss.textContent = `
+ #shelfBuy{position:fixed;top:50%;right:26px;transform:translateY(-50%);z-index:72;width:230px;background:rgba(18,15,10,.96);border:1px solid #b58b3a;border-radius:10px;padding:14px 15px;font:12px "Courier New",monospace;color:#e8e2d4;display:none}
+ #shelfBuy h3{margin:0 0 2px;font-size:15px;color:#ffd75e}
+ #shelfBuy .sub{color:#b8b0a0;margin-bottom:8px}
+ #shelfBuy .price{font-size:20px;color:#3dff8b;margin-bottom:10px}
+ #shelfBuy button{width:100%;padding:9px;background:#1b2436;border:1px solid #3d5273;color:#cfe0ff;font:700 13px "Courier New",monospace;border-radius:6px;cursor:pointer}
+ #shelfBuy button:disabled{opacity:.45;cursor:not-allowed}
+ #shelfBuy .x{position:absolute;top:8px;right:11px;color:#8a8272;cursor:pointer}`;
+document.head.appendChild(shelfCss); document.body.appendChild(shelfPanel);
+let shelfOffer = null; // { mesh, item } currently on the card
+
+// Collect buy meshes in the live room, raycast from crosshair, return the nearest unsold item.
+function shelfUnderAim(maxDist = 3.0) {
+ if (!current) return null;
+ const meshes = []; current.group.traverse(o => { if (o.userData && o.userData.buyMesh && o.userData.buyItems.length) meshes.push(o); });
+ if (!meshes.length) return null;
+ _buyRay.setFromCamera({ x: 0, y: 0 }, camera);
+ const hit = _buyRay.intersectObjects(meshes, false)[0];
+ if (!hit || hit.distance > maxDist) return null;
+ const mesh = hit.object;
+ _lp.copy(hit.point); mesh.worldToLocal(_lp); // hit → mesh-local (== item centre space)
+ let best = null, bd = Infinity;
+ for (const it of mesh.userData.buyItems) { const d = (it.center.x - _lp.x) ** 2 + (it.center.y - _lp.y) ** 2 + (it.center.z - _lp.z) ** 2; if (d < bd) { bd = d; best = it; } }
+ return best ? { mesh, item: best } : null;
+}
+const priceOf = (it) => it.price || (it.price_band === 'grail' ? 60 : it.price_band === 'collector' ? 30 : 8);
+function showShelfCard(offer) {
+ shelfOffer = offer; const it = offer.item.item;
+ const price = priceOf(it), afford = wallet.canBuy(price);
+ shelfPanel.style.display = 'block';
+ shelfPanel.innerHTML =
+ `×