Lane F R39 (v9 WAVE 1, CLOSE): QA GREEN 10/0/0/0 — and the interior margin is 162, not 21 and never 6

FIVE GATES, FIVE CONTROLS, each demonstrated in the same run. New F-owned tools wired into qa.sh as
Gate 8: r39_address.mjs · r39_transmission.py · r39_runtime.py · r39_shots.py (--no-r39 skips the
browser half). Lane A's selfcheck reproduces exactly: 157,647/157,647 ALL GREEN, fingerprint
0x5f76e76 — ZERO GOLDENS MOVED, in a round that added an address layer, a fog ledger, a spawn move
and a new interior fitting.

THE ROUND'S BIGGEST NUMBER IS NOT THE ONE THE LEDGER ASKED FOR. Two port-isolated no-store roots,
the same inode for every byte except one file — the shipped bookshelf GLB vs a copy with
transmissionFactor:1 re-inserted (BIN sha1 IDENTICAL, only the JSON chunk moved) — both through
interior_test.html's own drawSweep:

  GLB-on worst   329 (opshop/hall)  ->  188 (dept/auto)     -141
  GLB-off worst  162 (opshop/hall)  ->  162 (opshop/hall)   0 — the control's own control
  margin vs <=350            21     ->  162                 +141

opshop 329->174 (-47%) · book 247->125 (-49%) · every other type 0, exactly the shape Lane C
predicted from an isolated room, at sweep scale, by a harness C did not write. The worst interior
room in the game is now dept/auto — a room with no bookshelf in it. The default boot fetches these
GLBs from the public depot, so all four R39 GLBs were fetched off digalot.fyi and scanned: sha1
identical to the local tree, 0 transmissive materials, 4/4. Binding fact #3's third and final value.

