diff --git a/D-progress.md b/D-progress.md
new file mode 100644
index 0000000..34dfe91
--- /dev/null
+++ b/D-progress.md
@@ -0,0 +1,138 @@
+# PROCITY-D — progress (Lane D · Citizens)
+
+**Status: complete and verified in-browser.** 2026-07-14, by PROCITY-D (Opus 4.8).
+Standalone test page runs, all Lane-D acceptance gates pass. One cross-cutting finding for Lane E
+(ped poly-count) is flagged below and in `docs/LANES/LANE_D_NOTES.md`.
+
+### Re-verify after session resume (2026-07-14, PROCITY-D)
+Prior session hit the token limit; resumed on a fresh account. Confirmed nothing rotted — all 5
+citizen modules intact on disk with every adversarial-review fix present. Re-ran the gates live in
+the browser on this machine:
+- **perf @ pop 200**: max mixer **0.2 ms**, sim CPU **0.148 ms/frame**, near capped at **24** ✓
+- **determinism**: `✓ 200 identities match seed 20261990` (live vs two fresh recomputes) ✓
+- **no T-pose / one shared walk clip / keeper at counter** ✓
+- **`?noassets`**: placeholder town, 0.00 ms mixer, 2.4k tris, crash-free ✓
+- **Integration contract PROVEN**: built Lane A's real `generatePlan(1234)` → "North Cressy Reach"
+ (27 nodes / 22 edges) and ran `new CitizenSim({ …, graph: plan.streets })` **with zero adapter** —
+ 0 dangling edges, 0 non-finite citizen coords, no errors. `plan.streets` is exactly the `{nodes,
+ edges}` shape the sim consumes. Lane F §3.3 is a one-line construction (see below).
+
+---
+
+## What I built (owns `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*`)
+
+| file | lines-ish | what |
+|---|---|---|
+| `web/js/citizens/rigs.js` | ~185 | the ported crown-jewel rig stack — fleet loader, `_canon`/`canonRig`/`_rotOnly`, `buildFigure` (head-bone height-normalise + feet plant), `spawnRig`, `makeActor` (walk↔idle crossfade for the near tier), `pickRig` |
+| `web/js/citizens/placeholder.js` | ~150 | seeded low-poly box humanoid (the POLY lock) — walks/idles, so the town is populated from frame one and in `?noassets` mode |
+| `web/js/citizens/impostor.js` | ~210 | mid-tier LOD: bakes each ped to a 4-yaw sprite atlas on an offscreen RT, one InstancedMesh of camera-facing billboards → **1 draw call** for the whole mid crowd |
+| `web/js/citizens/sim.js` | ~330 | the simulation — deterministic roster, footpath lanes off the street graph, near/mid/far tiers with hysteresis + hard near-cap, a reusable rig pool, staggered mixer budget, time-of-day density |
+| `web/js/citizens/keepers.js` | ~95 | one keeper per shop at the counter, idle clip + greet turn toward the player |
+| `web/citizens_test.html` | ~310 | standalone harness (fixture 3×3 street graph, no Lane A/B imports) |
+| `web/models/peds/*` | 21 GLBs, 37 MB | 19 rigged peds + walk + idle, **byte-identical** copies from 90sDJsim (SHA-verified) |
+| `docs/LANES/LANE_D_NOTES.md` | — | measured budgets + clip wishlist |
+| `docs/shots/laneD/*.jpg` | 5 shots | near / mid / main-street / tier-debug / noassets |
+
+Everything is plain ES modules, three from `web/vendor/`, all randomness via `core/prng.js`
+(`Math.random()` used nowhere in identity/placement). Loaders come from `core/loaders.js`.
+
+## Acceptance — every gate met
+
+- ✅ **200-citizen fixture holds 60fps** — full frame CPU ~2.6 ms (≈380 fps of headroom on the M3
+ Ultra; GPU trivial). Preview's throttled rAF shows 47–84 fps, but timed frames prove the margin.
+- ✅ **Mixer update < 2 ms/frame** — measured **0.4 ms max** at 200 citizens (staggered: nearest 8
+ every frame, ≤4 more per frame).
+- ✅ **No T-pose ever** — fixed a real bug: freshly-promoted rigs were showing bind-pose for a frame;
+ actors now evaluate their mixer at acquire time. Verified across the fleet in `01_near_tier.jpg`.
+- ✅ **No giants/ants** — head-bone height normalisation verified across all 19 peds (1.55–1.95 m).
+- ✅ **One shared walk clip animates every ped** — the `mixorig` canonicalisation binds a single
+ `walk.glb` to all 19; confirmed in-scene.
+- ✅ **Same seed → same crowd** — determinism button asserts the live roster equals a fresh seed
+ recompute; holds while citizens walk (identity ≠ live position).
+- ✅ **Screenshots** into `docs/shots/laneD/` (near, mid, busy main street, + tier-debug & noassets).
+- ✅ **`docs/LANES/LANE_D_NOTES.md`** — measured budgets + the mixamo-fetch clip wishlist.
+
+Plus the LOD design points from the lane brief: near<25m rig+mixer walking footpath lanes & turning
+at nodes on a seeded choice with loiter stops; mid 25–70m instanced impostors baked from the fleet
+(auto-in-sync); far culled; hysteresis so nobody pops; time-of-day density; `?noassets` placeholder
+town; keepers at counters. All exercised in the test page.
+
+## Decisions worth your eyes
+
+1. **Rig pool, not per-spawn clone.** Near actors are pooled per ped-type and reused across citizens
+ (SkeletonUtils.clone + mixer are the expensive bits) — bounded at 30 instances, cap 24 active,
+ ≤3 new clones/frame to avoid hitches. Height is applied as an outer-group scale so pooled rigs are
+ height-agnostic and reusable.
+2. **Impostor colour management.** The mid atlas is baked *linear* (no tone-map) and the billboard
+ shader applies ACES + sRGB itself, so mid impostors match the ACES-tonemapped near rigs exactly —
+ fixed an early bug where they rendered ~2× too dark.
+3. **Figure origin at the feet.** Both rigs and placeholders present a `fig` Group whose origin is the
+ ground point, so the sim moves/rotates walkers without re-planting them each frame.
+
+## ⚠️ One thing I need a ruling on (Lane E territory)
+
+**The inherited 90sDJsim peds are ~49.7k tris each.** 24 near rigs ≈ 1.2M tris — far past the
+CITY_SPEC "≤200k tris typical view". It runs fine on Apple Silicon today, but it leaves no tri budget
+for Lane B's buildings. The rig stack is indifferent to poly count — this is an **asset-pipeline
+decimation job (Lane E)**: at ~6k tris/ped the near cap fits budget. Until then `NEAR_MAX` in `sim.js`
+is the throttle. Same story, smaller, for draw calls (each ped is ~6 sub-meshes → merge on load).
+Details + numbers in the NOTES. Not blocking Lane D; flagging so it's on the integration radar.
+
+## Adversarial review pass (6 real bugs found + fixed)
+
+After the build passed in-browser, I ran a 4-dimension multi-agent code review with an adversarial
+verify stage (14 agents). It confirmed **6 genuine defects** (4 others refuted); all 6 are fixed and
+re-verified:
+
+1. **[HIGH] Non-deterministic fleet order** — `fleet.normal/all` were filled in GLB load-completion
+ order, so `pickRig`'s index (→ every identity) could differ across reloads/machines, silently
+ breaking "same seed → same crowd". Fixed: fill fixed slots by `PED_NAMES` index, compact after.
+2. **[HIGH] Shared-resource disposal** — `_disposeInner` disposed geometry/materials that
+ SkeletonUtils.clone *shares* across all clones + the base rig; pool eviction would tear down GPU
+ buffers still in use by sibling citizens. Fixed: dispose only the clone's own `Skeleton` (its
+ bone texture) — never the shared geo/mats. Stress-tested (24 teleports forcing eviction churn) →
+ 0 broken meshes.
+3. **[HIGH] Impostor under-exposure** — the in-shader ACES omitted three's `/0.6` exposure
+ normalisation, making mid billboards ~1.67× darker than the identical near rig at the swap
+ boundary. Fixed: `color *= uExposure / 0.6`.
+4. **[HIGH] Renderer viewport not restored** — the atlas bake set per-cell viewport/scissor but only
+ restored scissor-*test*; the async re-bake left the viewport on a 128px cell → the main scene
+ could render into a corner. Fixed: save/restore viewport + scissor rect.
+5. **[MED] Billboard ignored `modelMatrix`** — impostors would desync from rigs if the citizens group
+ carries a transform (Lane B chunk offsets). Fixed: fold `modelMatrix` into the billboard base.
+6. **[LOW] Frozen tone-map exposure** — added `ImpostorLayer.setExposure()` for day/night.
+
+Also caught during re-verification: three injects its own `RRTAndODTFit`/ACES functions into any
+tone-mapped `ShaderMaterial`, colliding with mine. Fixed with `toneMapped:false` (I tone-map
+in-shader) + `imp`-prefixed helper names. Console is clean; determinism still passes; 60fps holds.
+
+## For Lane F integration (hooks are ready)
+
+- **Street graph — verified, no adapter.** `plan.streets` (Lane A) is byte-shape-compatible with the
+ `graph` the sim wants. In the street branch of the shell:
+ ```js
+ const sim = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets, fleet });
+ // per frame (street mode): sim.update(dt); // LOD is by sim.camera.position — pass the render camera
+ ```
+ Note `update(dt)` takes **only dt** (it reads `this.camera.position` for LOD, which is what you want —
+ LOD by what the viewer sees). Lane F §3.3's `sim.update(dt, player.position)` sig is loose; the
+ 1-arg form is correct as long as the constructed `camera` is the render camera.
+- **Density on big towns**: a flat `setPopulation(N)` spreads citizens uniformly across *all* edges, so
+ on a large plan most sit far-culled from any one viewpoint (correct + cheap, but streets read empty
+ if N is small). For v1 pick N to suit town size; the durable fix is chunk-streamed rosters (below).
+- Roster identity `identityOf(citySeed, edgeCount, id)` is chunk-ready — key by `chunkKey+i` and
+ stream rosters with Lane B's chunk build/dispose.
+- Keepers pull `x,z,ry` from Lane C's counter `places`; `sim.setTimeOfDay` from the shell day segment;
+ `sim.setPaused` from `visibilitychange`; **Lane B must set `scene.environment`** (PBR peds go dark
+ without it — already wired to be read from the scene).
+
+## Try it
+
+```
+cd web && python3 -m http.server 8130
+# open http://localhost:8130/citizens_test.html (full fleet)
+# open http://localhost:8130/citizens_test.html?noassets=1 (placeholder town)
+# open http://localhost:8130/citizens_test.html?seed=1234 (any seed)
+```
+Sliders: population 0–200, time-of-day. Buttons: tier colours (green=near, yellow=mid, pink=far),
+determinism check. Drag=look, wheel=zoom, WASD=move focus.
diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md
new file mode 100644
index 0000000..da7d0c3
--- /dev/null
+++ b/docs/LANES/LANE_D_NOTES.md
@@ -0,0 +1,124 @@
+# LANE D — NOTES (measured budgets + clip wishlist)
+
+*Written by PROCITY-D, 2026-07-14. Standalone verification via `web/citizens_test.html`.
+Reference stack ported from `90sDJsim/web/world/index.html` ~405–520 (loadRig/spawnRig/_canon/
+_rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.*
+
+## What shipped
+
+| file | role |
+|---|---|
+| `web/js/citizens/rigs.js` | ported rig stack: fleet loader, `_canon`/`canonRig`/`_rotOnly`, `buildFigure` (head-bone height-normalise + feet plant), `spawnRig` (single clip), `makeActor` (walk↔idle crossfade), `pickRig` |
+| `web/js/citizens/placeholder.js` | seeded low-poly box humanoid (POLY lock) — walks/idles, planted at y=0, hot-swaps to a rig |
+| `web/js/citizens/impostor.js` | 4-yaw sprite-atlas baker + instanced billboard layer (mid tier, 1 draw call) |
+| `web/js/citizens/sim.js` | deterministic roster, footpath lanes, near/mid/far LOD, rig pool, staggered mixer budget, time-of-day density |
+| `web/js/citizens/keepers.js` | one keeper per shop at the counter slot, idle + greet head/body-turn |
+| `web/citizens_test.html` | standalone harness: fixture 3×3 street graph, sliders, tier debug, determinism check |
+| `web/models/peds/*` | 19 rigged GLBs + `walk.glb` + `idle.glb`, **byte-identical** copies from 90sDJsim (checksummed) |
+
+## The fleet (copied, never edited)
+
+19 rigged peds (17 normal + 2 comical) + 2 clip-only GLBs, ~37 MB total, copied from
+`johnking@100.91.239.7:~/Documents/90sDJsim/web/world/models/peds/` (also present locally at
+`~/Documents/90sDJsim/…`). SHA-verified byte-identical (house law: canonical source stays upstream).
+
+- Skeletons canonicalise cleanly: `mixamorig1…`→`mixamorig…`, so **one shared walk clip drives all 19**
+ and one shared idle clip too. Verified in-scene — a single `walk.glb` animates every ped.
+- `walk.glb`/`idle.glb` position tracks + `Hips.quaternion` are stripped (`_rotOnly`) — without this
+ the different-scale root track inflates peds to giants. Confirmed: no giants/ants across all 19.
+
+## Measured budgets (M3 Ultra, `citizens_test.html`)
+
+Preview note: the in-app browser reports `visibilityState:'hidden'`, which throttles `requestAnimationFrame`,
+so the on-screen fps counter reads low (47–84). True cost was measured by timing 140 manual
+`update()+render()` frames.
+
+**200 citizens, midday (the acceptance fixture):**
+
+| metric | measured | CITY_SPEC / Lane-D budget | verdict |
+|---|---|---|---|
+| near (rigged) actives | 24 (hard cap) | ≤ 24 | ✅ |
+| mid (impostor) | ~122 | — | — |
+| far (culled) | ~54 | — | — |
+| **mixer update / frame** | **0.4 ms max, ~0.2 ms avg** | < 2 ms | ✅ comfortably |
+| sim logic / frame | 0.24 ms avg | 4 ms build budget | ✅ |
+| full frame CPU (update+render) | ~2.6 ms → ~380 fps headroom | 60 fps (16.6 ms) | ✅ huge margin |
+| mid tier draw calls | **1** (69 instances, 138 tris) | 1 per atlas | ✅ |
+| impostor atlas texture | 2048×640 (16×5 cells, 4 yaws × 19 peds) | ≤ 2048, < 512 MB total | ✅ (~5 MB) |
+| determinism | live roster ≡ seed recompute, stable while walking | same seed → same crowd | ✅ |
+
+**Time-of-day density** (200 base): 00:00 → 12 active, 06:00 → 70, 12:00 → 200, 15:00 → 150,
+21:00 → 36. Curve in `sim.js DAY_CURVE`.
+
+## ⚠️ Findings for Lane E / Lane F (need a decision)
+
+1. **The inherited peds are ~49.7k tris each — too heavy for the CITY_SPEC 200k-tri "typical view".**
+ 24 near rigs ≈ **1.2M tris**; a realistic 16-rig street view already measured **1.02M tris**.
+ Framerate holds on M-series (GPU eats it), but this leaves *nothing* for Lane B buildings.
+ **Recommendation:** decimate the ped fleet to ~5–8k tris in the asset pipeline (Lane E) — at 6k/ped
+ the near cap costs ~144k tris, back within budget. The rig stack is indifferent to poly count;
+ this is purely an asset job. Until then, `NEAR_MAX` (sim.js) is the throttle.
+
+2. **Draw calls scale with near rigs (~6 calls/ped — each GLB has several sub-meshes/materials).**
+ 16 rigs ≈ 144 calls; 24 rigs ≈ ~190. Under the 300-call street budget *today*, but tight once
+ Lane B adds shells. **Recommendation:** merge each ped's sub-meshes by material on load
+ (`BufferGeometryUtils.mergeGeometries`) → ~1–2 calls/ped. Cheap win, deferred (not blocking).
+
+3. **Peds are metallic-PBR (`metalness≈0.5`) and render black without a `scene.environment`.**
+ The test scene builds a neutral PMREM; the impostor bake is fed the same env. **Lane B's shell must
+ set `scene.environment`** (a sky PMREM) or every citizen — near and mid — goes dark. This is wired:
+ `CitizenSim` reads `scene.environment` and passes it to the impostor baker.
+
+4. **Impostor material is `toneMapped:false` and does ACES + sRGB itself** (matching
+ `ACESFilmicToneMapping`, exposure `/0.6`). If Lane B changes the tone-mapping curve or animates
+ `renderer.toneMappingExposure`, call `sim.impostor.setExposure(e)` (and, for a non-ACES curve,
+ the in-shader ACES in `impostor.js IMP_FRAG` would need to match). Mid billboards otherwise drift
+ from the near rigs at the LOD seam.
+
+*Hardened by a 4-dimension adversarial code review (see D-progress.md): 6 confirmed defects fixed —
+non-deterministic fleet order, shared-geometry disposal, impostor under-exposure, unrestored
+viewport, missing modelMatrix, frozen exposure.*
+
+## Integration hooks for Lane F
+
+- `new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet })` — `graph = { nodes:[{id,x,z}],
+ edges:[{id,a,b,width,kind}] }`. Swap the fixture graph for Lane A's `CityPlan.streets`. Footpath
+ lanes are derived from edge `width` + a 0.9 m margin; pedestrians keep to the right of travel.
+- `sim.setPopulation(n)` (slider / density) · `sim.setTimeOfDay(t01)` (drive from the shell's day
+ segment) · `sim.setPaused(bool)` (wire to `visibilitychange`) · `sim.update(dt)` each frame.
+- **Chunk streaming (TODO for F):** roster is currently whole-graph. The identity fn `identityOf(citySeed,
+ edgeCount, id)` is chunk-ready — key it by `chunkKey+i` per CITY_SPEC and spawn/despawn rosters with
+ Lane B's chunk build/dispose. The LOD + pool machinery is already per-frame and chunk-agnostic.
+- **Keepers:** `keepers.spawn(roomGroup, { x, z, ry, shopId, type })` — feed `x,z,ry` from Lane C's
+ interior counter `places`; call on interior build, `keepers.remove(handle)` on dispose.
+ `keepers.update(dt, playerPos)` each interior frame.
+- `?noassets=1` verified: full placeholder town, mixers=0, zero crashes.
+
+## Clip wishlist (for the mixamo-fetch run — CHECK THE 34 EXISTING CLIPS FIRST)
+
+Only `walk` + `idle` are wired today (they ship with the ped fleet). CITY_SPEC says
+`~/Documents/mixamo-fetch/out/` already holds **34 clips (sit, lean, look-around, phone, …)** — these
+bind directly through the canonical `mixamorig` skeleton exactly like walk/idle, so wiring them is a
+data task, not new rig work. **Verify each exists before requesting; never re-download.**
+
+Wanted for richer loiter/keeper behaviour, in priority order:
+1. `sit` — bench-sit at benches/verandah steps (sim loiter already has the hook; needs the clip).
+2. `lean` — loiter against a shopfront (window-shopping stops).
+3. `look-around` / `idle-look` — window-shopping variety + a better keeper greet than the body-turn.
+4. `talk` / `gesture` — pairs chatting on the footpath (spawn in seeded 2-groups).
+5. `carry` / laden walk — shoppers with bags after a purchase (content-phase tie-in).
+6. `browse` / `reach` — keeper stocking shelves; customer reaching a shelf inside interiors.
+
+Bespoke (won't be on Mixamo, hand-author later): crate-riffle at market stalls, till-operation.
+
+## Known limitations (honest)
+
+- Near↔mid swap is a hard LOD switch at ~25 m (hysteresis 24/27 m). At that range a 128px impostor
+ and the full rig subtend near-identical screen size, so it reads as seamless — but it is a switch,
+ not a cross-fade (rig materials are shared across SkeletonUtils clones, so per-instance opacity fade
+ isn't free). If a pop is ever visible after buildings land, the fix is a short dither/fade band.
+- In `?noassets` mode, mid impostors use 8 generic placeholder variants while near placeholders are
+ per-citizen coloured — so a citizen's colour can shift slightly crossing 25 m. Fallback-only; with
+ the real fleet, near and mid are the same baked ped.
+- Loiter *timing* is dt-driven (cosmetic), so exact positions at time T aren't reproducible across
+ runs; **identity** (who, which ped, height, speed, spawn beat) is fully seeded and asserted.
diff --git a/docs/shots/laneD/.gitkeep b/docs/shots/laneD/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/shots/laneD/01_near_tier.jpg b/docs/shots/laneD/01_near_tier.jpg
new file mode 100644
index 0000000..f9b892a
Binary files /dev/null and b/docs/shots/laneD/01_near_tier.jpg differ
diff --git a/docs/shots/laneD/02_mid_tier.jpg b/docs/shots/laneD/02_mid_tier.jpg
new file mode 100644
index 0000000..4be1290
Binary files /dev/null and b/docs/shots/laneD/02_mid_tier.jpg differ
diff --git a/docs/shots/laneD/03_main_street_noon.jpg b/docs/shots/laneD/03_main_street_noon.jpg
new file mode 100644
index 0000000..b52b398
Binary files /dev/null and b/docs/shots/laneD/03_main_street_noon.jpg differ
diff --git a/docs/shots/laneD/04_tiers_debug.jpg b/docs/shots/laneD/04_tiers_debug.jpg
new file mode 100644
index 0000000..6567932
Binary files /dev/null and b/docs/shots/laneD/04_tiers_debug.jpg differ
diff --git a/docs/shots/laneD/05_noassets_placeholders.jpg b/docs/shots/laneD/05_noassets_placeholders.jpg
new file mode 100644
index 0000000..e3cdad6
Binary files /dev/null and b/docs/shots/laneD/05_noassets_placeholders.jpg differ
diff --git a/web/citizens_test.html b/web/citizens_test.html
new file mode 100644
index 0000000..95208d4
--- /dev/null
+++ b/web/citizens_test.html
@@ -0,0 +1,304 @@
+
+
+