diff --git a/E-progress.md b/E-progress.md index c88017a..b479624 100644 --- a/E-progress.md +++ b/E-progress.md @@ -7,7 +7,88 @@ > lanes need from this one, newest first) and **[`pipeline/AUDIT.md`](pipeline/AUDIT.md)** > (provenance, licence zone, measurements and method, per round). Recorded in R39 rather than > quietly resurrected: a progress file whose last entry is 27 rounds old is a trap for the next -> reader, and the honest fix is a signpost, not a backfill. +> reader, and the honest fix is a signpost, not a backfill. *(One exception below: the R40 tasking +> explicitly asked for its control-arm transcripts in THIS file, so §40 lives here AND is +> cross-referenced from the notes.)* + +## Round 40 (§40.1+40.4) · 2026-08-04 — *(entry here by explicit R40 tasking; cross-lane story in `docs/LANES/LANE_E_NOTES.md` §40)* + +### §40.1 — transmission gate blind spot CLOSED, both control arms demonstrated + +`pipeline/validate_manifest.py` `check_transmission()` now covers every path the game can load a +GLB from: `pipeline/_normalized/` (as before), **`web/assets/models/`** (the `?localdepot=1` served +mirror — never scanned before), **the depot copy** for any manifest GLB with no local file in either +dir (ranged GET of the GLB JSON chunk; 206-aware, slices a 200 — the tailnet depot returns 200-full), +and a **sweep of every non-manifest `.glb`** in both dirs (ped rigs, dance clips — `rigs.js` loads +them by literal path). A depot fetch failure is a loud WARN (`depot copy UNSCANNED`), never a skip. + +**Fixture:** `procity_fit_box_crate_01.glb` with `transmissionFactor: 1` injected into material[0] +(the exact defect `strip_transmission.py` removes; injector kept in the session scratchpad, BIN +untouched). Clean sha1 `6c327d8624bc90203275a341128abb546f5d69e0` (staging = served, verified after +cleanup). **Depot arm used a LOCAL MOCK depot (`python3 -m http.server` serving `a/`) — no +fixture ever touched the real depot namespace.** + +**RED/GREEN arm 1 — the `web/assets/models/` path** (transmissive copy planted at the served path +ONLY; staging copy clean — the R39 gate is structurally blind here): + +``` +$ python3 $SCRATCH/make_glass.py pipeline/_normalized/procity_fit_box_crate_01.glb \ + web/assets/models/procity_fit_box_crate_01.glb +$ GOD3_DEPOT=http://100.94.195.115:8788 python3 pipeline/.validate_r39_ab.py # R39 copy of the gate +OK — 0 errors, 0 warning(s) # old rc=0 ← the blind spot, live +$ GOD3_DEPOT=http://100.94.195.115:8788 python3 pipeline/validate_manifest.py # R40 gate, same tree + ERR procity_fit_box_crate_01.glb (assets/models copy): material[0] 'Material.001' has + transmissionFactor 1 — three.js will run a transmission PRE-PASS ... # new rc=1 ← RED +$ cp $SCRATCH/hold/procity_fit_box_crate_01.glb web/assets/models/ # remove fixture +$ GOD3_DEPOT=http://100.94.195.115:8788 python3 pipeline/validate_manifest.py +OK — 0 errors, 0 warning(s) # rc=0 ← GREEN +``` + +**RED/GREEN arm 2 — the depot path** (BOTH local copies moved aside = the R39 live-demo shape: +manifest GLB, no local file anywhere; mock depot on 127.0.0.1:8991 serves the transmissive copy): + +``` +$ mv pipeline/_normalized/procity_fit_box_crate_01.glb $SCRATCH/hold/norm_... +$ mv web/assets/models/procity_fit_box_crate_01.glb $SCRATCH/hold/models_... +$ GOD3_DEPOT=http://127.0.0.1:8991 python3 pipeline/validate_manifest.py + ERR procity_fit_box_crate_01.glb (depot copy): material[0] 'Material.001' has + transmissionFactor 1 — ... # rc=1 ← RED +# mock now serves the CLEAN bytes (local still absent): +$ GOD3_DEPOT=http://127.0.0.1:8991 python3 pipeline/validate_manifest.py +OK — 0 errors, 38 warning(s) # rc=0 ← GREEN (mock) +# and the REAL tailnet depot, local still absent — the production fetch path, scanning the R39-fixed copy: +$ GOD3_DEPOT=http://100.94.195.115:8788 python3 pipeline/validate_manifest.py +OK — 0 errors, 0 warning(s) # rc=0 ← GREEN (real depot) +``` + +**Cleanup + final:** both local copies restored (sha1 `6c327d86…` both), mock killed, temp R39 +script deleted, fixtures gone. Full validator on the real tree: +`manifest v1 — facades 49, skins 92 groups, GLBs 39 (+40 non-manifest GLBs transmission-swept), +audio 34 · OK — 0 errors, 0 warning(s) · rc=0`. + +### §40.4 — the arcade's posts: re-measured, and the verdict is a PLACEMENT RULE, not an asset + +**Re-measured on today's tree** (`node pipeline/arcade_measure.mjs` + `buildings.js` read at the +cited lines — all R39 numbers stand): one edge `kind:'arcade'` **42.00 m × 5 m**, lot faces ±2.50 m +⇒ **5.00 m lane**; `AWNING_DEPTH 2.2` (`buildings.js:21`) at `AWNING_Y 2.95` (`:22`) ⇒ slabs reach +±0.30 m ⇒ **0.60 m open slot** at the centreline; posts (`:641-646`) at `d/2 + 2.2 − 0.2` = 2.00 m +from each lot face = **±0.50 m from the centreline — two rows 1.00 m apart**, 0.12 × 0.12 × 2.95 m, +**34 posts** at seed 20261990 (2 × 17 shop lots), colliderless (`:649`). + +**The kit (posts + roof designed together): the roof SPANS (depth 2.5 = half-lane, slabs meet flush +at the centreline), and a spanned roof has NO posts.** That is a change in `web/js/world/buildings.js` ++ one line of district plumbing in `web/js/world/chunks.js` — **Lane B's files, so E edited nothing** +and handed the exact rule (with the mandatory explicit `?classic=1` gate — the synthetic IS the +classic boot) to B in `LANE_E_NOTES.md` §40 with file:line. + +**Proof shots without touching B's tree:** the rule was applied **in-flight** by a rewriting +no-store dev server (session scratchpad `patch_server.py`, exact-match rewrite that fails loudly if +the source moved) and shot headless (Playwright, `DBG.teleport(21.5, 66.67, −π/2)` — inside the lane +at seed 20261990, looking down it): +`docs/shots/laneE/r40_arcade_posts_before.png` (post rows + open slot) · +`r40_arcade_posts_after.png` (clear lane, continuous roof) · `r40_arcade_posts_pair.png`. +**HUD draws 124 → 124 (+0), tris 44k → 44k, chunks 25 → 25.** Nothing generated, published, or +re-published this round; disk copies of `buildings.js`/`chunks.js` byte-identical throughout. ## Round 12 (v3.0-alpha gig layer, `?gigs=1`) · 2026-07-15 diff --git a/docs/LANES/LANE_E_NOTES.md b/docs/LANES/LANE_E_NOTES.md index b0e0a81..748f0cc 100644 --- a/docs/LANES/LANE_E_NOTES.md +++ b/docs/LANES/LANE_E_NOTES.md @@ -1,5 +1,81 @@ # LANE E — cross-lane notes +## Round 40 — the transmission gate grows eyes on every load path, and the arcade kit is a rule, not an asset ⟨40.1 + 40.4⟩ + +Shots: `docs/shots/laneE/r40_arcade_posts_before.png` · `r40_arcade_posts_after.png` · +`r40_arcade_posts_pair.png`. Full control-arm transcripts: `E-progress.md` § Round 40. + +### → LANE F: §40.1 — the R39 blind spot is CLOSED, both arms demonstrated + +R39 filed it live: `check_transmission()` parsed only `pipeline/_normalized/` and returned +silently when that copy was absent — a depot-only transmissive GLB, or a stale glass copy in +`web/assets/models/` (**what `?localdepot=1` actually serves**), shipped unseen. `validate_manifest.py` +now scans **every path the game can load a GLB from**: + +| path | when scanned | how | +|---|---|---| +| `pipeline/_normalized/` | always (as before) | local parse | +| `web/assets/models/` | **always — NEW** | local parse (staging and served copies CAN differ; that is the point) | +| `/a/` | **when NO local copy exists in either dir — NEW** | ranged GET of the GLB JSON chunk (206-aware; on a 200 it streams and slices, which is what the tailnet depot returns) | +| any other `.glb` in either dir | **always — NEW** (ped rigs, dance clips, strays) | local parse — `rigs.js` loads these by literal path from the same served dir | + +A depot fetch failure is a loud `WARN … depot copy UNSCANNED`, never a silent skip. Real tree: +**rc 0, 0 errors, 0 warnings — 39 manifest GLBs + 40 non-manifest GLBs swept.** Both control arms +were demonstrated with fixtures reachable ONLY via a previously-unscanned path (RED rc 1 on each, +GREEN rc 0 after removal; the R39 script passes rc 0 over the same RED tree — the A/B is in +E-progress). Depot arm used a **local mock depot** — nothing was published to the real namespace. +`qa.sh` consumes rc only; the summary line grew `(+N non-manifest GLBs transmission-swept)`. + +### → LANE B: §40.4 — THE ARCADE'S POSTS. Verdict: **PLACEMENT-RULE change in your files — E touched nothing.** The rule + numbers below; the roof and the posts are designed as ONE kit. + +**Re-measured on today's tree** (`node pipeline/arcade_measure.mjs`, plus `buildings.js` read at the +lines cited — all R39 numbers stand, nothing moved): + +- ONE edge `kind:'arcade'`, **42.00 m × 5 m** (seed 20261990: (19.34,66.67)→(61.34,66.67)); lot + faces at ±2.50 m ⇒ **5.00 m** between shopfronts; 16–17 shops over 5 seeds. +- `buildings.js:21` `AWNING_DEPTH 2.2` · `:22` `AWNING_Y 2.95` · slab at `:637-639` reaches + ±0.30 m ⇒ a **0.60 m open slot** down the centreline. +- `:641-646` plants two posts per shop lot at `d/2 + AWNING_DEPTH − 0.2` = **2.00 m out from the + lot face = ±0.50 m from the centreline** — **two rows of posts 1.00 m apart down a 5.00 m lane**, + 0.12 × 0.12 × 2.95 m, **34 of them** at seed 20261990 (2 × 17 shop lots). No collider + (`:649` `lotCollider` only), so walkability is untouched; it *reads* as a colonnade planted in + its own doorway. + +**THE KIT (posts + roof designed together): a real arcade's roof SPANS, and a spanned roof has no +posts.** No new geometry — the roof is the existing awning-slab instances, deepened to meet; the +posts are deleted instances. Exact rule, in your files: + +1. **`web/js/world/chunks.js:13`** (`createChunkManager(plan, scene, ctx)`) — the builder needs + district knowledge (`ctx` today is `{skins, citySeed, night}`; lots carry `block`, but + block→district lives only in the plan): + ```js + const kindById = new Map((plan.districts || []).map((d) => [d.id, d.kind])); + ctx.arcadeBlocks = new Set((plan.blocks || []) + .filter((b) => kindById.get(b.district) === 'arcade').map((b) => b.id)); + ``` + Keyed on **`district.kind`, never `edge.kind`** — the R39 rule: real towns have 1,102 + arcade-KIND edges = 39 km of ordinary footpath and zero arcade DISTRICTS. +2. **`web/js/world/buildings.js:635-646`** (the awning block in `buildShopfront`): + `const inArcade = ARCADEKIT && ctx.arcadeBlocks?.has(lot.block);` then + depth `inArcade ? 2.5 : AWNING_DEPTH` at `:637` and `:638` (2.50 = the half-lane: the slab's + outer edge lands exactly on the centreline, opposing slabs meet flush, the 0.60 m slot closes), + and wrap the post loop `:641-646` in `if (!inArcade)`. +3. **The classic gate is MANDATORY and it is the explicit-flag pattern, not by-absence** (R39's + warning, restated): the arcade district exists ONLY on the synthetic and **the synthetic IS the + classic boot** — ungated, this moves the golden render. `ground.js:43-50`'s `VERGE` idiom: + `ARCADEKIT = !classic && p.get('arcadekit') !== '0'` (`?classic=1` keeps today's geometry + byte-identical, `?arcadekit=0` is F's falsifiability control). + +**Cost, measured live** (in-flight patched serve of exactly the rule above — no Lane B file on disk +was modified; `docs/shots/laneE/r40_arcade_posts_pair.png`): **draws 124 → 124 (+0)**, −34 `boxMats` +instances, slab depth 2.2 → 2.5 on arcade lots only, everywhere else untouched. Nothing was +generated, published, or re-published this round — no depot writes, no sha1 deltas. + +**One kit note for later:** with the roof spanned, the slab's underside IS the lane's ceiling — +that is exactly kit item #3's `ground-arcade-roof` skin (`use:"arcade-ceiling"`, shipped R39, +selected by nothing *on purpose*). Wiring it stays your call at **+1 draw in the 2 chunks** that +hold arcade lots (measured: lots span chunks 0,0 and 0,1 on all five seeds). + ## Round 39 — the magpie settled, the arcade measured, and three GLBs that were secretly glass ⟨v9 39.4⟩ Full provenance and method: `pipeline/AUDIT.md` § Round 39. Sheets: diff --git a/docs/shots/laneE/r40_arcade_posts_after.png b/docs/shots/laneE/r40_arcade_posts_after.png new file mode 100644 index 0000000..1444463 Binary files /dev/null and b/docs/shots/laneE/r40_arcade_posts_after.png differ diff --git a/docs/shots/laneE/r40_arcade_posts_before.png b/docs/shots/laneE/r40_arcade_posts_before.png new file mode 100644 index 0000000..8984440 Binary files /dev/null and b/docs/shots/laneE/r40_arcade_posts_before.png differ diff --git a/docs/shots/laneE/r40_arcade_posts_pair.png b/docs/shots/laneE/r40_arcade_posts_pair.png new file mode 100644 index 0000000..0d48763 Binary files /dev/null and b/docs/shots/laneE/r40_arcade_posts_pair.png differ diff --git a/pipeline/validate_manifest.py b/pipeline/validate_manifest.py index 999edb9..3b87481 100644 --- a/pipeline/validate_manifest.py +++ b/pipeline/validate_manifest.py @@ -5,15 +5,22 @@ Checks: JSON parses; every referenced skin exists locally; every fitting/furnitu exists locally (pipeline/_normalized) OR HEADs 200 on the depot; every thumb exists; footprints & heights are sane; every registry shop type has >=2 facades. +[R40 §40.1] The transmission gate scans EVERY path the game can load a GLB from: +pipeline/_normalized/ (staging), web/assets/models/ (the ?localdepot=1 served mirror), and — +for any manifest GLB with NO local copy in either — the depot copy itself (ranged GET of the +GLB JSON chunk). Non-manifest GLBs in either local dir (ped rigs, dance clips, strays) are +swept too: the game loads rigs by literal path from the same dirs. + python3 pipeline/validate_manifest.py # local + soft depot check python3 pipeline/validate_manifest.py --depot # require GLBs live on the depot (post-publish) Exit 0 = green, 1 = fail. Plain stdlib (no deps), so it runs anywhere. """ -import json, os, sys, urllib.request, urllib.error +import json, os, struct, sys, urllib.request, urllib.error ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ASSETS = os.path.join(ROOT, "web", "assets") +MODELS = os.path.join(ASSETS, "models") # loaders.js LOCAL_MODELS — what ?localdepot=1 serves NORM = os.path.join(ROOT, "pipeline", "_normalized") MANIFEST = os.path.join(ASSETS, "manifest.json") REGISTRY_TYPES = ["record", "opshop", "toy", "book", "video", "pawn", "milkbar", "dept", "stall"] @@ -49,47 +56,109 @@ def check_skin(file): err(f"skin missing: {file}") -def check_transmission(local_path, file): - """[R39] HARD FAIL on KHR_materials_transmission > 0. +# ── the transmission gate (R39, blind spot closed R40 §40.1) ───────────────────────────────────── +# [R39] HARD FAIL on KHR_materials_transmission > 0. +# This is a draw-budget check wearing a material's clothes. three.js runs a transmission PRE-PASS +# — `renderTransmissionPass(opaqueObjects, …)`, three.module.js:16433, entered at :16209 whenever +# `currentRenderList.transmissive.length > 0` — which re-renders the entire OPAQUE list into a +# render target, so EVERY draw in that scene is issued twice. Lane C measured the effect of one +# such material (the bookshelf's): opshop/hall 191→104 draws, book/hall 101→51, against a control +# room with no bookshelf at 116→116. Three GLBs shipped with `transmissionFactor: 1` because a +# bookshelf, a park bench and a streetlight are obviously not glass and so nobody ever looked. +# A gate is the only thing that makes "nobody looked" impossible. Fix: pipeline/strip_transmission.py +# +# [R40 §40.1] The R39 gate only parsed pipeline/_normalized/ and returned silently when that +# copy was absent — so a depot-only transmissive GLB (exactly the shape of longbench/streetlight) +# or a stale glass copy in web/assets/models/ (what ?localdepot=1 actually serves) shipped unseen. +# Demonstrated live in R39: the glass asset passed rc 0 with the local copy removed. Now every +# load path is scanned: both local dirs always, the depot copy whenever no local file exists. - This is a draw-budget check wearing a material's clothes. three.js runs a transmission PRE-PASS - — `renderTransmissionPass(opaqueObjects, …)`, three.module.js:16433, entered at :16209 whenever - `currentRenderList.transmissive.length > 0` — which re-renders the entire OPAQUE list into a - render target, so EVERY draw in that scene is issued twice. Lane C measured the effect of one - such material (the bookshelf's): opshop/hall 191→104 draws, book/hall 101→51, against a control - room with no bookshelf at 116→116. Three GLBs shipped with `transmissionFactor: 1` because a - bookshelf, a park bench and a streetlight are obviously not glass and so nobody ever looked. - A gate is the only thing that makes "nobody looked" impossible. Fix: pipeline/strip_transmission.py - """ +def _glb_json_chunk(d, label): + """Parse a GLB byte string → its JSON chunk dict, or None (warn on non-GLB magic is the + caller's call — a skin or a rig is still a GLB here, so magic mismatch is worth a warning).""" + if len(d) < 20 or d[:4] != b"glTF": + warn(f"{label}: not a GLB (bad magic) — transmission unchecked") + return None + off = 12 + while off + 8 <= len(d): + clen, ctype = struct.unpack(" 0. g = glTF JSON dict (may be None).""" + for i, mat in enumerate((g or {}).get("materials", [])): + t = (mat.get("extensions") or {}).get("KHR_materials_transmission") + if t and t.get("transmissionFactor", 0) > 0: + err(f"{label}: material[{i}] '{mat.get('name')}' has transmissionFactor " + f"{t['transmissionFactor']} — three.js will run a transmission PRE-PASS and " + f"issue every opaque draw in that scene TWICE. Run pipeline/strip_transmission.py") + + +def check_transmission(local_path, label): + """Transmission scan of one LOCAL GLB. Absent file = nothing to parse (the caller decides + whether an absent file means the depot copy must be fetched instead — see check_glb).""" if not os.path.isfile(local_path): - return # depot-only asset: nothing to parse here + return try: - import struct - d = open(local_path, "rb").read() - if d[:4] != b"glTF": - return - off, g = 12, None - while off < len(d): - clen, ctype = struct.unpack(" 0: - err(f"{file}: material[{i}] '{mat.get('name')}' has transmissionFactor " - f"{t['transmissionFactor']} — three.js will run a transmission PRE-PASS and " - f"issue every opaque draw in that scene TWICE. Run pipeline/strip_transmission.py") + _scan_transmission(_glb_json_chunk(open(local_path, "rb").read(), label), label) except Exception as e: # a parse failure is not a licence to skip the check - warn(f"{file}: could not read materials for the transmission check ({str(e)[:60]})") + warn(f"{label}: could not read materials for the transmission check ({str(e)[:60]})") + + +def _ranged_read(url, start, length): + """GET url bytes [start, start+length). Honours 206; on a 200 (the tailnet depot and plain + http.server both ignore Range) streams and slices so we never pull more than needed.""" + req = urllib.request.Request(url, headers={"Range": f"bytes={start}-{start + length - 1}", + "User-Agent": "procity-validator/1.0"}) + r = urllib.request.urlopen(req, timeout=20) + try: + if r.status == 206: + return r.read() + skip = start + while skip > 0: # 200 = full body; skip up to our offset + chunk = r.read(min(65536, skip)) + if not chunk: + return b"" + skip -= len(chunk) + return r.read(length) + finally: + r.close() + + +def check_transmission_depot(url, label): + """Transmission scan of the DEPOT copy — the JSON chunk via two ranged GETs (header first for + the chunk length, then exactly the chunk). A fetch failure is a warning, never a silent skip.""" + try: + head = _ranged_read(url, 0, 20) + if len(head) < 20 or head[:4] != b"glTF": + warn(f"{label}: depot copy is not a GLB (bad magic) — transmission unchecked") + return + clen, ctype = struct.unpack("