39.1 ADDRESS — GREEN. 1,192/1,219 shops (97.8%), 23/23 'ways', 857/857 membership agree / 0 wrong
(and the control is asserted to have CHECKED 857 edges). No cache -> supplier 'district', 0 names,
still labels 72/72 from district+block. RED ARM: a 3 m shift error RAISES adelaide 89 -> 98 resolved
shops (Lane A's number to the digit) while INVENTING 9 names; stats().shiftCheck reads DISAGREE on
all 72 perturbations. And the honest limit: at 3 m the membership control reads 0 disagreements,
because those shops front ways OSM leaves unnamed — only the shift cross-derivation catches it.

39.2 FOG — GREEN, both arms on one boot. HIDES 4/72 at spawn, 0 of 61 distant shops known. REVEALS
2,125 m -> 58 shops. FRONT TEST: probe from 5 m BEHIND each of 59 unknown shopfronts, 0 learned.
+0 DRAWS AND +0 TRIS, four freshly launched browsers, both orders, drained, 3 reads each, with the
arms asserted distinguishable first. 8/8 corrupt saves rejected, state untouched. Null store on
classic/game=0/fog=0; game=0 and fog=0 pixel-identical, default differs.

39.3 THE SIGN — GREEN. Shops in front of the player at spawn: 4/4 · 4/4 · 4/4 · 1/1, and ZERO on all
four with the pre-R39 sign re-derived on the same tree. The live spawn is asserted equal to the +sin
derivation, so the shell is what is scored. Re-baselined WITHOUT falsifying a release record: the
v7 tour and v7-beta frames are kept as history; docs/shots/laneF_r39/ ships the new pair with its
own BEFORE. The R35 gate itself is pose-independent and was green either side.

39.4 RUMMAGE BIN — GREEN. 120 rooms: tub 60/60 both arms, kind:'bin' 0/60 default vs 60/60 armed,
0 carves, 60/60 deterministic, reachable 0.80-1.03 m by a flood fill written here. THE CONTENTS DID
NOT SHIP: on the default street boot the tub is kind:'prop', 0 dig targets, E opens nothing.

39.5 TRANSMISSION VALIDATOR — GREEN and it FIRES: hard-fails rc 1 on the fabricated glass asset AND
passes on the shipped tree.

THREE CORRECTIONS, ALL MEASURED. A's "a 30 m shift returns 0 street names" — it returns 40/12/47
wrong ones; a big shift lands you on the next street, it does not degrade to null. B's "a fresh
export has no `known` key" — true of ?fog=0, no longer of a default boot, because B's own +sin fix
means you spawn in front of four shopfronts. And my own: I first aimed the transmission control at
web/assets/models/ when validate_manifest.py reads pipeline/_normalized/, and nearly filed a defect
against Lane E on the strength of it. Aim the control at the code path, not the plausible path.

HELD -> LANE E: check_transmission() skips any GLB with no local _normalized copy and never scans
web/assets/models/. Demonstrated with a live control — remove the local copy and the same glass
asset passes rc 0. A depot-only transmissive GLB (the exact shape of longbench and streetlight)
would ship unseen.

No tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-03 22:47:03 +10:00
parent 43c7f4281e
commit 076b972f71
13 changed files with 1763 additions and 0 deletions

View File

@ -4,6 +4,70 @@
---
## Round 39 (v9 WAVE 1 — THE CITY IS THE GAME) — five gates, five controls, and the interior margin is 162
*Full detail: [LANE_F_NOTES §39](docs/LANES/LANE_F_NOTES.md). (R38's close lives in
[LANE_F_NOTES §38](docs/LANES/LANE_F_NOTES.md) — it never got a row here.)*
**`tools/qa.sh --strict --matrix` GREEN — 10 passed · 0 failed · 0 warn · 0 skipped**, on the true
final tree, with three new gates wired in as Gate 8. Lane A's selfcheck reproduces exactly:
**157,647/157,647 ALL GREEN, fingerprint `0x5f76e76` — zero goldens moved**, in a round that added an
address layer, a fog ledger, a spawn move and a new interior fitting.
**THE ROUND'S BIGGEST NUMBER IS NOT THE ONE THE LEDGER ASKED FOR. The interior margin is 162 draws,
not 21 and never 6.** Lane E's transmission fix takes the worst room from **329 → 188** draws.
Measured with `tools/qa/r39_transmission.py`: two port-isolated no-store roots, identical inode for
inode except one file — the shipped `bookshelf` GLB vs a copy with `transmissionFactor: 1`
re-inserted (BIN sha1 identical, only the JSON chunk moved) — both driven through
`interior_test.html`'s own `drawSweep`.
| `drawSweep` worst | glass bookshelf (= the pre-E tree) | E's fix (shipped) | Δ |
|---|---|---|---|
| GLB-on | **329** `opshop/hall` | **188** `dept/auto` | **141** |
| GLB-off | 162 `opshop/hall` | 162 `opshop/hall` | 0 — the control's own control |
| margin vs the ≤350 law | **21** | **162** | **+141** |
`opshop` 329→174 (47%) · `book` 247→125 (49%) · **every other type 0**, which is the shape Lane C
predicted from an isolated room, reproduced at sweep scale by a harness C did not write. **The worst
interior room in the game is now `dept/auto` — a room with no bookshelf in it.** The default boot
fetches these GLBs from the public depot, so I fetched all four R39 GLBs off `digalot.fyi` and
scanned them: **sha1-identical to the local tree, 0 transmissive materials, 4/4.**
**The five gates, each with its control demonstrated in the same run:**
| gate | verdict | the arm that could have failed |
|---|---|---|
| **39.1 address** (`r39_address.mjs`) | GREEN | **1,192/1,219 shops (97.8%)**, 23/23 `ways` supplier, 857/857 membership agree / 0 wrong. **RED ARM: a 3 m shift error raises adelaide 89 → 98 resolved shops** (Lane A's number to the digit) while inventing 9 names — `stats().shiftCheck` reads DISAGREE on all 72 perturbations, and it is the ONLY alarm that catches the 3 m case |
| **39.2 fog** (`r39_runtime.py`) | GREEN | **HIDES** 4/72 at spawn, 0 of 61 distant shops known · **REVEALS** 2,125 m → 58 shops · **FRONT TEST** 0 of 59 shopfronts learned when probed from 5 m behind · **+0 draws AND +0 tris**, four fresh browsers, both orders, drained, 3 reads each · 8/8 corrupt saves rejected · null store on classic/`game=0`/`fog=0` |
| **39.3 THE SIGN** (`r39_runtime.py`) | GREEN | shops in front of the player at spawn **4/4 · 4/4 · 4/4 · 1/1**; with the pre-R39 sign re-derived on the same tree, **0 on all four** |
| **39.4 rummage bin** (`r39_runtime.py`) | GREEN | 120 rooms: tub 60/60 both arms, `kind:'bin'` **0/60 default vs 60/60 armed**, 0 carves, 60/60 deterministic, reachable 0.801.03 m. **THE CONTENTS DID NOT SHIP** — on the default street boot the tub is `kind:'prop'`, 0 dig targets, E opens nothing |
| **39.5 transmission** (`r39_transmission.py --validator`) | GREEN | the validator **HARD FAILS rc 1** on a fabricated glass asset and **PASSES on the shipped tree** — both halves, or "it failed" only proves the gate is always red |
**Re-baselined, without falsifying a release record** (`tools/qa/r39_shots.py` →
`docs/shots/laneF_r39/`): the R32 cluster-pose spawn frame and the R35 first-five splash were both
shot at the old pose. `v7_tour/02_the_strip.png` and `v7_beta/first_five_splash.png` are **kept as
history**; the re-baseline ships its own BEFORE, camera parked at the old `-sin` pose on the same
tree. Katoomba's BEFORE is a paddock and a blank back wall; the AFTER is the awnings and the
shopfront run. The R35 *gate* needed no re-baselining — `smoke_first_five` is pose-independent and
was green either side.
**Three corrections, all mine to make and all measured:** LANE_A_NOTES §39's "a 30 m shift returns 0
street names" — it returns 40/12/47 wrong ones, because on a dense grid a big shift lands you on the
next street · LANE_B_NOTES §39's "a fresh export has no `known` key" — true of `?fog=0`, no longer of
a default boot, because B's own `+sin` fix means you spawn in front of four shopfronts · and my own
harness bug, recorded: I first pointed the transmission control at `web/assets/models/` when
`validate_manifest.py` reads `pipeline/_normalized/`, and nearly filed a defect against Lane E on the
strength of it. **Aim the control at the code path, not the plausible path.**
**Held → Lane E:** `check_transmission()` skips any GLB with no local `_normalized` copy and never
scans `web/assets/models/`. Demonstrated with a live control: remove the local copy and the same
glass asset passes rc 0. A depot-only transmissive GLB — the exact shape of `longbench` and
`streetlight` — would ship unseen.
**No tag.**
---
## Round 37 (v8 WAVE 0 — THE FLOOR) — the budget of record settled · two vacuous gates repaired
**0.3 — THE BUDGET OF RECORD: 292 draws / 124,793 tris. The audit was right and the spike was

View File

@ -2708,3 +2708,294 @@ fixed**, because they are other people's files or other rounds' scope:
ungated diagnostic override · **should `?classic=1` show the town selector at all**.
**No tag.** Wave 1 is content; the epoch tags when it is whole.
## §39 — R39: v9 WAVE 1, THE CITY IS THE GAME. F: five gates, five controls — and the interior margin is **162**, not 21 and never 6
**Verdict: v9 wave 1 SHIPS.** Four lanes landed the biggest single-round surface since the epoch
opened and every arm reproduces. **The round's biggest number is not the one the ledger asked for:
Lane E's transmission fix takes the worst interior room from 329 draws to 188, and the margin
against the ≤350 law from 21 to 162.** The op-shop bin that this round spent so much care costing
against a 21-draw margin is now spending against a 162-draw one.
Everything below is measured on the true final tree (`43c7f42` + this lane's `tools/**`), on this
box, with each gate's falsifiability control demonstrated in the same run. New tools, all F-owned:
`tools/qa/r39_address.mjs` · `tools/qa/r39_transmission.py` · `tools/qa/r39_runtime.py` ·
`tools/qa/r39_shots.py`, wired into `qa.sh` as Gate 8 (`--no-r39` skips the browser half).
### §39.1 THE INTERIOR MARGIN, RE-MEASURED — **21 → 162, and the worst room is no longer an op shop**
Fable's binding fact #3 was corrected in-round twice: "six" was R9-era, and Lane C measured today's
tree at **GLB-off 156 / GLB-on 319**, → **329** after the bin, margin **21**. That correction was
right *and it was taken before Lane E's transmission fix landed.* Re-measured here through
`interior_test.html`'s own `drawSweep` — the ≤350 law's own instrument, 12 types × 7 archetypes —
against **two port-isolated no-store roots that are the same inode for every byte except one file**:
the shipped `procity_fit_bookshelf_01.glb`, and a copy with `transmissionFactor: 1` re-inserted
(JSON chunk rewritten, **BIN sha1 identical**, `ab1fa806…`). `?localdepot=1` on both, so the GLB
comes off the served root and the swap is the only variable.
| `drawSweep` worst room | with the glass bookshelf (= the pre-E tree) | with E's fix (shipped) | Δ |
|---|---|---|---|
| **GLB-on** | **329** (`opshop/hall`) | **188** (`dept/auto`) | **141** |
| GLB-off | 162 (`opshop/hall`) | 162 (`opshop/hall`) | **0 — the control's own control** |
| **margin against the ≤350 law** | **21** | **162** | **+141** |
Per type, GLB-on worst archetype, glass → fixed: **`opshop` 329 → 174 (155, 47%)** ·
**`book` 247 → 125 (122, 49%)** · **every other type 0** — record 135, toy 158, video 123, pawn
136, milkbar 142, dept 188, stall 144, pub 112, band_room 111, rsl 122, all unchanged to the draw.
That is exactly the shape Lane C predicted from an isolated room (`opshop`/hall 191→104 46%,
`book`/hall 101→51 50%, `dept`/hall 116→116 as the control), reproduced at the sweep's own scale by
a harness C did not write. **The falsifiable arm E asked for fires: the sweep dropped.**
Three things follow and they are worth writing down:
1. **The worst interior room in the game is now `dept/auto` at 188 draws** — a room with no
bookshelf in it, which is why it did not move. The op shop was only the worst room because it was
being rendered twice.
2. **GLB-off is 162 on both roots**, which is what makes the 141 attributable: a GLB-off room loads
no GLB, so if the two roots differed for any other reason this row would have shown it.
*(Lane C reports GLB-off worst **161**; I measure **162**, same room, same tree. One draw, not
chased — recorded so the next person to quote a number knows two harnesses differ by it.)*
3. **The default boot fetches these GLBs from the public depot, not from `web/assets/models/`**
(`loaders.js:29` — `?localdepot` is opt-in), so I fetched all four R39 GLBs off
`https://digalot.fyi/3god/a/` and scanned them: **bytes sha1-identical to the local tree, 0
transmissive materials, 4/4.** E's publish is verified independently of E's own verification.
### §39.2 GATE — THE ADDRESS LAYER (39.1) · `node tools/qa/r39_address.mjs` · **GREEN**
Fable asked for ≥97% corpus resolution and a loud failure with no roads cache, and Lane A's binding
warning set the shape: **coverage is not a proxy for correctness.** So the gate reports coverage and
then spends its remaining arms trying to make a *high*-coverage build fail.
| arm | result |
|---|---|
| corpus resolution, counted here over all 23 caches | **1,192 / 1,219 shops (97.8%)**, 29,865/30,986 edges (96.4%), **100% on 18 of 23 towns**, worst adelaide 80.9% |
| supplier | `ways` on **23/23** |
| shift cross-derivation (`address.js`'s recovery vs `plan_osm`'s published `norm.shift`, 0.01 m) | **agree on 23/23** |
| wrong names, against an independently written point-membership resolver over shop-bearing edges | **857/857 agree, 0 disagree** — and the control **checked 857 edges**, asserted, because a control that quietly checks nothing is the R37 species |
| **no cache** (`null`, `undefined`, and `roads[].name` stripped) | supplier `district`, **0** named edges, **0** street names on all three — and it still labels **72/72** katoomba shops from district+block (*"the south end of the main street"*). A degrade, not an outage, and visible in `stats()` |
| synthetic, same code path | supplier `district`, **493/493** labelled, 0 nulls — one consumer contract, two suppliers |
| purity | **23/23** towns byte-identical after the call |
**THE RED ARM — the wrong-shift sweep (8 directions × 3 magnitudes × 3 towns, 72 perturbations).**
A wrong shift is the one input that renames a whole town, so it is what the gate is built around:
| adelaide_real (truth **89**/110 resolved, 0 wrong) | best of 8 directions |
|---|---|
| off by **3 m** | **98 shops resolved — NINE MORE THAN THE TRUTH**; 0 renamed outright, **9 given a street name the correctly-shifted build honestly refused**; 0/74 membership disagreements |
| off by 8 m | 96 resolved (+7); 10 invented |
| off by 30 m | 40 resolved (49); 3 renamed outright, 2/28 membership disagreements |
**Lane A's number reproduces to the digit: 89 → 98.** A gate that read coverage would have gone
*greener* on a broken build. `stats().shiftCheck` reads **DISAGREE on all 72 perturbations**, which
is what this gate actually stands on.
**And the honest limit of the second alarm, which matters:** at 3 m the *membership* control reads
**0 disagreements** while 9 names are being invented — because those nine shops front ways OSM leaves
**unnamed**, so membership has no answer there and stays silent. **Only the shift cross-derivation
catches the 3 m case.** That is why this gate asserts the cross-derivation on all 23 towns and never
the coverage, and it is a load-bearing detail for anyone who later "simplifies" these two arms into
one.
**And it is live in the game, not just in node.** Read off the running shell during the fog gate:
`katoomba_real``addresses.stats().supplier === 'ways'`, synthetic → `'district'`, and
`minimap.view.labels === true` on **both** — the consumer draws street labels through one code path
with no town-type branch, which is the whole design constraint A published.
**One correction to LANE_A_NOTES §39, measured.** A's note says a 30 m shift error makes
castlemaine/katoomba/adelaide *"return 0 street names rather than a town full of wrong ones."*
Measured here, 30 m keeps **adelaide 40 · castlemaine 12 · katoomba 47** names and they include wrong
ones. **A big shift does not degrade to null on a dense grid — it lands you on the next street.**
The conclusion A drew from it is unchanged and if anything stronger: the shift arm must be
exact-or-nothing, because there is no magnitude at which being wrong announces itself in the output.
### §39.3 GATE — THE SIGN (B, `a982f3c`) · `r39_runtime.py --only sign` · **GREEN**
The picture-free arm Lane B specified, on four towns, with its "before" computed on the same tree:
both spawn derivations (`+sin` as shipped, `sin` as it read from R32 until this round) are rebuilt
from the **live plan in the page**, the shipped pose is **read off the live player and asserted equal
to the `+sin` derivation** (4/4 towns, <0.01 m) so the thing being scored is the shell, not a
re-implementation — and then one scorer runs at both poses.
| town | shipped (`+sin`) | the pre-R39 sign, same tree |
|---|---|---|
| synthetic | (6.1, 19.8) · 4 within 25 m, **4 in front** | (25.5, 17.9) · 3 within 25 m, **0 in front** |
| katoomba_real | (200.0, 474.4) · 4 within 25 m, **4 in front** | (219.0, 471.3) · 3 within 25 m, **0 in front** |
| bowral_real | (1774.0, 171.7) · 4 within 25 m, **4 in front** | (1755.1, 168.5) · 2 within 25 m, **0 in front** |
| fitzroy_real | (22.8, 184.9) · 1 within 25 m, **1 in front** | (25.4, 204.0) · 1 within 25 m, **0 in front** |
**> 0 on 4/4 after, ZERO on 4/4 before** — the gate discriminates, and it goes red the moment the
sign flips back. The shell's own probe agrees at boot (4 · 4 · 4 · 1 shops discovered). 0 console
errors across all four boots. Every one of Lane B's numbers is also mine.
**THE TWO RE-BASELINED PICTURE GATES** (`tools/qa/r39_shots.py` → `docs/shots/laneF_r39/`):
- **The R32 cluster-pose spawn.** `docs/shots/v7_tour/02_the_strip.png` photographs the old pose.
**I did not overwrite it** — it is an artefact of the tagged v7.0 epoch and re-shooting a release
tour on a v9 tree falsifies a record. The re-baseline is `the_strip-{BEFORE,AFTER}*.png` and
`the_strip_katoomba-{BEFORE,AFTER}*.png`, each pair shot on the **same tree** with the camera
parked at the two poses. Katoomba's BEFORE is the paddock and the blank back wall, exactly as B
reported; the AFTER is the awnings and the shopfront run.
- **The R35 first-five-minutes smoke needed no re-baselining as a GATE, only as a picture.**
`smoke_first_five` reads the hunt line and drives the beats through `DBG.enterShop`, so it is
pose-independent — it was green before and after, and it is green in this round's suite. What moved
is the frame its subject is *seen* in: `first_five-AFTER-plus-sin.png`, *"You're hunting HILLS
HOIST — $191 in your pocket"* over a street that now has shops in it. `v7_beta/first_five_splash.png`
is likewise kept as history.
### §39.4 GATE — THE FOG (B, `0ee7a84`) · `r39_runtime.py --only fog` · **GREEN**
Both arms on the same boot, or it is vacuous. The walk is a **shopper's walk** — the edges the most
shops front, not the longest edges (my first cut walked katoomba's 5.8 km of rural arterial and
revealed one shop, which measures the walker, not the fog).
| | katoomba_real (72 shops) | synthetic (493 shops) |
|---|---|---|
| **ARM 1 — HIDES** | 4/72 known at spawn, **0 of the 61 shops beyond 120 m known** | 4/493 known, **0 of 410 beyond 120 m** |
| **ARM 2 — REVEALS** | 2,125 m walked → **58 shops / 51 segments** (+54/+50) | 2,100 m → **405 shops / 16 segments** (+401/+15) |
| **ARM 3 — the FRONT test** | stand 5 m **behind** each of the 14 still-unknown shopfronts and probe: **0 learned**, 14 refusals | 45 unknown shopfronts probed from behind: **0 learned**, 197 refusals |
| the map moves with the ledger | 3,478 → 135 px of shop colour (the frame grows with the walk) | 2,202 → 16,328 px |
| lot size on the map | fresh **29.4×39.2 px** vs the shipped full-plan **1.42×1.89 px** | 20.97×41.16 vs 5.22×10.24 |
| save round-trip | 58 shop + 51 edge ids, **567 B** | 405 + 16 ids, **1,758 B** |
| corrupt-save arms | **8/8 rejected, live state untouched** | 8/8 |
ARM 3 is the side test's red arm and it is decisive: a radius-only fog would have handed over all 59
of those shopfronts through their own back walls. On the shopper's walk itself the same test refuses
**6 times on katoomba and 0 on the synthetic strip** — free where a shopper stands, decisive behind
the buildings, which is the shape Lane B claimed.
**ZERO DRAWS, and the arm is non-vacuous by assertion:** four freshly launched browsers, two orders
(default-first and `?fog=0`-first), each boot **drained** (`chunks.pending → 0`, settle, re-teleport
to read) and read **three times** so the arm carries its own noise floor; the fog flag and the store's
presence are asserted opposite on the two arms *before* their counters are compared.
```
yaw 0 : default 171 draws [171,172] / 24,507 tris [23196,27193] · ?fog=0 171 / 24,507 — identical
yaw 90: default 103 draws [102,103] / 25,121 tris [23058,25121] · ?fog=0 103 / 25,121 — identical
```
**Identical in BOTH counters, both yaws, both orders.** Map mode reads the same 103 draws in mode
`map` — structural, `index.html`'s frame loop never calls `composer.render()` there.
*A method note worth keeping:* my first cut booted both arms **in one browser** and read a **12,000
triangle gap at identical draw counts**. That is HTTP-cache warmth — the second boot has more of the
asset fleet resolved by sample time — and the only way to see it is to reverse the order. One browser
per arm makes the two readings identical to the byte. **Two arms in one browser is not an A/B.**
**The null arm:** `?classic=1`, `?game=0` and `?fog=0` all produce a **null store** (3/3), which is
the pre-R39 map with no flag test in `minimap.js` at all. `?game=0` and `?fog=0` draw a
**pixel-identical** map (FNV `0xddb5b939`) and the default boot's differs (`0x672e276b`) — the hash
is sensitive to the map's content, which is what makes the equality mean something.
**A CORRECTION TO LANE_B_NOTES §39, and it is B's own fix that caused it.** B records *"a fresh
export has no `known` key"*. On the shipped tree the default boot discovers **4 shops before the
player touches a key** — because the `+sin` spawn now puts you in front of four shopfronts — so a
default boot's first export **does** carry `known`. The claim the delta law is actually about
survives and is asserted instead: **a boot that learns nothing (`?fog=0`) exports
`[cash, collection, day, savedAt, schema, town, wants]` — no `known` key, the pre-R39 bytes.**
### §39.5 GATE — THE RUMMAGE BIN (C, `eeba920`) · `r39_runtime.py --only bin` · **GREEN**
Both arms, or the gate only proves that a tub exists. The tub is identified off the room's **own
placement summary** (`placement[].kind === 'rummageBin'`), not a bounding-box guess, and reachability
is a **4-connected flood fill from the room spawn** written here rather than a flag the builder set
about itself.
| 6 archetypes × 6 seeds × GLB on/off = 120 rooms | default (`dig:false`) | ARMED (`{rummageBin:{dig:true}}`) |
|---|---|---|
| tub placed | **60/60** | **60/60** |
| `kind:'bin'` | **0/60** | **60/60** |
| dig aim targets in `places` | **0** | **60** |
| pathOK / corridor carves | 60/60 · **0** | 60/60 · **0** |
| deterministic (byte-equal placement over two builds) | 60/60 | 60/60 |
| nearest reachable cell to the tub | — | **0.801.03 m** (dig proximity 2.5 m, aimed reach 3.2 m) |
**THE CONTENTS DID NOT SHIP, proven on the boot every player gets:** on the default street boot
(*"Second Time Redfern"*, fittings `counter · clothesRack ×2 · metalShelf ×2 · bookshelf ×2 ·
crateStack · rummageBin · art ×3`) the tub is in the room as **`kind:'prop'`**, there are **0**
`kind:'bin'` objects and **0** dig targets, and standing in front of it and pressing **E opens
nothing**. Lane C's hold is intact and I am not the one to break it.
Lane C's 72/72 is 6 archetypes; `ARCHETYPE_KEYS` on this tree is **5**, so my 120 rooms are
5 × 6 × 2 × 2 arms. C's reachability range (0.771.15 m) and mine (0.801.03 m) agree.
### §39.5b THE MAGPIE'S GLB ADOPTION (B, `f0b9a1d`) — checked on the wire, because it is a blind-probe shape
B wired E's magpie GLB as `furniture.js`'s use-if-ready second path and states that `?noassets` and
`?classic` still get the procedural bird. A module-scope `loadGLB` is exactly the shape that leaks a
fetch past the asset-free law, so I watched the wire rather than the flag:
| boot | `magpie.state.asset` | depot/model fetches | magpie GLB fetched |
|---|---|---|---|
| default (`?magpie=1` to force the season) | **`glb`** | 37 | yes — `digalot.fyi/3god/a/procity_street_magpie_01.glb` |
| **`?noassets=1&magpie=1`** | **`primitive`** | **0** | **no** |
| `?classic=1` | **null — never constructed** | 25 (the classic layer's own, allow-listed) | no |
`state.asset` is read off the live module, so the gate reads which bird is bound instead of inferring
it from a fetch. The matrix's `?noassets` arm (0 fetches on all ten towns) is the standing guard.
### §39.6 GATE — THE TRANSMISSION VALIDATOR (E, `12eb571`) · **GREEN, and it FIRES**
`validate_manifest.py`'s R39 hard-fail is live, so the gate is: **make it fire.** A fabricated glass
`bookshelf` (BIN sha1 identical, only the JSON chunk moved) dropped into a mirror of the repo where
every other path is a symlink:
- **validator HARD FAILS, rc 1**: *"material[0] 'mtl_10218_Bookshelves_v1' has transmissionFactor 1.0
— three.js will run a transmission PRE-PASS and issue every opaque draw in that scene TWICE"*
- **validator PASSES on the shipped tree** — the positive control, without which "it failed" only
proves the gate is always red
- shipped tree re-scanned: **0 transmissive materials in 59 GLBs under `web/assets/models/` and 59
under `pipeline/_normalized/`**, and 0 in the 4 fetched off the public depot
**MY OWN HARNESS BUG, RECORDED BECAUSE IT IS THIS ROUND'S OWN SPECIES.** My first cut planted the
glass asset in `web/assets/models/` and reported *"the validator did NOT fail — E's gate is
vacuous."* It was **my** error: `check_transmission()` reads `pipeline/_normalized/<file>`. I pointed
a gate at a file it never opens and nearly filed a defect against another lane on the strength of it.
*Aim the control at the code path, not at the plausible path.*
**→ LANE E, one ask (your file, not mine).** `check_transmission()` returns silently when
`pipeline/_normalized/<file>` is absent (`# depot-only asset: nothing to parse here`), and it never
scans `web/assets/models/`. **Demonstrated, not inferred:** with the local `_normalized` copy removed
and the manifest still pointing at it, the same glass asset **passes, rc 0**. So a depot-only
transmissive GLB — exactly the shape of `longbench` and `streetlight`, which are in the manifest and
not wired — would ship unseen. Two lines fix it: scan `web/assets/models/<file>` when `_normalized`
has no copy, and warn (not skip) when neither exists.
### §39.7 THE SUITE — `tools/qa.sh --strict --matrix` on the true final tree
```
SUMMARY 10 passed · 0 failed · 0 warn · 0 skipped
● QA GREEN — every landed gate passed.
```
- **Lane A's selfcheck reproduces exactly: 157,647/157,647 ALL GREEN, fingerprint `0x5f76e76`.**
Zero goldens moved this round, on a round that added an address layer, a fog ledger, a spawn move
and a new interior fitting.
- flags harness GREEN (0 fails, 0 warns) — including `smoke_first_five`, which the spawn move does
not touch, and `smoke_classic_purity`.
- MATRIX GREEN, 10 towns × 7 gates; manifest validator `--depot` green; interior figure-scale green.
- Gate 8 (new): `r39_address` · `r39_transmission --validator` · `r39_runtime` — all green.
- A **baseline** run of the pre-Gate-8 suite on the same tree was also green (7/0/0/0), so the three
new gates added coverage without moving any existing verdict.
### §39.8 HELD, AND HANDED ON
1. **→ LANE E:** the validator's depot-only blind spot (§39.6). Demonstrated with a running control.
2. **→ FABLE / the record:** binding fact #3 needs its third and final value. **The interior margin
is 162 draws** (worst room `dept/auto` 188 / 350) as of `12eb571`. The lineage is worth keeping
because it is a lesson in three parts: **6** (a stale doc), **21** (a correct measurement of a
tree that was about to change), **162** (the measurement after the change). Every one of them was
honestly arrived at.
3. **→ LANE A (note, not a defect):** the 30 m shift claim in LANE_A_NOTES §39 (§39.2 above).
4. **→ LANE B (note, not a defect):** "a fresh export has no `known` key" is now true of `?fog=0`
only (§39.4 above).
5. **The 1-draw GLB-off disagreement with Lane C** (161 vs 162, same room, same tree). Not chased.
6. Carried, unchanged: **A's D1D4** · the matrix's budget column is bookmark numbers · **`?r=3`
breaches at 307** and is still an ungated diagnostic override · should `?classic=1` show the town
selector at all · **nobody in this loop has ears**.
**The street budget was not re-measured this round and does not need to be:** the fog and the address
layer are both zero-draw by construction and measured at zero, the spawn move is a pose, and the bin
is interior. The pin stands at **292/300**. What moved is the *interior* budget, and it moved in our
favour by 141 draws.
**No tag.**

View File

@ -0,0 +1,29 @@
# Lane F — R39: the spawn-pose RE-BASELINE (v9 wave 1)
Lane B's `+sin` fix moved the non-classic spawn (`index.html:500`, R32's cluster-pose). **Two of this
lane's picture baselines were taken at the old pose and are therefore photographs of the defect:**
| baseline | what it shows | status |
|---|---|---|
| `docs/shots/v7_tour/02_the_strip.png` | "the cluster-pose spawn: midday on the main street, shopfronts down the block" | **HISTORICAL** — kept. It is an artefact of the tagged v7.0 epoch; re-shooting it on a v9 tree would falsify a release record. |
| `docs/shots/v7_beta/first_five_splash.png` | the R35 first-five-minutes splash, shot at the spawn | **HISTORICAL** — kept, same reason. |
The re-baseline lives here, and it ships its own BEFORE — the **same tree**, camera parked at the
old `-sin` pose — so the pair is a comparison rather than a claim. Regenerate with
`tools/.venv/bin/python tools/qa/r39_shots.py` (seed 20261990, `?roster=v1&pop=0`, segment 2).
| frame | pose | what is in it |
|---|---|---|
| `the_strip-BEFORE-minus-sin.png` | synthetic (25.5, 17.9) yaw π | the shipped-since-R32 spawn: a footpath facing a plaza, not one shopfront in frame |
| `the_strip-AFTER-plus-sin.png` | synthetic (6.1, 19.8) yaw π | the fixed spawn: the strip, four shopfronts inside 25 m and all four in front of you |
| `the_strip_katoomba-BEFORE-minus-sin.png` | katoomba_real (219.0, 471.3) yaw 0 | **standing in a paddock facing the blank back wall of a building** — the R31 playtest's own complaint, the one R32 was written to fix and, because of the sign, never did |
| `the_strip_katoomba-AFTER-plus-sin.png` | katoomba_real (200.0, 474.4) yaw 0 | under the awnings on Katoomba Street with the shopfronts and their signage running away down the block |
| `first_five-AFTER-plus-sin.png` | synthetic, fresh game | the R35 frame re-shot: *"You're hunting HILLS HOIST — $191 in your pocket."* over a street that now has shops in it |
**The automated R35 gate did not need re-baselining, only the picture did.** `smoke_first_five`
(`tools/flags_check.py`) reads the hunt line and drives the beats through `DBG.enterShop`, so it is
pose-independent; it was green before and after the fix. What the fix changed is the frame the gate's
subject is *seen* in. The falsifiable arm that now guards this pose is
`tools/qa/r39_runtime.py --only sign`: **at the spawn, the count of shops within 25 m that are in
front of the player must be > 0** — measured 4/4 · 4/4 · 4/4 · 1/1 on synthetic / katoomba / bowral /
fitzroy after the fix, and **0 on all four** with the old sign re-derived on the same tree.

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

View File

@ -12,6 +12,7 @@
# 1. scaffold_check.mjs — scaffold + PRNG determinism law + lane-readiness matrix (F-owned)
# 2. citygen selfcheck — node web/js/citygen/selfcheck.js (Lane A ships it)
# 3. manifest validator — python3 pipeline/validate_manifest.py (Lane E ships it)
# 8. the R39 (v9 wave 1) gates — tools/qa/r39_address.mjs · r39_transmission.py · r39_runtime.py
#
# Browser-driven gates (determinism-PNG, 10-min soak, budget-HUD, ?noassets run) need the running
# game; they live in tools/soak.md + tools/shots.md and run once Lane B's index.html lands.
@ -140,6 +141,26 @@ if [ "$MATRIX_RUN" = 1 ]; then
fi
fi
# ── Gate 8 (Lane F, round 39): the v9 wave-1 gates ───────────────────────────
# 39.1 THE ADDRESS LAYER (node, no browser) · 39.4 THE TRANSMISSION PRE-PASS (validator control +
# the interior draw sweep against a fabricated glass asset) · 39.2 THE FOG / THE SIGN / THE RUMMAGE
# BIN (browser). Each ships its falsifiability control in the same run — see LANE_F_NOTES §39.
# --no-r39 skips the browser half on a fast determinism-only run; the node half always runs.
run_gate "R39 address layer (>=97% corpus · degrade-to-district · the wrong-shift red arm)" \
node tools/qa/r39_address.mjs
R39_SKIP=0; for a in "$@"; do [ "$a" = "--no-r39" ] && R39_SKIP=1; done
if [ "$R39_SKIP" = 1 ]; then
soft_skip "R39 transmission + runtime gates" "--no-r39"
elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
run_gate "R39 transmission pre-pass (validator hard-fail control · interior sweep vs a glass asset)" \
tools/.venv/bin/python tools/qa/r39_transmission.py --validator
run_gate "R39 runtime (THE SIGN · THE FOG both arms · THE RUMMAGE BIN, contents unarmed)" \
tools/.venv/bin/python tools/qa/r39_runtime.py
else
soft_skip "R39 transmission + runtime gates" "Playwright venv absent"
fi
# ── Summary ──────────────────────────────────────────────────────────────────
hr
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \

255
tools/qa/r39_address.mjs Normal file
View File

@ -0,0 +1,255 @@
#!/usr/bin/env node
// PROCITY Lane F — R39 §39.5 gate A: THE ADDRESS LAYER, gated on CORRECTNESS, not on coverage.
//
// Fable's brief: prove the layer resolves >=97% of corpus shops and FAILS LOUDLY on a town with no
// roads cache. Lane A's binding warning, which sets the whole shape of this gate:
//
// > COVERAGE IS NOT A PROXY FOR CORRECTNESS. A 3 m shift error RAISES adelaide from 89 to 98
// > resolved shops while every one of those names is wrong.
//
// So this harness measures coverage (because the brief asks for a number) and then spends the rest of
// its arms trying to make a HIGH-coverage build fail — by perturbing the one input that can rename a
// whole town, and checking two independent things fire:
// (a) `stats().shiftCheck` — address.js's recovered shift vs plan_osm's published `norm.shift`
// (two derivations that share no code), which trips on 0.01 m;
// (b) POINT MEMBERSHIP — a plan edge is two consecutive points of ONE simplified way snapped on a
// 3 m lattice, so the way it came from must CONTAIN both endpoints' snap keys. Implemented here
// independently of both address.js and selfcheck.js, over the shop-bearing edges only.
//
// The red arm is the deliverable: under a +3 m shift adelaide's coverage goes UP and membership
// disagreement goes from 0 to nearly every edge. A gate that reads coverage would have gone green.
//
// Run: node tools/qa/r39_address.mjs [--json OUT]
import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generatePlanOSM, generatePlan, createAddresses, STREET_TOLERANCE_M } from '../../web/js/citygen/index.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const TOWNS = join(ROOT, 'web', 'assets', 'towns');
const SEED = 20261990;
const CORPUS_FLOOR_PCT = 97; // Fable's brief
const SNAP = 3; // plan_osm.js's lattice — replicated on purpose; see below
let fails = 0;
const ok = (c, m) => { console.log(c ? ` \x1b[32m✓\x1b[0m ${m}` : ` \x1b[31m✗ FAIL\x1b[0m ${m}`); if (!c) fails++; };
const head = (m) => console.log(`\n\x1b[1m${m}\x1b[0m`);
const note = (m) => console.log(` \x1b[33m·\x1b[0m ${m}`);
// ── the independent control: which named way CONTAINS both endpoints of this edge? ───────────────
// Deliberately the same coupling Lane A's own control declares (it replicates plan_osm's SNAP): if
// the lift's snapping changes, this must be re-derived rather than silently trusted. It is a
// different QUESTION from address.js's ("which named way is nearest at five samples along it?"),
// which is what makes the two comparable rather than circular.
function membership(plan, cache, shift) {
const cosLat = Math.cos(cache.center.lat * Math.PI / 180);
const px = (lon) => (lon - cache.center.lon) * 111320 * cosLat;
const pz = (lat) => (lat - cache.center.lat) * 111320;
const k = (x, z) => `${Math.round(x / SNAP)},${Math.round(z / SNAP)}`;
const at = new Map(), name = [];
(cache.roads || []).forEach((rd, i) => {
if (!rd || !Array.isArray(rd.pts) || rd.pts.length < 2) { name.push(null); return; }
name.push(typeof rd.name === 'string' && rd.name.trim() ? rd.name.trim() : null);
for (const p of rd.pts) {
if (!Array.isArray(p)) continue;
const key = k(px(p[1]), pz(p[0]));
const s = at.get(key); if (s) s.add(i); else at.set(key, new Set([i]));
}
});
const nodeById = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const out = new Map();
for (const e of plan.streets.edges) {
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const A = at.get(k(a.x - shift.shx, a.z - shift.shz)), B = at.get(k(b.x - shift.shx, b.z - shift.shz));
if (!A || !B) continue;
const nms = [...new Set([...A].filter((w) => B.has(w)).map((w) => name[w]).filter(Boolean))];
if (nms.length) out.set(e.id, nms);
}
return out;
}
function shopEdgeVerdict(plan, addr, ctrl) {
const lotById = new Map(plan.lots.map((l) => [l.id, l]));
const fronts = new Set(plan.shops.map((s) => (lotById.get(s.lot) || {}).frontEdge));
let checked = 0, agree = 0, disagree = 0;
for (const [eid, nms] of ctrl) {
const got = addr.streetOf(eid);
if (!got || !fronts.has(eid)) continue;
checked++;
if (nms.includes(got)) agree++; else disagree++;
}
return { checked, agree, disagree };
}
const keys = existsSync(TOWNS)
? readdirSync(TOWNS).filter((f) => f.endsWith('.json') && f !== 'index.json').map((f) => f.replace(/\.json$/, '')).sort()
: [];
console.log(`\x1b[1mGATE R39 §39.5-A: THE ADDRESS LAYER\x1b[0m (${keys.length} caches · tolerance ${STREET_TOLERANCE_M} m · seed ${SEED})`);
if (!keys.length) { console.log(' no town caches on disk — nothing to gate'); process.exit(1); }
// ── 1. the corpus roll-up, measured here rather than quoted ──────────────────────────────────────
head('1. corpus resolution — my own count, town by town');
const roll = { towns: 0, shops: 0, street: 0, labelled: 0, edges: 0, edgesNamed: 0,
checked: 0, agree: 0, disagree: 0, shiftAgree: 0, ways: 0 };
const perTown = {};
for (const key of keys) {
const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8'));
const report = {};
const plan = generatePlanOSM(SEED, key, { cache, report });
const addr = createAddresses(plan, cache);
const st = addr.stats();
const ctrl = membership(plan, cache, report.shift);
const v = shopEdgeVerdict(plan, addr, ctrl);
const pct = st.shops ? (100 * st.shopsWithStreet) / st.shops : 0;
perTown[key] = { shops: st.shops, street: st.shopsWithStreet, pct: +pct.toFixed(1), supplier: st.supplier,
edges: st.edges, edgesNamed: st.edgesNamed, names: st.distinctStreets, ...v,
shiftCheck: createAddresses(plan, cache, { shift: report.shift }).stats().shiftCheck };
roll.towns++; roll.shops += st.shops; roll.street += st.shopsWithStreet; roll.labelled += st.shopsLabelled;
roll.edges += st.edges; roll.edgesNamed += st.edgesNamed;
roll.checked += v.checked; roll.agree += v.agree; roll.disagree += v.disagree;
if (perTown[key].shiftCheck === 'agree') roll.shiftAgree++;
if (st.supplier === 'ways') roll.ways++;
}
const pctCorpus = (100 * roll.street) / roll.shops;
const worst = Object.entries(perTown).sort((a, b) => a[1].pct - b[1].pct)[0];
const full = Object.values(perTown).filter((t) => t.pct === 100).length;
ok(pctCorpus >= CORPUS_FLOOR_PCT,
`corpus ${roll.street}/${roll.shops} shops resolve to a real street name (${pctCorpus.toFixed(1)}%) — floor ${CORPUS_FLOOR_PCT}%`);
note(`${roll.edgesNamed}/${roll.edges} edges named (${((100 * roll.edgesNamed) / roll.edges).toFixed(1)}%) · ` +
`100% on ${full}/${roll.towns} towns · worst ${worst[0]} ${worst[1].pct}%`);
ok(roll.ways === roll.towns, `all ${roll.ways}/${roll.towns} towns resolve through the 'ways' supplier`);
ok(roll.shiftAgree === roll.towns,
`the shift recovered by address.js agrees with plan_osm's published norm.shift on ${roll.shiftAgree}/${roll.towns} towns (0.01 m tolerance)`);
// ── 2. the correctness arm: zero wrong names, and the control is NOT vacuous ──────────────────────
head('2. wrong names — the independent point-membership control');
ok(roll.disagree === 0,
`${roll.agree}/${roll.checked} shop-bearing edges agree with the membership control, ${roll.disagree} disagree`);
ok(roll.checked > 800,
`the control actually checked ${roll.checked} edges — a control that quietly checks nothing is how a gate goes vacuous (R37)`);
// ── 3. FAILS LOUDLY WITH NO CACHE — degrade to district labels, never to a wrong name ────────────
head('3. no roads cache — degrade to district labels, never to a wrong name');
{
const key = 'katoomba_real';
const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8'));
const plan = generatePlanOSM(SEED, key, { cache });
for (const [what, arg] of [['null', null], ['undefined', undefined],
['names stripped (the pre-R39 state)',
{ ...cache, roads: (cache.roads || []).map((r) => ({ kind: r.kind, pts: r.pts })) }]]) {
const st = createAddresses(plan, arg).stats();
const a = createAddresses(plan, arg);
const wrong = a.cohort((l) => l.street !== null).length;
ok(st.supplier === 'district' && st.edgesNamed === 0 && wrong === 0,
`${key} with cache=${what}: supplier '${st.supplier}', ${st.edgesNamed} named edges, ${wrong} street names — visible in stats(), never a silent wrong name`);
}
// …and the degradation is USABLE: labels still come out, from the other supplier.
const a = createAddresses(plan, null);
const lab = a.cohort((l) => l.label !== null).length;
ok(lab > 0, `…and it still labels ${lab}/${plan.shops.length} shops from district+block (e.g. "${a.localityOf(plan.shops[0].id).label}") — a degrade, not an outage`);
// the synthetic is the same code path with no cache in sight, and must label EVERY shop
const syn = generatePlan(SEED);
const sa = createAddresses(syn, null), ss = sa.stats();
ok(ss.supplier === 'district' && sa.cohort((l) => l.label === null).length === 0,
`synthetic: supplier '${ss.supplier}', ${syn.shops.length}/${syn.shops.length} shops labelled, 0 nulls — one consumer contract, two suppliers`);
}
// ── 4. THE COVERAGE TRAP, swept rather than assumed. This is the arm the brief demands. ───────────
// A wrong shift is the one input that can rename an entire town, so it is the perturbation the gate
// is built around. Eight directions x three magnitudes on three towns, and for each: how many shops
// still resolve (COVERAGE), how many carry a name different from the truth (WRONGNESS), and whether
// the two independent alarms fire. The pairing is the finding: coverage and correctness move apart.
head('4. COVERAGE IS NOT CORRECTNESS — the wrong-shift sweep');
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.707, 0.707], [-0.707, 0.707], [0.707, -0.707], [-0.707, -0.707]];
const MAGS = [3, 8, 30];
const trap = {};
for (const key of ['adelaide_real', 'castlemaine_real', 'katoomba_real']) {
const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8'));
const report = {};
const plan = generatePlanOSM(SEED, key, { cache, report });
const truth = createAddresses(plan, cache, { shift: report.shift });
const tSt = truth.stats();
const ctrl = membership(plan, cache, report.shift);
const rows = [];
for (const m of MAGS) for (const [dx, dz] of DIRS) {
const bad = { shx: report.shift.shx + dx * m, shz: report.shift.shz + dz * m };
const a = createAddresses(plan, cache, { shift: bad });
const st = a.stats();
let changed = 0, invented = 0;
for (const s of plan.shops) {
const t = truth.localityOf(s.id).street, g = a.localityOf(s.id).street;
if (!g) continue;
if (t && t !== g) changed++; // the truth had a name and this build says another one
else if (!t) invented++; // the truth honestly said null and this build names it
}
rows.push({ m, dx, dz, street: st.shopsWithStreet, changed, invented, renamed: changed + invented,
shiftCheck: st.shiftCheck, ...shopEdgeVerdict(plan, a, ctrl) });
}
trap[key] = { true: tSt.shopsWithStreet, shops: tSt.shops, rows };
const byMag = MAGS.map((m) => {
const r = rows.filter((x) => x.m === m);
const best = r.reduce((p, q) => (q.street > p.street ? q : p));
const wrongest = r.reduce((p, q) => (q.disagree > p.disagree ? q : p));
return { m, maxStreet: best.street, maxChanged: Math.max(...r.map((x) => x.changed)),
maxInvented: Math.max(...r.map((x) => x.invented)),
maxDisagree: wrongest.disagree, checked: wrongest.checked };
});
trap[key].byMag = byMag;
console.log(` ${key}: truth ${tSt.shopsWithStreet}/${tSt.shops} shops resolved, 0 wrong`);
for (const b of byMag) {
const up = b.maxStreet - tSt.shopsWithStreet;
console.log(` off by ${String(b.m).padStart(2)} m (best of 8 directions): ${b.maxStreet} shops resolved ` +
`(${up > 0 ? `\x1b[31m+${up} — MORE than the truth\x1b[0m` : up}), up to ${b.maxChanged} shops given a DIFFERENT ` +
`name, ${b.maxInvented} given a name the truth honestly refused, ` +
`${b.maxDisagree}/${b.checked} shop-edges disagree with the membership control`);
}
}
{
const anyUp = Object.entries(trap).filter(([, t]) => t.byMag.some((b) => b.maxStreet > t.true));
ok(anyUp.length > 0,
`RED ARM: a wrong shift RAISES coverage on ${anyUp.length} of 3 towns — ` +
anyUp.map(([k, t]) => `${k} ${t.true}${Math.max(...t.byMag.map((b) => b.maxStreet))}`).join(' · ') +
` — a coverage gate goes GREENER on a broken build`);
const ade = trap['adelaide_real'].byMag.find((b) => b.m === 3);
ok(ade.maxInvented > 0 || ade.maxChanged > 0,
`…and the extra coverage is fabricated: ${ade.maxInvented} adelaide shops get a street name that the correctly-shifted build honestly refused ` +
`(${ade.maxChanged} get a different name outright). Coverage 89 → 98 is 9 borrowed names, not 9 corrections.`);
const allFire = Object.values(trap).every((t) => t.rows.every((r) => r.shiftCheck === 'DISAGREE'));
ok(allFire, `stats().shiftCheck fires DISAGREE on all ${Object.values(trap)[0].rows.length * 3} perturbations (8 directions x 3 magnitudes x 3 towns) — the cross-derivation arm is what this gate stands on`);
const memFires = Object.values(trap).some((t) => t.rows.some((r) => r.disagree > 0));
ok(memFires, `the membership control also discriminates: 0 wrong on the shipped tree, up to ` +
`${Math.max(...Object.values(trap).flatMap((t) => t.rows.map((r) => r.disagree)))} wrong under perturbation`);
// …and the honest limit of that second alarm, which matters for what this gate leans on.
const ade3 = trap['adelaide_real'].rows.filter((r) => r.m === 3);
note(`BUT the membership control does NOT catch the 3 m case: at 3 m adelaide reads ${Math.max(...ade3.map((r) => r.disagree))} disagreements ` +
`while inventing ${ade.maxInvented} names — because those shops front ways OSM leaves UNNAMED, so membership has no answer there and stays silent. ` +
`The only alarm that fires at 3 m is stats().shiftCheck. That is why this gate asserts the shift cross-derivation on all 23 towns and never the coverage.`);
// A's note says a 30 m error returns ZERO names on these three towns. It does not — measured below.
const at30 = Object.entries(trap).map(([k, t]) => [k, t.byMag.find((b) => b.m === 30)]);
note(`CORRECTION to LANE_A_NOTES §39 ("feed it a shift wrong by 30 m and castlemaine/katoomba/adelaide return 0 street names"): ` +
`measured here, 30 m keeps ${at30.map(([k, b]) => `${k} ${b.maxStreet}`).join(' · ')} names, and they are wrong ones ` +
`(up to ${at30.map(([, b]) => b.maxDisagree).join('/')} membership disagreements). A big shift does NOT degrade to null on a dense grid — ` +
`it lands you on the next street. Which is why the shift arm is exact-or-nothing and this gate never reads coverage.`);
}
// ── 5. the layer never writes to a plan (the reason it costs nothing) ─────────────────────────────
head('5. purity — createAddresses does not mutate the plan');
{
let dirty = 0;
for (const key of keys) {
const cache = JSON.parse(readFileSync(join(TOWNS, `${key}.json`), 'utf8'));
const plan = generatePlanOSM(SEED, key, { cache });
const before = JSON.stringify(plan);
createAddresses(plan, cache);
if (JSON.stringify(plan) !== before) dirty++;
}
ok(dirty === 0, `${keys.length}/${keys.length} towns byte-identical after the call (${dirty} mutated)`);
}
const out = { corpusPct: +pctCorpus.toFixed(2), roll, perTown, trap, fails };
if (process.argv.includes('--json')) writeFileSync(process.argv[process.argv.indexOf('--json') + 1], JSON.stringify(out, null, 1));
console.log();
if (fails) { console.log(`\x1b[31m${fails} FAIL\x1b[0m`); process.exit(1); }
console.log('\x1b[32mR39 §39.5-A GREEN\x1b[0m');

687
tools/qa/r39_runtime.py Normal file
View File

@ -0,0 +1,687 @@
#!/usr/bin/env python3
"""PROCITY Lane F — R39 §39.5 runtime gates: THE SIGN (B) · THE FOG (B) · THE RUMMAGE BIN (C).
Three gates, one browser, each with its falsifiability control demonstrated in the same run.
§39.5-SIGN index.html:428/:500 read `(-sin ry, -cos ry)` the BACK of the building since R8/R32.
The picture-free arm Lane B specified: AT THE SPAWN, the count of shops within 25 m that
are IN FRONT of the player must be > 0. It was 0 on 4 of 4 towns before. The red arm is
computed here from the plan by re-deriving the OLD spawn with the old sign and scoring
the same metric at it so the gate carries its own "before" on every run.
§39.5-FOG BOTH ARMS OR IT IS VACUOUS: unwalked shops hidden AND walked shops revealed, measured on
the same boot. Plus zero draws (default vs ?fog=0 at one pose, sim quiet), the save
round-trip and its rejects, and the null store on ?classic=1 / ?game=0 / ?fog=0.
§39.5-BIN The op shop's rummage bin: present and dug-able when armed, and CONTENTS UNARMED on the
boot every player gets (Lane C held the contents for John). Both arms, or the gate only
proves that a tub exists.
Run: tools/.venv/bin/python tools/qa/r39_runtime.py [--only sign,fog,bin] [--json OUT]
"""
import sys, os, json, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = int(os.environ.get('PROCITY_R39_RT_PORT', '8751'))
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
FOG_R = 25.0
fails, warns = [], []
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
def WARN(m): warns.append(m); print(f" \033[33m! WARN\033[0m {m}")
def OK(m): print(f" \033[32m✓\033[0m {m}")
def head(m): print(f"\n\033[1m{m}\033[0m")
def note(m): print(f" \033[33m·\033[0m {m}")
NOSTORE = r'''
import sys, http.server, functools
class H(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
super().end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])),
functools.partial(H, directory=sys.argv[2])).serve_forever()
'''
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
def serve(root, port):
proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(port), str(root)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(80):
if port_up(port): return proc
time.sleep(0.1)
proc.terminate(); raise SystemExit(f'could not serve on :{port}')
def new_page(p):
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errs = []
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
pg.on('pageerror', lambda e: errs.append(str(e)))
return b, pg, errs
def boot(pg, query):
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1' + (('&' + query) if query else ''))
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000)
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
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# THE SIGN
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# Both spawn algorithms, re-derived from the LIVE plan in the page: `sgn = +1` is index.html:506 as
# shipped after Lane B's fix, `sgn = -1` is what it read from R32 until this round. Then one scorer
# for both poses. Nothing is monkey-patched: the shipped pose is READ off the live player and asserted
# equal to the +1 derivation, which is what makes the -1 derivation a fair "before".
JS_SIGN = r"""
(FOG_R) => {
const P = window.PROCITY, plan = P.plan;
const lotById = new Map(plan.lots.map(l => [l.id, l]));
const spawnWith = (sgn) => {
const msBlocks = new Set((plan.blocks || []).filter(b => b.kind === 'mainstreet').map(b => b.id));
const doorOf = (s) => {
const l = lotById.get(s.lot); if (!l) return null;
const ry = l.ry || 0;
return { x: l.x + sgn * Math.sin(ry) * (l.d / 2 + 2.6), z: l.z + sgn * Math.cos(ry) * (l.d / 2 + 2.6), block: l.block };
};
let doors = plan.shops.map(doorOf).filter(Boolean);
const ms = doors.filter(d => msBlocks.has(d.block));
if (ms.length >= 2) doors = ms;
if (doors.length < 2) return null;
let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length;
let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; }
const ax = vx >= vz ? 'x' : 'z';
let stand = doors[0], bd = Infinity;
for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } }
let lo = Infinity, hi = -Infinity;
for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); }
const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1;
return { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) };
};
// the shopfront points the fog probe uses: facade centre + its outward normal (buildings.js's +Z)
const fronts = [];
for (const s of plan.shops) {
const l = lotById.get(s.lot); if (!l) continue;
const ry = l.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry);
fronts.push({ id: s.id, x: l.x + nx * (l.d / 2), z: l.z + nz * (l.d / 2), nx, nz });
}
const score = (pos) => {
if (!pos) return null;
let near = 0, front = 0;
for (const f of fronts) {
const dx = pos.x - f.x, dz = pos.z - f.z;
if (dx * dx + dz * dz > FOG_R * FOG_R) continue;
near++;
if (dx * f.nx + dz * f.nz > 0) front++;
}
return { near, front };
};
const live = { x: P.player.position.x, z: P.player.position.z };
const fixed = spawnWith(1), old = spawnWith(-1);
return {
town: P.townKey || 'synthetic',
shops: plan.shops.length,
live, fixed, old,
liveMatchesFixed: !!fixed && Math.hypot(live.x - fixed.x, live.z - fixed.z) < 0.01,
scoreLive: score(live), scoreFixed: score(fixed), scoreOld: score(old),
discovered: P.discovery ? P.discovery.store.counts().shops : null,
};
}
"""
def gate_sign(p, result):
head('GATE R39 §39.5-SIGN — at the spawn, are there shopfronts in front of you?')
towns = [('synthetic', ''), ('katoomba_real', 'plansrc=osm&town=katoomba_real'),
('bowral_real', 'plansrc=osm&town=bowral_real'), ('fitzroy_real', 'plansrc=osm&town=fitzroy_real')]
rows = {}
b, pg, errs = new_page(p)
try:
for name, q in towns:
boot(pg, q)
r = pg.evaluate(JS_SIGN, FOG_R)
rows[name] = r
print(f" {name:16s} shipped(+sin) at ({r['fixed']['x']:.1f}, {r['fixed']['z']:.1f}): "
f"{r['scoreFixed']['near']} shops within 25 m, \033[1m{r['scoreFixed']['front']} in front\033[0m"
f" · old(-sin) at ({r['old']['x']:.1f}, {r['old']['z']:.1f}): "
f"{r['scoreOld']['near']} within 25 m, \033[31m{r['scoreOld']['front']} in front\033[0m")
finally:
b.close()
result['sign'] = rows
live_ok = all(r['liveMatchesFixed'] for r in rows.values())
if live_ok: OK(f"the LIVE spawn equals the +sin derivation on {len(rows)}/{len(rows)} towns — the shipped shell is the thing being scored, not a re-implementation")
else: FAIL(f"the live spawn does not match the +sin derivation: {[(k, v['live'], v['fixed']) for k, v in rows.items() if not v['liveMatchesFixed']]}")
good = [k for k, r in rows.items() if r['scoreLive']['front'] > 0]
if len(good) == len(rows):
OK(f"THE ARM: shops in front of the player at spawn > 0 on {len(good)}/{len(rows)} towns — "
+ ' · '.join(f"{k} {r['scoreLive']['front']}/{r['scoreLive']['near']}" for k, r in rows.items()))
else:
FAIL(f"spawn faces nothing on {[k for k in rows if k not in good]} — the sign has flipped back")
bad = [k for k, r in rows.items() if r['scoreOld']['front'] == 0]
if len(bad) == len(rows):
OK(f"THE RED ARM, on this same tree: with the pre-R39 sign the identical metric reads ZERO in front on {len(bad)}/{len(bad)} towns — the gate discriminates")
else:
FAIL(f"the red arm does not go red on {[k for k in rows if k not in bad]} — this gate cannot fail, so it is not a gate")
disc = {k: r['discovered'] for k, r in rows.items()}
OK(f"…and the shell's own probe agrees at boot: shops discovered at spawn {disc}")
if errs: WARN(f"{len(errs)} console error(s) across the sign boots; first: {errs[0][:140]}")
else: OK('0 console errors across all four boots')
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# THE FOG
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# Walk the town by driving the SHIPPED probe (`discovery.probe`) from stations along the street graph,
# rather than trusting a throttled rAF loop in a headless browser to cover ground. The cadence itself
# is checked separately (a plain teleport-and-wait must also learn something).
JS_WALK = r"""
(n) => {
const P = window.PROCITY;
const plan = P.plan, nodes = new Map(plan.streets.nodes.map(x => [x.id, x]));
// A SHOPPER'S WALK, not the longest roads: take the edges the most shops front. On a real town the
// longest edges are rural arterials with nothing on them (katoomba: 5.8 km of them reveals 1 shop),
// which measures the walker, not the fog.
const lotById = new Map(plan.lots.map(l => [l.id, l]));
const perEdge = new Map();
for (const s of plan.shops) { const l = lotById.get(s.lot); if (!l) continue;
perEdge.set(l.frontEdge, (perEdge.get(l.frontEdge) || 0) + 1); }
const rank = new Map([...perEdge].sort((a, b) => b[1] - a[1]).slice(0, n).map(([id], i) => [id, i]));
const edges = plan.streets.edges.filter(e => rank.has(e.id)).map(e => {
const a = nodes.get(e.a), b = nodes.get(e.b);
return a && b ? { a, b, len: Math.hypot(b.x - a.x, b.z - a.z) } : null;
}).filter(Boolean);
let metres = 0, learned = 0, probes = 0;
for (const e of edges) {
const steps = Math.max(2, Math.ceil(e.len / 4));
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const pos = { x: e.a.x + (e.b.x - e.a.x) * t, z: e.a.z + (e.b.z - e.a.z) * t };
learned += P.discovery.probe(pos); probes++;
}
metres += e.len;
}
const c = P.discovery.store.counts();
return { metres: Math.round(metres), probes, learned, shops: c.shops, edges: c.edges,
stats: P.discovery.stats, view: P.minimap.view };
}
"""
# count pixels on the minimap canvas that EXACTLY match one of minimap.js's ten shop-type colours.
# An exact palette match, not a threshold: the shipped instrument for "is a shop drawn at all".
JS_MAPPX = r"""
() => {
const P = window.PROCITY;
const PAL = ['#8a5aa8','#8a7a5a','#c86aa0','#7a6a3a','#4a6ab0','#c4a028','#c4863a','#5a6a7a','#5a8a6a','#6b5a48'];
const want = new Set(PAL.map(h => ((parseInt(h.slice(1), 16) << 8) | 255) >>> 0));
const cv = document.querySelector('canvas#minimap, canvas.minimap') ||
[...document.querySelectorAll('canvas')].find(c => c !== P.renderer.domElement && c.width >= 600);
if (!cv) return { err: 'no minimap canvas' };
const g = cv.getContext('2d');
const d = g.getImageData(0, 0, cv.width, cv.height).data;
let n = 0, fnv = 0x811c9dc5 >>> 0;
for (let i = 0; i < d.length; i += 4) {
const v = (((d[i] << 16) | (d[i + 1] << 8) | d[i + 2]) << 8 | d[i + 3]) >>> 0;
if (want.has(v)) n++;
fnv = Math.imul((fnv ^ d[i]) >>> 0, 0x01000193) >>> 0;
fnv = Math.imul((fnv ^ d[i + 1]) >>> 0, 0x01000193) >>> 0;
fnv = Math.imul((fnv ^ d[i + 2]) >>> 0, 0x01000193) >>> 0;
}
return { px: n, w: cv.width, h: cv.height, fnv: fnv >>> 0 };
}
"""
JS_OPENMAP = r"""
() => { window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyM' })); }
"""
def gate_fog(p, result):
head('GATE R39 §39.5-FOG — hides what you have not walked, reveals what you have')
out = {}
for town, q in (('katoomba_real', 'plansrc=osm&town=katoomba_real'), ('synthetic', '')):
b, pg, errs = new_page(p)
try:
boot(pg, q)
fresh = pg.evaluate("""() => { const P = window.PROCITY;
return { fog: !!P.knownStore, key: P.fogKey, total: P.plan.shops.length,
known: P.knownStore ? P.knownStore.counts() : null, view: P.minimap.view,
addr: P.addresses ? P.addresses.stats().supplier : null,
exportHasKnown: 'known' in JSON.parse(P.game.export()) }; }""")
pg.evaluate(JS_OPENMAP); pg.wait_for_timeout(400)
px_fresh = pg.evaluate(JS_MAPPX)
# which shops are FAR from the spawn? they must be unknown.
far = pg.evaluate("""() => { const P = window.PROCITY, p = P.player.position;
const lot = new Map(P.plan.lots.map(l => [l.id, l]));
let farTotal = 0, farKnown = 0;
for (const s of P.plan.shops) { const l = lot.get(s.lot); if (!l) continue;
if (Math.hypot(l.x - p.x, l.z - p.z) < 120) continue;
farTotal++; if (P.knownStore.hasShop(s.id)) farKnown++; }
return { farTotal, farKnown }; }""")
walk = pg.evaluate(JS_WALK, 12)
pg.wait_for_timeout(400)
px_walk = pg.evaluate(JS_MAPPX)
save = pg.evaluate("""() => { const P = window.PROCITY, g = P.game;
const blob = g.export(); const obj = JSON.parse(blob);
const before = P.knownStore.counts();
const ids = P.knownStore.shopIds().slice(0, 5);
// round trip
const rt = g.import(blob);
const after = P.knownStore.counts();
// rejects each must leave live state untouched
const rej = [];
const mut = (f) => { const o = JSON.parse(blob); f(o); return JSON.stringify(o); };
const cases = {
'string id': mut(o => { o.known[P.fogKey].s[0] = 'x'; }),
'negative id': mut(o => { o.known[P.fogKey].s[0] = -1; }),
'float id': mut(o => { o.known[P.fogKey].s[0] = 1.5; }),
'town = array':mut(o => { o.known[P.fogKey] = [1,2,3]; }),
'known = array':mut(o => { o.known = [1,2,3]; }),
'8193 ids': mut(o => { o.known[P.fogKey].s = Array.from({length: 8193}, (_, i) => i); }),
'65 towns': mut(o => { for (let i = 0; i < 65; i++) o.known['t' + i] = { s: [1], e: [] }; }),
'empty key': mut(o => { o.known[''] = { s: [1], e: [] }; }),
};
for (const [why, blb] of Object.entries(cases)) {
const took = g.import(blb);
const now = P.knownStore.counts();
rej.push({ why, took, intact: now.shops === after.shops && now.edges === after.edges });
}
return { keys: Object.keys(obj), hasKnown: 'known' in obj,
ids: obj.known ? obj.known[P.fogKey].s.length : 0,
edgeIds: obj.known ? obj.known[P.fogKey].e.length : 0,
bytes: blob.length, before, after, roundTrip: rt, sample: ids, rejects: rej }; }""")
# THE SIDE TEST'S RED ARM, on the same boot: stand 5 m BEHIND each unknown shopfront and
# probe. A radius-only fog hands every one of them over through the back wall.
behind = pg.evaluate("""() => { const P = window.PROCITY;
const lot = new Map(P.plan.lots.map(l => [l.id, l]));
let tried = 0, learned = 0;
const b0 = P.discovery.stats.rejectedBehind;
for (const s of P.plan.shops) {
if (P.knownStore.hasShop(s.id)) continue;
const l = lot.get(s.lot); if (!l) continue;
const ry = l.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry);
const fx = l.x + nx * (l.d / 2), fz = l.z + nz * (l.d / 2);
tried++;
P.discovery.probe({ x: fx - nx * 5, z: fz - nz * 5 }); // 5 m behind its own facade
if (P.knownStore.hasShop(s.id)) learned++;
}
return { tried, learned, rejected: P.discovery.stats.rejectedBehind - b0 }; }""")
out[town] = {'fresh': fresh, 'far': far, 'walk': walk, 'save': save, 'behind': behind,
'pxFresh': px_fresh, 'pxWalk': px_walk, 'errs': len(errs)}
finally:
b.close()
# THE DELTA LAW, taken where it can actually be taken — and in its OWN browser, because a
# save written by the walk above lives in localStorage and would be re-exported verbatim
# (my first cut read `known` off a ?fog=0 boot that had simply LOADED the fogged save).
# Since the +sin spawn fix the DEFAULT boot discovers 4 shops before the player touches a key,
# so LANE_B_NOTES §39's "a fresh export has no `known` key" is now true of ?fog=0, not of a
# default boot. The claim the law is actually about — a boot that learns NOTHING writes the
# bytes it always wrote — is what this measures.
b, pg, _ = new_page(p)
try:
boot(pg, q + ('&' if q else '') + 'fog=0')
out[town]['nofog'] = pg.evaluate("""() => { const P = window.PROCITY;
const o = JSON.parse(P.game.export());
return { store: !!P.knownStore, keys: Object.keys(o).sort(), hasKnown: 'known' in o }; }""")
finally:
b.close()
result['fog'] = out
for town, r in out.items():
f, w, s = r['fresh'], r['walk'], r['save']
print(f"\n \033[1m{town}\033[0m {f['total']} shops · fogKey {f['key']}")
print(f" fresh boot : {f['known']['shops']} shops / {f['known']['edges']} segments known · "
f"map frame {w['view']['span']:.0f} m at walk-end (fresh {f['view']['span']:.0f} m) · "
f"lot {f['view']['lotPx']['w']}x{f['view']['lotPx']['d']} px (full-plan {f['view']['lotPxFull']['w']}x{f['view']['lotPxFull']['d']} px) · "
f"{r['pxFresh']['px']} px of shop colour on the map")
print(f" after {w['metres']:>5} m walked ({w['probes']} probes): {w['shops']} shops / {w['edges']} segments · "
f"{r['pxWalk']['px']} px of shop colour · {w['stats']['rejectedBehind']} in-radius rejections through a back wall "
f"({w['stats']['behindOnly']} shops refused and never learned from the front)")
# ARM 1 — HIDES
if f['known']['shops'] < f['total'] and r['far']['farKnown'] == 0 and r['far']['farTotal'] > 0:
OK(f"{town} ARM 1 (HIDES): {f['known']['shops']}/{f['total']} known at spawn, and 0 of the {r['far']['farTotal']} shops more than 120 m away are known")
else:
FAIL(f"{town} ARM 1: the fog is not hiding — fresh {f['known']['shops']}/{f['total']}, far known {r['far']}")
# ARM 2 — REVEALS
if w['shops'] > f['known']['shops'] and w['edges'] > f['known']['edges']:
OK(f"{town} ARM 2 (REVEALS): walking {w['metres']} m took it to {w['shops']} shops / {w['edges']} segments (+{w['shops'] - f['known']['shops']} / +{w['edges'] - f['known']['edges']})")
else:
FAIL(f"{town} ARM 2: walking revealed nothing — {f['known']['shops']}{w['shops']} shops")
# ARM 3 — the SIDE TEST's red arm: a radius-only fog would hand these over through the wall
bh = r['behind']
if bh['tried'] > 0 and bh['learned'] == 0 and bh['rejected'] > 0:
OK(f"{town} ARM 3 (the FRONT test): standing 5 m BEHIND each of the {bh['tried']} still-unknown shopfronts and probing learns "
f"\033[1m{bh['learned']}\033[0m of them — {bh['rejected']} refusals. A radius-only fog would have handed over all {bh['tried']}.")
else:
FAIL(f"{town} ARM 3: the side test does not refuse through a back wall — {bh}")
note(f"{town} on the shopper's walk itself the side test refused {w['stats']['rejectedBehind']} times "
f"({w['stats']['behindOnly']} shops never learned from the front) — free where a shopper stands, decisive behind the buildings")
# the map draws the fog
if r['pxWalk']['px'] != r['pxFresh']['px']:
OK(f"{town} the MAP moves with the ledger: {r['pxFresh']['px']}{r['pxWalk']['px']} px of shop colour (the frame grows too, so this is a change, not a monotone)")
else:
FAIL(f"{town} the map drew the same pixels before and after the walk — it is not reading the ledger")
# save — the delta law
if not r['nofog']['hasKnown'] and not r['nofog']['store']:
OK(f"{town} THE DELTA LAW: a boot that learns nothing (?fog=0) exports {r['nofog']['keys']} — no `known` key, the pre-R39 bytes")
else:
FAIL(f"{town} ?fog=0 wrote a `known` key: {r['nofog']}")
if f['exportHasKnown']:
note(f"{town} on the DEFAULT boot the export DOES carry `known` from the first frame — the +sin spawn now puts you in front of "
f"{f['known']['shops']} shopfronts, so LANE_B_NOTES §39's \"a fresh export has no `known` key\" is true of ?fog=0 and no longer of a default boot")
if s['hasKnown'] and s['ids'] == w['shops'] and s['edgeIds'] == w['edges'] and s['roundTrip'] and s['after'] == s['before']:
OK(f"{town} save round-trip: {s['ids']} shop ids + {s['edgeIds']} edge ids, {s['bytes']:,} B, export→import restores the same counts")
else:
FAIL(f"{town} save round-trip wrong: {s}")
bad = [x for x in s['rejects'] if x['took'] or not x['intact']]
if not bad:
OK(f"{town} all {len(s['rejects'])} corrupt-save arms REJECTED and live state untouched: " + ', '.join(x['why'] for x in s['rejects']))
else:
FAIL(f"{town} corrupt saves accepted or state clobbered: {bad}")
# ── the null store: classic / game=0 / fog=0 ──────────────────────────────────────────────────
head('the null arm — ?classic=1 / ?game=0 / ?fog=0 are the pre-R39 map by construction')
nulls = {}
b, pg, errs = new_page(p)
try:
for q, name in (('classic=1', 'classic'), ('game=0', 'game0'), ('fog=0', 'fog0'), ('', 'default')):
boot(pg, 'plansrc=osm&town=katoomba_real' + (('&' + q) if q else ''))
pg.evaluate(JS_OPENMAP); pg.wait_for_timeout(400)
nulls[name] = pg.evaluate("""() => { const P = window.PROCITY;
return { store: !!P.knownStore, fog: P.minimap.view.fog, flagFog: P.flags.fog,
span: P.minimap.view.span, lot: P.minimap.view.lotPx }; }""")
nulls[name]['map'] = pg.evaluate(JS_MAPPX)
finally:
b.close()
result['fogNull'] = nulls
off = [k for k in ('classic', 'game0', 'fog0') if not nulls[k]['store'] and not nulls[k]['fog']]
if len(off) == 3:
OK(f"null store on all three ({', '.join(off)}) — minimap.js sees `known === null`, which is the pre-R39 map with no flag test in the file")
else:
FAIL(f"a fog store leaked into a null arm: {nulls}")
if nulls['game0']['map']['fnv'] == nulls['fog0']['map']['fnv']:
OK(f"?game=0 and ?fog=0 draw a PIXEL-IDENTICAL map (fnv {nulls['fog0']['map']['fnv']:#010x}) — two different routes to the same null store")
else:
FAIL(f"?game=0 and ?fog=0 draw different maps: {nulls['game0']['map']['fnv']:#010x} vs {nulls['fog0']['map']['fnv']:#010x}")
if nulls['default']['map']['fnv'] != nulls['fog0']['map']['fnv']:
OK(f"…and the DEFAULT boot's map differs from both (fnv {nulls['default']['map']['fnv']:#010x}) — the hash is sensitive to the map's content, so the equality above means something")
else:
FAIL('the fogged map hashes the same as the un-fogged one — the pixel comparison is vacuous')
note(f"katoomba at the shipped full-plan transform draws {nulls['fog0']['map']['px']} px of shop colour "
f"(lot {nulls['fog0']['lot']['w']}x{nulls['fog0']['lot']['d']} px) against the fogged map's "
f"{nulls['default']['map']['px']} px at lot {nulls['default']['lot']['w']}x{nulls['default']['lot']['d']} px")
# ── zero draws ────────────────────────────────────────────────────────────────────────────────
head('zero draws — the fog is information design and must cost nothing on the street')
# Each arm gets its OWN freshly launched browser, and the pair is run in BOTH orders. My first cut
# booted default-then-?fog=0 in ONE browser and read a 12k triangle gap at identical draw counts;
# order is a confound (the second boot has a warm HTTP cache, so more of the asset fleet has
# resolved by sample time) and reversing it is the only way to see that. Each boot is sampled
# DRAINED — teleport, wait for chunks.pending == 0, settle, re-teleport at the same pose to read —
# and three times over, so the arm carries its own noise floor.
def run_arm(name, pose):
q = '' if name == 'default' else 'fog=0'
b, pg, _ = new_page(p)
try:
boot(pg, 'roster=v1&pop=0&gigs=0' + (('&' + q) if q else ''))
pg.evaluate("() => window.DBG.setSegment(2)")
if pose is None:
pose = pg.evaluate("() => [window.PROCITY.player.position.x, window.PROCITY.player.position.z]")
def settled(x, z, yaw, n=3):
pg.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [x, z, yaw])
try: pg.wait_for_function("() => window.PROCITY.chunks.pending === 0", timeout=25000)
except Exception: pass
reads = []
for _ in range(n):
pg.wait_for_timeout(1800)
reads.append(pg.evaluate("""([x, z, y]) => { window.DBG.teleport(x, z, y); const i = window.DBG.info();
return { d: i.drawCalls, t: i.tris, chunks: i.chunks }; }""", [x, z, yaw]))
last = reads[-1]
return {**last, 'dRange': [min(r['d'] for r in reads), max(r['d'] for r in reads)],
'tRange': [min(r['t'] for r in reads), max(r['t'] for r in reads)]}
got = {'a': settled(pose[0], pose[1], 0), 'b': settled(pose[0], pose[1], 3.14159265 / 2)}
# non-vacuity: prove the two arms really are two arms before comparing their counters
got['fogOn'] = pg.evaluate("() => ({ flag: window.PROCITY.flags.fog, store: !!window.PROCITY.knownStore })")
pg.evaluate(JS_OPENMAP); pg.wait_for_timeout(400)
got['mapMode'] = pg.evaluate("() => { const i = window.DBG.info(); return { d: i.drawCalls, t: i.tris, mode: i.mode }; }")
return got, pose
finally:
b.close()
zero, pose = {}, None
for run, order in enumerate((('default', 'fog0'), ('fog0', 'default'))):
for name in order:
zero[f'{name}#{run}'], pose = run_arm(name, pose)
result['fogZero'] = zero
arms_differ = all(zero[f'default#{r}']['fogOn']['flag'] and zero[f'default#{r}']['fogOn']['store'] and
not zero[f'fog0#{r}']['fogOn']['flag'] and not zero[f'fog0#{r}']['fogOn']['store'] for r in (0, 1))
if arms_differ:
OK('the two arms ARE two arms: fog flag on + store present on the default boots, off + null on the ?fog=0 boots (without this, "identical counters" is two identical boots)')
else:
FAIL(f"the zero-draw arms are not distinguishable: {[(k, v['fogOn']) for k, v in zero.items()]}")
draws_same, tris_same = True, True
for run in (0, 1):
d0, d1 = zero[f'default#{run}'], zero[f'fog0#{run}']
for k, lab in (('a', 'yaw 0 '), ('b', 'yaw 90')):
print(f" run {run} ({'default first' if run == 0 else '?fog=0 first '}) {lab}: "
f"default {d0[k]['d']} draws {d0[k]['dRange']} / {d0[k]['t']:,} tris {d0[k]['tRange']} · "
f"?fog=0 {d1[k]['d']} draws {d1[k]['dRange']} / {d1[k]['t']:,} tris {d1[k]['tRange']}")
if d0[k]['d'] != d1[k]['d']: draws_same = False
if d0[k]['t'] != d1[k]['t']: tris_same = False
if draws_same and tris_same:
OK('identical in BOTH counters, both yaws, both orders, four freshly launched browsers — the fog costs +0 draws and +0 triangles on the street')
elif draws_same:
OK(f"DRAWS identical in both orders at both yaws — the budget's own quantity does not move")
# is the triangle gap the FLAG or the ORDER? if it follows the order, it is cache warmth.
g0 = zero['default#0']['a']['t'] - zero['fog0#0']['a']['t']
g1 = zero['default#1']['a']['t'] - zero['fog0#1']['a']['t']
if g0 * g1 < 0:
OK(f"…and the triangle gap FOLLOWS THE ORDER, not the flag: {g0:+,} when default boots first, {g1:+,} when ?fog=0 does. "
f"The second browser has a warm HTTP cache and more of the asset fleet resolved by sample time. Not the fog.")
else:
WARN(f"triangles differ in the same direction in both orders ({g0:+,} / {g1:+,} at yaw 0) — not explained by cache warmth; "
f"the fog touches no scene graph, so this wants a look, but it is not a draw and not the budget's quantity")
else:
FAIL(f"the fog moved the DRAW counter: {zero}")
OK(f"map mode reads {zero['default#0']['mapMode']['d']} draws (mode '{zero['default#0']['mapMode']['mode']}') — the frame loop never calls composer.render() in the map branch, so the counter cannot move")
# ══════════════════════════════════════════════════════════════════════════════════════════════════
# THE RUMMAGE BIN
# ══════════════════════════════════════════════════════════════════════════════════════════════════
JS_BIN_ARMED = r"""
async ([arm, glb]) => {
const C = window.PROCITY_C;
const ARCH = C.ARCHETYPE_KEYS;
const out = { rooms: 0, withTub: 0, withBin: 0, carves: 0, pathOK: 0, deterministic: 0,
binless: [], reach: [], reachDist: [], digPlaces: 0 };
const manifest = glb ? await fetch('assets/manifest.json').then(r => r.ok ? r.json() : null).catch(() => null) : null;
// 4-connected flood over walkable cells from the room spawn layout.js's own reachability, redone
// here so "reachable" is a measurement and not a flag the builder set about itself.
const nearestReachable = (dbg, x, z) => {
const g = dbg.grid;
const idx = (cx, cz) => cz * g.cols + cx;
const start = Array.isArray(dbg.spawnCell) ? idx(dbg.spawnCell[0], dbg.spawnCell[1]) : dbg.spawnCell;
const seen = new Uint8Array(g.cols * g.rows);
const st = [start]; seen[start] = 1;
let best = Infinity;
while (st.length) {
const i = st.pop(), cx = i % g.cols, cz = (i / g.cols) | 0;
const wx = (cx + 0.5) * g.cw - g.W / 2, wz = (cz + 0.5) * g.cd - g.D / 2;
const d = Math.hypot(wx - x, wz - z); if (d < best) best = d;
for (const [dx, dz] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nx = cx + dx, nz = cz + dz;
if (nx < 0 || nz < 0 || nx >= g.cols || nz >= g.rows) continue;
const j = idx(nx, nz);
if (seen[j] || g.occ[j] !== 0) continue;
seen[j] = 1; st.push(j);
}
}
return best;
};
const opts = arm ? { fittingOpts: { rummageBin: { dig: true } } } : {};
for (const a of ARCH) {
for (let s = 0; s < 6; s++) {
const shop = { id: 'g' + s, type: 'opshop', seed: 1990 + s * 7717, storeys: 1 };
const base = Object.assign({ archetype: a, useGLB: !!glb }, glb ? { manifest } : {}, opts);
const r = C.buildInterior(shop, C.THREE, base);
const r2 = C.buildInterior(shop, C.THREE, base);
out.rooms++;
const tubs = (r.placement || []).filter(p => p.kind === 'rummageBin');
let bin = 0;
r.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bin++; });
if (tubs.length) out.withTub++; else out.binless.push(a + '/' + s);
if (bin) out.withBin++;
if (r.pathOK !== false) out.pathOK++;
if (r.carved) out.carves++;
out.digPlaces += (r.places || []).filter(m => m.userData && m.userData.kind === 'bin').length;
if (tubs.length && r._debug) out.reachDist.push(+nearestReachable(r._debug, tubs[0].x, tubs[0].z).toFixed(2));
// determinism: the placement summary is the deep-equal instrument the room already publishes
if (JSON.stringify(r.placement) === JSON.stringify(r2.placement)) out.deterministic++;
out.reach.push({ a, s, tub: tubs.length, bin, tubKind: tubs.length ? tubs[0].kindName : null,
fw: tubs.length ? tubs[0].fw : null, fd: tubs.length ? tubs[0].fd : null });
r.dispose(); r2.dispose();
}
}
return out;
}
"""
def gate_bin(p, result):
head('GATE R39 §39.5-BIN — the op shop can be dug, and the contents did NOT ship')
b, pg, errs = new_page(p)
res = {}
try:
# ── (a) the DEFAULT BOOT — the one every player gets. Furniture, no contents. ─────────────
boot(pg, '')
res['street'] = pg.evaluate("""() => {
const P = window.PROCITY, D = window.DBG;
D.setSegment(2);
const s = (P.plan.shops || []).find(x => x.type === 'opshop' && P.isOpen(x));
if (!s) return { why: 'no open op shop on the default boot' };
D.enterShop(s.id);
const cur = P.interiorMode.current;
let bins = 0;
cur.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins++; });
// the tub, identified off the room's OWN placement summary (kind is the recipe's fitting
// kind) not a bounding-box guess. Then stand in front of it and press E anyway.
const spec = (cur.placement || []).find(p => p.kind === 'rummageBin');
let opened = null, world = null;
if (spec) {
world = cur.group.localToWorld(new P.THREE.Vector3(spec.x, 0.76, spec.z));
P.camera.position.set(world.x, 1.6, world.z + 1.3); P.camera.lookAt(world.x, world.y, world.z);
P.camera.updateMatrixWorld();
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }));
opened = !!P.interiorMode.digActive;
}
const digPlaces = (cur.places || []).filter(m => m.userData && m.userData.kind === 'bin').length;
const fittings = (cur.placement || []).map(x => x.kind);
D.exitShop();
return { shop: s.name, bins, tub: !!spec, tubKind: spec ? spec.kindName : null,
opened, digPlaces, fittings }; }""")
r = res['street']
if r.get('why'): FAIL(f"bin/default: {r['why']}")
else:
if r['tub'] and r['bins'] == 0 and r['digPlaces'] == 0 and r['opened'] is False:
OK(f"DEFAULT BOOT (\"{r['shop']}\", fittings {r['fittings']}): the tub IS in the room and it is `kind:'{r['tubKind']}'` — "
f"0 `kind:'bin'`, 0 dig targets, and standing in front of it and pressing E opens nothing. "
f"THE CONTENTS DID NOT SHIP, which is how Lane C held them for John.")
else:
FAIL(f"bin/default: contents may have shipped — tub {r['tub']} kind '{r['tubKind']}', kind:'bin' {r['bins']}, dig targets {r['digPlaces']}, E opened {r['opened']}")
finally:
b.close()
# ── (b) ARMED, through interior_test.html's own opt ────────────────────────────────────────────
b, pg, errs2 = new_page(p)
try:
for arm, glb, label in ((False, False, 'default · GLB off'), (True, False, 'ARMED · GLB off'),
(False, True, 'default · GLB on'), (True, True, 'ARMED · GLB on')):
pg.goto(f'{HOST}/interior_test.html?localdepot=1')
pg.wait_for_function('() => !!window.PROCITY_C', timeout=30000)
pg.wait_for_timeout(400)
res[label] = pg.evaluate(JS_BIN_ARMED, [arm, glb])
g = res[label]
rd = g['reachDist']
print(f" {label:18s} {g['rooms']} rooms: tub {g['withTub']}/{g['rooms']} · kind:'bin' {g['withBin']}/{g['rooms']} · "
f"dig targets {g['digPlaces']} · pathOK {g['pathOK']}/{g['rooms']} · carves {g['carves']} · "
f"deterministic {g['deterministic']}/{g['rooms']} · nearest reachable cell "
f"{min(rd):.2f}{max(rd):.2f} m" if rd else '')
rooms = sum(res[k]['rooms'] for k in res if k.startswith(('default ·', 'ARMED')))
dd = [res['default · GLB off'], res['default · GLB on']]
aa = [res['ARMED · GLB off'], res['ARMED · GLB on']]
n = sum(x['rooms'] for x in aa)
if all(x['withTub'] == x['rooms'] for x in dd + aa):
OK(f"the tub lands in {n}/{n} armed and {n}/{n} default op-shop rooms (6 archetypes x 6 seeds x GLB on/off) — "
f"Lane C's fallbackZones/`anywhere` machinery does what it says (it landed in 11 of 24 without it)")
else:
FAIL(f"the tub is missing from rooms — armed misses {[x['binless'] for x in aa]}, default misses {[x['binless'] for x in dd]}")
if all(x['withBin'] == 0 for x in dd) and all(x['withBin'] == x['rooms'] for x in aa):
OK(f"THE ARMING IS THE ONLY DIFFERENCE: 0/{n} default rooms carry a `kind:'bin'`, {n}/{n} armed ones do — same tub, one opt, and the opt is OFF in the shipped recipe")
else:
FAIL(f"the dig gate is not the switch: default bins {[x['withBin'] for x in dd]}, armed {[x['withBin'] for x in aa]}")
if all(x['digPlaces'] == 0 for x in dd) and all(x['digPlaces'] == x['rooms'] for x in aa):
OK(f"…and the AIM agrees: 0 `places` entries of kind 'bin' across the {n} default rooms, {n} across the armed ones — the dig cannot resolve to a tub that is not armed")
else:
FAIL(f"places/kind:'bin' wrong: default {[x['digPlaces'] for x in dd]}, armed {[x['digPlaces'] for x in aa]}")
if all(x['pathOK'] == x['rooms'] and x['carves'] == 0 for x in aa + dd):
OK(f"pathOK {n}/{n} on every arm and ZERO corridor carves — the tub never blocks the room it lands in")
else:
FAIL(f"rooms unwalkable or carved: {[(x['pathOK'], x['carves']) for x in aa + dd]}")
if all(x['deterministic'] == x['rooms'] for x in aa + dd):
OK(f"deterministic {n}/{n}: two builds of the same seed produce a byte-equal placement summary on both arms")
else:
FAIL(f"non-deterministic placement: {[x['deterministic'] for x in aa + dd]}")
allrd = [d for x in aa for d in x['reachDist']]
if allrd and max(allrd) < 2.5:
OK(f"REACHABLE {len(allrd)}/{len(allrd)}: the nearest walkable cell flood-reachable FROM THE ROOM SPAWN sits "
f"{min(allrd):.2f}{max(allrd):.2f} m from the tub, against the dig's 2.5 m proximity / 3.2 m aimed reach")
else:
FAIL(f"the tub is out of reach in some rooms: {sorted(allrd)[-5:] if allrd else 'no measurements'}")
finally:
b.close()
result['bin'] = res
if errs2: WARN(f"{len(errs2)} console error(s) on the interior harness; first: {errs2[0][:140]}")
else: OK('0 console errors across the bin arms')
def main():
from playwright.sync_api import sync_playwright
only = None
if '--only' in sys.argv: only = set(sys.argv[sys.argv.index('--only') + 1].split(','))
out_json = sys.argv[sys.argv.index('--json') + 1] if '--json' in sys.argv else None
result = {}
proc = serve(ROOT / 'web', PORT)
try:
with sync_playwright() as p:
if not only or 'sign' in only: gate_sign(p, result)
if not only or 'fog' in only: gate_fog(p, result)
if not only or 'bin' in only: gate_bin(p, result)
finally:
proc.terminate()
if out_json: pathlib.Path(out_json).write_text(json.dumps(result, indent=1, default=str))
print()
print(f"R39 runtime gates: {len(fails)} fail · {len(warns)} warn")
if fails:
for f in fails: print(f" \033[31m✗\033[0m {f}")
return 1
print('\033[32mR39 RUNTIME GATES GREEN\033[0m')
return 0
if __name__ == '__main__':
sys.exit(main())

126
tools/qa/r39_shots.py Normal file
View File

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""PROCITY Lane F — R39: RE-BASELINE the two frames that photograph the spawn pose.
Lane B's `+sin` fix moved the non-classic spawn (`index.html:500`, the R32 cluster-pose). Two of this
lane's picture baselines were taken at the OLD pose and are therefore photographs of the defect:
· `docs/shots/v7_tour/02_the_strip.png` "the cluster-pose spawn: midday on the main street"
· `docs/shots/v7_beta/first_five_splash.png` the R35 first-five-minutes splash, shot at the spawn
Those two files are **not overwritten**: they are artefacts of the tagged v7.0 epoch and re-shooting
them on a v9 tree would falsify a release record. The re-baseline lands in `docs/shots/laneF_r39/`
with its own BEFORE (the same tree, camera parked at the old `-sin` pose), so the pair is a
comparison rather than a claim.
Run: tools/.venv/bin/python tools/qa/r39_shots.py
"""
import sys, os, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
OUT = ROOT / 'docs' / 'shots' / 'laneF_r39'
PORT = int(os.environ.get('PROCITY_R39_SHOT_PORT', '8761'))
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
NOSTORE = r'''
import sys, http.server, functools
class H(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cache-Control', 'no-store'); super().end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])),
functools.partial(H, directory=sys.argv[2])).serve_forever()
'''
# both spawn derivations, from the live plan — identical to tools/qa/r39_runtime.py's JS_SIGN
JS_POSES = r"""
() => {
const P = window.PROCITY, plan = P.plan;
const lotById = new Map(plan.lots.map(l => [l.id, l]));
const spawnWith = (sgn) => {
const ms = new Set((plan.blocks || []).filter(b => b.kind === 'mainstreet').map(b => b.id));
const doorOf = (s) => { const l = lotById.get(s.lot); if (!l) return null;
const ry = l.ry || 0;
return { x: l.x + sgn * Math.sin(ry) * (l.d / 2 + 2.6), z: l.z + sgn * Math.cos(ry) * (l.d / 2 + 2.6), block: l.block }; };
let doors = plan.shops.map(doorOf).filter(Boolean);
const m = doors.filter(d => ms.has(d.block)); if (m.length >= 2) doors = m;
if (doors.length < 2) return null;
let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length;
let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; }
const ax = vx >= vz ? 'x' : 'z';
let stand = doors[0], bd = Infinity;
for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } }
let lo = Infinity, hi = -Infinity;
for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); }
const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1;
return { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) };
};
return { fixed: spawnWith(1), old: spawnWith(-1) };
}
"""
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
def main():
from playwright.sync_api import sync_playwright
OUT.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(80):
if port_up(PORT): break
time.sleep(0.1)
shots = []
try:
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
for town, q, seg, tag in (('synthetic', '', 2, 'the_strip'),
('katoomba_real', 'plansrc=osm&town=katoomba_real', 2, 'the_strip_katoomba')):
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&roster=v1&pop=0' + (('&' + q) if q else ''))
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
pg.wait_for_timeout(2500)
poses = pg.evaluate(JS_POSES)
pg.evaluate('(s) => window.DBG.setSegment(s)', seg)
for which in ('old', 'fixed'):
pz = poses[which]
pg.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']])
try: pg.wait_for_function('() => window.PROCITY.chunks.pending === 0', timeout=20000)
except Exception: pass
pg.wait_for_timeout(2000)
pg.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']])
name = f"{tag}-{'BEFORE-minus-sin' if which == 'old' else 'AFTER-plus-sin'}.png"
pg.screenshot(path=str(OUT / name))
shots.append(name)
print(f" {name} @ ({pz['x']:.1f}, {pz['z']:.1f}) yaw {pz['yaw']:.3f}")
# the R35 first-five frame: a FRESH game at the fixed spawn, splash + hunt line visible
b2 = p.chromium.launch()
pg2 = b2.new_page(viewport={'width': 1280, 'height': 720})
pg2.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&roster=v1&pop=0')
pg2.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000)
pg2.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
pg2.wait_for_timeout(2500)
pz = pg2.evaluate(JS_POSES)['fixed']
pg2.evaluate('() => window.DBG.setSegment(2)')
pg2.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']])
try: pg2.wait_for_function('() => window.PROCITY.chunks.pending === 0', timeout=20000)
except Exception: pass
pg2.wait_for_timeout(2000)
pg2.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']])
hunt = pg2.evaluate("""() => { const h = document.getElementById('pc-hunt');
return h ? { shown: h.style.display !== 'none', text: h.textContent } : null; }""")
pg2.screenshot(path=str(OUT / 'first_five-AFTER-plus-sin.png'))
shots.append('first_five-AFTER-plus-sin.png')
print(f" first_five-AFTER-plus-sin.png hunt line: {hunt}")
b2.close(); b.close()
finally:
proc.terminate()
print(f"\n{len(shots)} frames → {OUT}")
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""PROCITY Lane F — R39 §39.5 gate E: THE TRANSMISSION PRE-PASS, measured on both sides.
Lane E stripped `transmissionFactor: 1` from three GLBs (`bookshelf` shipped, `longbench` +
`streetlight` unwired) because three.js runs a **transmission pre-pass** when any material in the
scene has `transmission > 0` `renderTransmissionPass()` renders the whole opaque list a second
time, so every opaque draw is issued twice. Lane C measured `opshop`/hall 191 -> 104 by zeroing it.
**That measurement is the falsifiable arm and this tool runs it.** Two port-isolated no-store roots,
identical inode-for-inode EXCEPT one file:
· TREATMENT the shipped tree (E's stripped bookshelf)
· CONTROL the same tree with `transmissionFactor: 1` REINSERTED into that one material,
fabricated here (JSON chunk rewritten, BIN copied through byte for byte)
both driven through `interior_test.html`'s own `drawSweep` — the >=350-draw law's own instrument
with `?localdepot=1` so the GLB comes off the served root and the swap is the only variable.
The fabricated glass GLB doubles as the validator's control asset: `--validator` points
`pipeline/validate_manifest.py` at a root containing it and asserts the R39 HARD FAIL fires.
Run: tools/.venv/bin/python tools/qa/r39_transmission.py [--validator] [--json OUT]
"""
import sys, os, json, time, socket, struct, shutil, subprocess, pathlib, tempfile
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT_TREAT = int(os.environ.get('PROCITY_R39_PORT', '8741'))
PORT_CTRL = PORT_TREAT + 1
BOOKSHELF = 'procity_fit_bookshelf_01.glb'
MATERIAL = 'mtl_10218_Bookshelves_v1'
fails, notes = [], []
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
def OK(m): print(f" \033[32m✓\033[0m {m}")
def head(m): print(f"\n\033[1m{m}\033[0m")
# ── GLB surgery (the same shape as pipeline/strip_transmission.py, run in reverse) ────────────────
def glb_chunks(data):
assert data[:4] == b'glTF', 'not a GLB'
off, out = 12, []
while off < len(data):
ln, ty = struct.unpack_from('<II', data, off); off += 8
out.append((ty, data[off:off + ln])); off += ln
return out
def glb_json(path):
for ty, payload in glb_chunks(pathlib.Path(path).read_bytes()):
if ty == 0x4E4F534A:
return json.loads(payload.decode('utf-8'))
raise AssertionError('no JSON chunk')
def transmissive_materials(path):
"""[(material name, factor)] for every material with transmissionFactor > 0."""
j = glb_json(path)
hits = []
for m in j.get('materials', []):
t = (m.get('extensions') or {}).get('KHR_materials_transmission')
if t and float(t.get('transmissionFactor', 0)) > 0:
hits.append((m.get('name'), float(t['transmissionFactor'])))
return hits
def make_glass(src, dst, material=None, factor=1.0):
"""Write `dst` = `src` with KHR_materials_transmission reinserted. BIN copied through unchanged;
the function returns (sha1_bin_src, sha1_bin_dst) so the caller can prove only JSON moved."""
import hashlib
data = pathlib.Path(src).read_bytes()
chunks = glb_chunks(data)
j = None; binp = b''
for ty, payload in chunks:
if ty == 0x4E4F534A: j = json.loads(payload.decode('utf-8'))
elif ty == 0x004E4942: binp = payload
touched = []
for m in j.get('materials', []):
if material and m.get('name') != material: continue
m.setdefault('extensions', {})['KHR_materials_transmission'] = {'transmissionFactor': factor}
touched.append(m.get('name'))
if material: break
used = j.setdefault('extensionsUsed', [])
if 'KHR_materials_transmission' not in used: used.append('KHR_materials_transmission')
jb = json.dumps(j, separators=(',', ':')).encode('utf-8')
jb += b' ' * ((4 - len(jb) % 4) % 4)
bb = binp + b'\x00' * ((4 - len(binp) % 4) % 4)
total = 12 + 8 + len(jb) + (8 + len(bb) if binp else 0)
out = bytearray(b'glTF' + struct.pack('<II', 2, total))
out += struct.pack('<II', len(jb), 0x4E4F534A) + jb
if binp: out += struct.pack('<II', len(bb), 0x004E4942) + bb
pathlib.Path(dst).write_bytes(bytes(out))
return touched, hashlib.sha1(binp).hexdigest(), hashlib.sha1(bb).hexdigest()
# ── servers ──────────────────────────────────────────────────────────────────────────────────────
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
NOSTORE = r'''
import sys, http.server, functools
class H(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
self.send_header('Pragma', 'no-cache')
super().end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])),
functools.partial(H, directory=sys.argv[2])).serve_forever()
'''
def serve(root, port):
proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(port), str(root)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(80):
if port_up(port): return proc
time.sleep(0.1)
proc.terminate(); raise SystemExit(f'could not serve {root} on :{port}')
def mirror_except(src, path_parts, replacement):
"""A temp mirror of `src` in which every entry is a SYMLINK to the original except the single
file at `path_parts`, which is a copy of `replacement`. Only the directories on that path are
real; everything else is the same inode, so the one file is the only variable."""
dst = pathlib.Path(tempfile.mkdtemp(prefix='procity-r39-mirror-'))
def walk(s, d, parts):
d.mkdir(parents=True, exist_ok=True)
for e in pathlib.Path(s).iterdir():
if parts and e.name == parts[0]:
if len(parts) == 1: shutil.copy2(replacement, d / e.name)
else: walk(e, d / e.name, parts[1:])
else:
(d / e.name).symlink_to(e)
walk(src, dst, list(path_parts))
return dst
def glass_root(glb_name=BOOKSHELF, material=MATERIAL):
"""web/ mirrored symlink-for-symlink EXCEPT assets/models/<glb_name>, which is the fabricated
glass copy. Every other byte the browser sees is the same inode."""
d = pathlib.Path(tempfile.mkdtemp(prefix='procity-r39-glass-'))
src = ROOT / 'web'
for e in src.iterdir():
if e.name != 'assets': (d / e.name).symlink_to(e)
a = d / 'assets'; a.mkdir()
for e in (src / 'assets').iterdir():
if e.name != 'models': (a / e.name).symlink_to(e)
m = a / 'models'; m.mkdir()
for e in (src / 'assets' / 'models').iterdir():
if e.name != glb_name: (m / e.name).symlink_to(e)
touched, s0, s1 = make_glass(src / 'assets' / 'models' / glb_name, m / glb_name, material)
return d, touched, s0, s1
# ── the sweep ────────────────────────────────────────────────────────────────────────────────────
def sweep(pg, host, glb):
pg.goto(f'{host}/interior_test.html?localdepot=1')
pg.wait_for_function('() => !!window.PROCITY_C', timeout=30000)
pg.wait_for_timeout(600)
return pg.evaluate('(glb) => window.PROCITY_C.drawSweep({ glb })', glb)
def main():
from playwright.sync_api import sync_playwright
want_validator = '--validator' in sys.argv
out_json = None
if '--json' in sys.argv: out_json = sys.argv[sys.argv.index('--json') + 1]
result = {}
head('GATE R39 §39.5-E: THE TRANSMISSION PRE-PASS — the shipped tree vs a fabricated glass control')
shipped = ROOT / 'web' / 'assets' / 'models' / BOOKSHELF
hits = transmissive_materials(shipped)
if hits: FAIL(f'the SHIPPED {BOOKSHELF} still carries transmission: {hits}')
else: OK(f'shipped {BOOKSHELF}: 0 transmissive materials (E\'s strip is in the served tree)')
gdir, touched, sha_src, sha_dst = glass_root()
result['glassMaterials'] = touched
if touched == [MATERIAL] and sha_src == sha_dst:
OK(f'control asset fabricated: {touched[0]} transmissionFactor 1 · BIN sha1 IDENTICAL ({sha_src[:12]}…) — only the JSON chunk moved')
else:
FAIL(f'control asset fabrication wrong: touched={touched} binSha {sha_src[:12]} vs {sha_dst[:12]}')
gh = transmissive_materials(gdir / 'assets' / 'models' / BOOKSHELF)
if gh: OK(f'control asset re-scans DIRTY, as intended: {gh}')
else: FAIL('control asset did not take the transmission flag — the control is vacuous')
# ── the validator's hard fail, proven to FIRE (E's gate) ──────────────────────────────────────
if want_validator:
head('the validator control — pipeline/validate_manifest.py must HARD FAIL on the glass asset')
# validate_manifest.py reads pipeline/_normalized/<file> (NOT web/assets/models/), so the
# control asset has to land THERE or the gate is being pointed at a file it never opens.
# My own first cut patched web/assets/models/ and the control reported "did not fire" — the
# harness bug, not E's gate. Recorded because it is exactly the species this round is about.
vroot = mirror_except(ROOT, ('pipeline', '_normalized', BOOKSHELF),
gdir / 'assets' / 'models' / BOOKSHELF)
r = subprocess.run([sys.executable, 'pipeline/validate_manifest.py'],
cwd=str(vroot), capture_output=True, text=True)
blob = r.stdout + r.stderr
result['validator'] = {'rc': r.returncode, 'mentions': 'transmissionFactor' in blob or 'transmission' in blob.lower()}
if r.returncode != 0 and 'transmission' in blob.lower():
line = next((l for l in blob.splitlines() if 'transmission' in l.lower()), '')
OK(f'validator HARD FAILS on the glass asset (rc {r.returncode}): {line.strip()[:150]}')
else:
FAIL(f'validator did NOT fail on a transmissive GLB (rc {r.returncode}) — E\'s gate is vacuous')
r2 = subprocess.run([sys.executable, 'pipeline/validate_manifest.py'],
cwd=str(ROOT), capture_output=True, text=True)
if r2.returncode == 0: OK('validator PASSES on the shipped tree (the positive control — the gate is not simply always-red)')
else: FAIL(f'validator red on the shipped tree: {(r2.stdout + r2.stderr)[-300:]}')
# ...and the gate's OWN blind spot, measured rather than assumed: check_transmission()
# returns silently when pipeline/_normalized/<file> is absent, so a depot-only GLB is never
# parsed. Demonstrated by deleting the local copy and re-running with the glass file live.
vroot2 = mirror_except(ROOT, ('pipeline', '_normalized', BOOKSHELF), gdir / 'assets' / 'models' / BOOKSHELF)
(vroot2 / 'pipeline' / '_normalized' / BOOKSHELF).unlink()
r3 = subprocess.run([sys.executable, 'pipeline/validate_manifest.py'],
cwd=str(vroot2), capture_output=True, text=True)
result['validatorDepotOnly'] = {'rc': r3.returncode}
if r3.returncode == 0:
notes.append('validate_manifest.py check_transmission() SKIPS any GLB with no local '
'pipeline/_normalized copy — a depot-only transmissive asset passes silently. '
'(web/assets/models/, which ?localdepot=1 actually serves, is never scanned either.)')
print(f" \033[33mnote\033[0m the validator's own blind spot, demonstrated: with the local "
f"_normalized copy removed the same glass asset passes (rc {r3.returncode}) — the check is "
f"local-file-only. Ask filed to Lane E.")
shutil.rmtree(vroot2, ignore_errors=True)
shutil.rmtree(vroot, ignore_errors=True)
if '--no-sweep' in sys.argv:
shutil.rmtree(gdir, ignore_errors=True)
print()
if fails: print(f"\033[31m{len(fails)} FAIL\033[0m"); return 1
print("\033[32mvalidator arm GREEN\033[0m"); return 0
# ── the sweep, both roots ─────────────────────────────────────────────────────────────────────
head('the interior draw sweep — 12 types x 7 archetypes, GLB off and on, on both roots')
p_t = serve(ROOT / 'web', PORT_TREAT)
p_c = serve(gdir, PORT_CTRL)
try:
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errs = []
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
for name, port in (('treatment', PORT_TREAT), ('control_glass', PORT_CTRL)):
host = f'http://127.0.0.1:{port}'
off = sweep(pg, host, False)
on = sweep(pg, host, True)
result[name] = {'glbOff': off, 'glbOn': on}
print(f" {name:14s} GLB-off worst {off['worst']:4d} ({off['worstAt']}) "
f"GLB-on worst {on['worst']:4d} ({on['worstAt']})")
b.close()
finally:
p_t.terminate(); p_c.terminate(); shutil.rmtree(gdir, ignore_errors=True)
t_on = result['treatment']['glbOn']; c_on = result['control_glass']['glbOn']
t_off = result['treatment']['glbOff']; c_off = result['control_glass']['glbOff']
delta = c_on['worst'] - t_on['worst']
result['summary'] = {'glbOnWorst': t_on['worst'], 'glbOnWorstAt': t_on['worstAt'],
'glassWorst': c_on['worst'], 'glassWorstAt': c_on['worstAt'],
'saved': delta, 'law': t_on['law'],
'marginTreatment': t_on['law'] - t_on['worst'],
'marginGlass': c_on['law'] - c_on['worst'],
'glbOffWorst': t_off['worst'], 'glbOffWorstAt': t_off['worstAt']}
head('the verdict')
print(f" GLB-on worst room · with E's fix: {t_on['worst']} ({t_on['worstAt']}) "
f"· with transmission restored: {c_on['worst']} ({c_on['worstAt']}) saved {delta}")
print(f" GLB-off worst room · {t_off['worst']} ({t_off['worstAt']}) vs {c_off['worst']} ({c_off['worstAt']}) "
f"(a GLB-off room loads no GLB, so this pair MUST be equal — it is the control's own control)")
if delta > 0: OK(f"the pre-pass is real and E's fix took it out: {c_on['worst']} -> {t_on['worst']} draws (-{delta}, -{100*delta/max(1,c_on['worst']):.0f}%)")
else: FAIL(f"the sweep did NOT drop with transmission stripped (delta {delta}) — E's fix is not reaching the served tree")
if t_off['worst'] == c_off['worst']: OK(f"GLB-off is identical on both roots ({t_off['worst']}) — the delta above is the GLB and nothing else")
else: FAIL(f"GLB-off differs between roots ({t_off['worst']} vs {c_off['worst']}) — the two roots are not otherwise identical")
if t_on['worst'] <= t_on['law']: OK(f"the >=350 law holds with {t_on['law'] - t_on['worst']} draws of margin (was {c_on['law'] - c_on['worst']} with the glass bookshelf)")
else: FAIL(f"the shipped tree BREACHES the {t_on['law']}-draw interior law at {t_on['worst']}")
per = [(k, c_on['perType'].get(k, 0), t_on['perType'].get(k, 0)) for k in t_on['perType']]
print('\n per type (GLB-on worst archetype): glass -> fixed')
for k, c, t in sorted(per, key=lambda r: -(r[1] - r[2])):
print(f" {k:10s} {c:4d} -> {t:4d} {t - c:+d}")
if out_json: pathlib.Path(out_json).write_text(json.dumps(result, indent=1))
print()
if fails:
print(f"\033[31m{len(fails)} FAIL\033[0m"); return 1
print("\033[32mR39 §39.5-E GREEN\033[0m"); return 0
if __name__ == '__main__':
sys.exit(main())