R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast

John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-07 23:49:49 +10:00
parent bb80ac122b
commit 293a82c5e8
152 changed files with 9331 additions and 203 deletions

View File

@ -4,6 +4,131 @@
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
`generatePlan` (which landed mid-session). Everything below was verified live in a browser. `generatePlan` (which landed mid-session). Everything below was verified live in a browser.
## Round 42 (§42.5) — the bench turns to face the road, and the font race turns out to have been the crowd
Full measurement detail in `docs/LANES/LANE_B_NOTES.md` §42. Everything below: today's tree, headless
fresh contexts, port-isolated no-store servers, pin method of record (stepwalk 2 m · yaw 0 · laps 2 ·
802 stations), A/B against a control tree — a HEAD copy of `web/` with `assets/ models/ vendor/` **and
every `js/` subtree that is not Lane B's** symlinked to the live tree, so another lane's mid-round wave
is identical on both arms.
**1. FIX 1 — THE BENCH (and the streetlight) FACED THE WRONG WAY. D's filing is correct on both.**
Verified before changing anything, off the live InstancedMesh matrices, each instance matched to the
edge whose **perpendicular offset equals the placement rule** (position only, so the yaw verdict is
not circular; `unmatched: 0`).
| live scene, seed 20261990 | BEFORE | AFTER |
|---|---|---|
| bench front vs the direction to the road | **90.0° — min = med = max, 60/60** | **0.0° — min = med = max, 60/60** |
| lamp head gains … toward the road | **0.00 m — 0 of 89 reach over it** | **+1.05 m — 89 of 89** (the whole arm) |
The code and its comments had disagreed for 41 rounds: `:203` said *"facing the road"*, `:197` said
*"arm reaches over the road"*, and both used `atan2(ux, uz)` — the yaw that points local +Z **down the
street**. Fixed with one named convention rather than two more literals: `faceYaw = atan2(-uz, ux)`
(= `alongYaw π/2`), road-facing yaw = `faceYaw + (side > 0 ? π : 0)` — **the `side` term was already
there and already right**, which is why the defect survived reading. Also found and fixed in the same
path: **9 of the town's 60 benches are the depot GLB** (this box reaches the CDN), and that GLB is
**0.437 × 0.845 × 1.215 m with all 318 backrest verts in a slab at x ∈ [0.151, 0.219]** — plank along
**Z**, sitter facing **X**, i.e. 90° out from the house "+Z is the front" law. Normalised by rotating
the **geometry** once at load (`+π/2`), not the instance, so a station's yaw means one thing whichever
geometry draws. Zero draws, zero tris.
**2. FIX 2 — `benchStops(plan)` ships, and the RULE now has one copy.** `benchStationsOnEdge(e)` is
what `buildChunkFurniture` places from **and** what the export publishes — a second copy inside the
same file would have been D's defect at a shorter distance. Signature:
`benchStops(plan) → [{ edgeId, kind, s, side, x, z, yaw, seatLen }]`, pure function of the plan (no
THREE/scene/DOM), plan-edge order then ascending `s`, whole town. `side` is on the **furniture**
perpendicular so D's `side === forward` test is unchanged; `yaw` is the instance rotation with **+Z
the bench front, now pointing at the road** (a sitter is still `yaw + π`); `seatLen` 1.6 m so D's
±0.38 m two-up offset stops being a copy of B's geometry. **Gated, not asserted** (D's own arm-6
method, pointed at the export): **254 stations town-wide, 60 instances in the window, 60/60 within
2 cm and 60/60 yaw-identical to <1e-6 rad; CONTROL at 2 m offset matches 0 of 65 on both counters.**
**3. FIX 3 — `await document.fonts.ready` IS SHIPPED AND IT FIXES NOTHING. The cause is the crowd.**
Measured, not assumed: the sign-atlas canvases are **byte-identical across three boots** (4149
canvases per bookmark, same sha1 list every time); `document.fonts.size === **0**` at document start
and at ready, because the API tracks **font faces** and this game ships none (house law — canvas text);
and on **6 of 6 boots** `fonts.ready` resolves at 3851 ms while the **first `fillText` in the process**
is at 89109 ms, i.e. **+49 to +60 ms later**, with `fonts.status === 'loaded'` already. There is no
window in which a sign is painted early. It is shipped anyway at the only free site — a module-level
await at the top of `buildings.js`, so every `buildChunkBuildings` call is downstream of it and the
builder **stays synchronous** (the 4 ms/frame streamer budget is untouched). **Measured added boot
latency, 6 boots/arm: first sign paint 102.28 → 102.62 ms (+0.34 ms on the mean) against a control
whose own spread is 93.7110.6 ms; `DBG.ready` 3,822 → 3,802 ms.** Noise, both directions.
What actually moves the frame is the **citizen sim on wall-clock time** — and **the freeze recipe every
lane quotes does not freeze anything**: `?roster=v1&pop=0` boots **140 citizens** (96 active, measured)
because `index.html:454` is `(parseInt(params.get('pop'),10) || 140)` and **0 is falsy**. Two fresh
contexts of `street_noon` differ by **983 px of 921,600, all of them in rows 361418 — the pedestrian
band** — and by **0 px** with the crowd genuinely out. So `dbg.js` gains **`DBG.freeze()`** (`?dbg=1`
only): `setPopulation(0)` for the fixed roster **plus** hiding `citizens.group` for the chunk-streamed
default (whose active set is per-chunk density and ignores `target`), returning
`{draws:{before,after}, peds, stillLive:{weather,tram,magpie,washing,gigs}}` so a harness asserts.
**THE 3-BOOT HASH TABLE** — F's method, fresh context per (bookmark, boot), `?classic=1`, 2.5 s settle:
| bookmark | BEFORE (control) | AFTER (fonts fix in) | AFTER + `DBG.freeze()` | BEFORE + same freeze |
|---|---|---|---|---|
| `street_noon` | **3 of 3** | **3 of 3** | **1 of 3** | **1 of 3** |
| `shopfront_detail` | **3 of 3** | **3 of 3** | **1 of 3** | **1 of 3** |
| `crossroads_busy` | **3 of 3** | **3 of 3** | **1 of 3** | **1 of 3** |
| `market_square` | 1 of 3 | 1 of 3 | 1 of 3 | 1 of 3 |
| `night_neon` | 1 of 3 | 1 of 3 | 1 of 3 | 1 of 3 |
| **stable** | 2/5 | **2/5 — unchanged** | **5/5** | **5/5** |
**→ LANE F, EXPLICITLY: YES, byte-hash goldens become possible and the R41 ruling can be reversed —
but not for the reason it was made.** The condition is not "the await landed", it is "the frame holds
nothing that runs on wall-clock time". A golden boot is
`?classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0` **+ `DBG.freeze()` before the shot**; `?pop=0`
is not a substitute and never was. The last column is the proof it is the freeze and not any R42
code — the untouched HEAD tree goes 5/5 the same way. Two asks in your files: fix `index.html:454`
(`Number.isFinite`, not `||`), and if a gate pins a hash, assert `freeze().stillLive` is all-false in
the same run or the golden rots the first time someone shoots without a flag. (On `market_square`:
R41 has B calling it stable and F calling it 3-of-3; on this box today it is stable in all four arms.)
**→ LANE D, A BREAKING CHANGE, AND IT IS WHY THE EXPORT EXISTS.** `sim.js benchStationsFor()` is now
**90° wrong**. Positions are untouched (your 14/14 position arm stays green); your **yaw arm goes
red**, and `_seatPose()` will put sitters up to 0.38 m off the plank facing along the street — that is
`bench_seated_after.jpg`, shipped un-retouched on purpose. `bench_seated_after_dwired.jpg` is the same
frame with the sitter re-seated off the **real bench transform**: that is what importing `benchStops`
in 42.4 delivers.
**4. ZERO DRAWS, measured on both arms** — pin method, 802 stations, control vs treatment:
| synthetic, pin | CONTROL | R42 | delta |
|---|---|---|---|
| default noon (seg 2) | 282 / 100,346 | **282 / 100,346** | **0 / 0** |
| default **NIGHT** (seg 5) | 291 / 122,183 | **291 / 122,183** | **0 / 0** — ruling 4's nine draws intact |
| `?classic=1` NIGHT | 269 / 90,580 | **269 / 90,580** | **0 / 0** — byte-exact to the R38/R40/R41 pin |
| `?classic=1` noon | 269 / 90,592 | **269 / 90,592** | **0 / 0** |
0 console errors on all 8 runs. `night_neon` frame **98 draws / 14,454 tris on both**; crowd-frozen
bench pose **130 draws on both**. (Night tris read 122,183 against R41's 120,093 — the +2,090 is on the
**control** too, arriving through the symlinked `models/`/`assets/` from another lane's in-flight R42
work. Recorded so nobody bills it to this round.)
**5. CLASSIC.** `furniture.js` and the new `buildings.js` await carry **no classic flag test at all**,
so classic and default are identical to each other by construction and the fix lands on both. Fetch
surface **127 URLs on both arms, added `[]`, removed `[]`** (blob: object URLs excluded — random UUID
per boot); HUD DOM **2355:8 identical**; help line unchanged; `night_neon` **0/0 delta**; classic pin
**269 / 90,580 byte-exact**; **0 page errors and 0 console errors** on `?dbg=1`, `?classic=1`,
`?noassets=1` and `?roster=v1&pop=0`. The only probe difference anywhere is `DBG.freeze` existing, and
`DBG` exists only under `?dbg=1`. Goldens: `node web/js/citygen/selfcheck.js` → **ALL GREEN
157,647/157,647**, fingerprint **`0x5f76e76` unmoved**.
**6. FILED, NOT FIXED.** The **bus shelter has the bench's bug**`syaw = atan2(-uz, ux)` is `faceYaw`
**without** the `side` term and the shelter always stands on `+perp`, so its open front reads *away*
from the road on the primitive. Left alone deliberately: `busShelterStops()`'s yaw is consumed by
`tram.js` and the shipped shelter is a depot GLB whose axis is **unmeasured**. Measure the GLB, then
flip both together. Also to E: a depot prop disagreeing with the house axis is a **class** of bug, not
an instance — the bench was one, the shelter is the next one waiting.
Shots: `docs/shots/laneB_r42/``bench_seated_{before,after,after_dwired}.jpg` (same station, same
pose, R10 human-sized line off the sitter's own bone bbox), `bench_geometry_ab.jpg` and
`streetlight_night_ab.jpg` (crowd frozen, 130 draws both arms). Files: `web/js/world/furniture.js`,
`web/js/world/buildings.js`, `web/js/world/dbg.js`, `docs/shots/laneB_r42/`, these docs. **No git
commands run** (lane treaty; Fable commits).
## Round 41 (§41.5) — the game learns to say where it came from, and 81 pubs stop wearing op-shop paint ## Round 41 (§41.5) — the game learns to say where it came from, and 81 pubs stop wearing op-shop paint
Full measurement detail in `docs/LANES/LANE_B_NOTES.md` §41. Everything below: today's tree, headless Full measurement detail in `docs/LANES/LANE_B_NOTES.md` §41. Everything below: today's tree, headless

View File

@ -3,7 +3,277 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens *Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.* into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-08-07 (round 41) · owner: PROCITY-C · reviewer: Fable Last updated: 2026-08-07 (round 42) · owner: PROCITY-C · reviewer: Fable
---
## Round 42 (§42.2b) — MAKE THE DECKS READ: the composition half
Lane E fixed the asset in §42.2a (chassis → Technics silver, the record → black vinyl, the inside-out
platter turned right way out). This is the other half of the same fault: **the booth still did not read
as a DJ setup from where a player stands, because the bench was a black slab in a place the player was
not looking, turned so they saw its end.** Nothing was driven, no mechanic was added (ruling 5). What
changed is colour, structure, orientation and placement.
**Everything green: 0 throws · 0 path-fails · 0 carves · 0 determinism-fails · 0 leaks · 0 console
errors · worst warm build 13.0 ms (budget 50) · worst room 123 draws against the ≤350 law, UNCHANGED.**
### 0. FIRST: the 122-vs-123 reconciliation — neither of us was wrong, the instrument was unpinned
F's §41.6 close read **123 @ `dept`/auto** where this lane published **122**, on the same tree, and F's
note said "the difference is C's measurement environment". That is exactly right, and here is the
environment: **`renderer.info.render.calls` counts what survives FRUSTUM CULLING, and the sweep camera
holds a fixed VERTICAL fov (70) while taking its aspect from the browser window.** A wider window sees
more of the room and therefore reads more draws.
Measured on ONE tree, running **F's own `SWEEP` function from `tools/qa/r41_integration.py` verbatim**,
changing nothing but the viewport:
| viewport | camera aspect | GLB on | GLB off | whose harness |
|---|---:|---:|---:|---|
| 1024×768 | 1.333 | 120 @ `dept/auto` | 88 @ `stall/hall` | — |
| 1280×1024 | 1.250 | 116 @ `dept/auto` | 88 @ `stall/hall` | — |
| **1280×800** | **1.600** | **122** @ `dept/auto` | 92 @ `opshop/hall` | **C's R41 harness** |
| 1440×900 | 1.600 | 122 @ `dept/auto` | 92 @ `opshop/hall` | — |
| **1280×720** | **1.778** | **123** @ `dept/auto` | 93 @ `opshop/hall` | **F's R41 harness** |
| 1920×1080 | 1.778 | 123 @ `dept/auto` | 93 @ `opshop/hall` | — |
| 2560×1080 | 2.370 | 124 @ `dept/auto` | 95 @ `opshop/hall` | (21:9 bound) |
Corroborating detail: the phantom control decomposes identically at both aspects — 197123 = **+74** on
F's harness, 196122 = **+74** on mine. Same bug, same magnitude, two readings of the same tree.
**THE TRUE NUMBER IS 123, and the margin is 227** — F's number, and I am adopting it, because the law
has to be the worst a *player* can produce and a player's window is wide (16:9 is the common case; 21:9
reads 124). My 122 was a 16:10 reading.
**Fixed, not just explained.** `drawSweep` now pins its own measurement aspect at **16:9**
(`MEASURE_ASPECT` in `interior_test.html`) and restores the window's aspect afterwards, so the number
stops depending on who ran it. **Control demonstrated:** `drawSweep({ glb: true, aspect: 1.25 })` on the
same tree reads **116** — the instrument can still be made to lie, on purpose, on demand.
*(Two things ruled out on the way, so the aspect finding is not a guess: I re-ran F's arm read-only and
it reproduced 123/93/197 exactly; and E's "+0 draws on all 46 repainted assets" was checked
independently by counting glTF primitives in `web/assets/models/` against the pristine copies in
`pipeline/.props_orig/r42/`**46/46 identical primitive counts**, so the repaint cannot be the cause.)*
### 1. E's numbers, reproduced before building on them
| E's claim | reproduced here | how |
|---|---|---|
| chassis `#b9bdc2` rel. luminance **0.506** | **0.5060** | WCAG relative luminance from the hex |
| vs `djBooth` bench `#3a3128` (0.0325) = **6.74:1** | **6.74:1** | ditto — exact |
| R41 chassis (linear 0.05) vs the same bench = **1.21:1** | **1.21:1** | ditto |
| `deck_1200_rigged` has no plinth; ~43 mm missing | **confirmed, and sharpened** | see §4 |
| 46 repainted assets are **+0 draws** | **confirmed** | 46/46 identical glTF primitive counts vs `.props_orig/r42` |
**And the conclusion I drew from them is not E's.** E's advice was "the bench should stay dark, a
lighter bench reduces the ratio". True, and not the binding constraint: 6.74:1 and 4.73:1 are both far
above the 3:1 WCAG threshold for a graphical object, so deck-to-bench contrast was already solved by
E's half. The fault that survived is a claim about pixels, so I measured pixels.
### 2. THE MEASUREMENT: "a plain black box" is a statement about pixels
Six booths rendered at eye height, 2.0 m off the bench's own face, counting inside the booth's own
projected screen rect (`pixels.py`, scratch). BEFORE = the R41 lane-C tree **with E's §42.2a repaint
already live** — so this isolates my half and nothing else.
| room | booth px < rel.lum 0.05 | bench mass < 0.05 | distinct 4-bit colours | mean saturation |
|---|---:|---:|---:|---:|
| `record`/auto/1990 | 46.4% → **28.7%** | 53.8% → **32.4%** | 507 → 359 | 0.385 → 0.454 |
| `record`/gallery/4242 | 65.1% → **10.3%** | 73.7% → **7.8%** | 172 → 526 | 0.220 → 0.401 |
| `record`/pokey/7 | 88.4% → **17.1%** | 93.1% → **7.8%** | 150 → 408 | 0.227 → 0.455 |
| `pub`/auto/1990 | 20.1% → 70.0% ⚠ | 16.4% → 67.6% ⚠ | 154 → 177 | 0.214 → 0.295 |
| `band_room`/auto/1990 | 65.7% → **20.6%** | 64.4% → **5.9%** | 121 → 439 | 0.285 → 0.467 |
| `pawn`/auto/1990 | 59.5% → **13.4%** | 69.4% → **5.2%** | 261 → 425 | 0.299 → 0.429 |
| **mean, all six** | **57.5% → 26.7%** | **61.8% → 21.1%** | **228 → 389** | **0.272 → 0.417** |
| mean, excluding the pub | 65.0% → 18.0% | 70.9% → 11.8% | 242 → 431 | — |
**The pub row is an occlusion artefact and I am not hiding it.** A 1.8 m `speaker_stack` stands
between the 2 m camera and that booth, so the crop is mostly speaker. It is a real composition
complaint about that one room (see §5), not a measurement of the bench.
### 3. WHAT CHANGED, and why each
**a. The bench stops being one rectangle.** `djBooth` was three boxes (body, top edge, a dark cavity)
and is now seven: recessed plinth · two end cheeks · apron under · apron over · a back panel · the
worktop. **Footprint (`w+0.12 × d+0.12`), height (`h+0.25`) and therefore every room's occupancy grid,
path guarantee and placement are untouched by the new geometry** — every part is inside the volume R41
already reserved.
**b. Colour, decided by measurement not by taste.** The R41 bench was a 50/50 seeded coin flip between
`#3a3128` and `#2a2a30` — two near-blacks. Now: carcass `#5a4630` / `#6b4526` (stained pine, red-brown
timber — E's own R42 pub palette family), **worktop kept at `#1c1a18`**, plinth and bay interior
`#211d19`.
| pair | rel. luminance | WCAG |
|---|---:|---:|
| chassis `#b9bdc2` vs R41 body `#3a3128` | 0.5060 / 0.0325 | 6.74:1 (E's number) |
| chassis vs R42 carcass `#5a4630` | 0.5060 / 0.0677 | **4.73:1** |
| chassis vs R42 carcass `#6b4526` | 0.5060 / 0.0752 | **4.44:1** |
| **chassis vs the worktop it actually stands on `#1c1a18`** | 0.5060 / 0.0105 | **9.19:1** |
| black vinyl `#17171a` vs alloy platter `#c9ced3` | 0.0087 / 0.6126 | 11.29:1 |
**The deck's frame is the worktop, not the bench body** — the deck is a 0.11 m object sitting on a
0.05 m slab — so the number that governs whether the deck reads went **up** (6.74 → 9.19), while the
mass below it stopped being a void.
**c. An open record bay across the bench front.** The strongest single "this is a record shop's DJ
booth" signal available, and nearly free. `djBooth` now exposes a stock `slot`, so `stock.fill` puts
seeded records in it: pooled procedural sleeves by default, and **real GODVERSE covers under
`?stock=real`** (the record pack serves the `sleeve` slot). Pawn's bay gets `box` stock instead — a
pawnbroker's bench must not read as a DJ booth.
*Built wrong first and caught by looking:* the bay was a solid dark box, which put an opaque front
plane in front of the records; it is now an actual opening (cheeks + aprons + back panel).
**d. Headphones on the mixer.** `stackOn` in the booth part spec: a part may ride another part, y
resolved off the `KIT` table so the primitive stand-in and the GLB agree. It costs **no footprint**
the bench is full edge to edge (0.51 + 0.23 + 0.45 across 1.55 m leaves two 0.14 m gaps and an HD25 is
0.23 wide), so the only free surface was upward, which is also where a real pair of cans lives.
**e. Turn the booth to face the player, and put it somewhere they look.** `spec.faceDoor`, on all four
`djBooth` specs. Two parts, and the second only exists because the first moved the problem:
* **the yaw** — aim local +Z at the spawn point (`door x`, `D/2 1.5`), with the zone's own yaw as
fallback. `placeNearCounter` copies the counter's yaw, and a `corner` counter is at −π/2, which
stands the booth square to a side wall.
* **the candidate order** — aiming it made its deep axis narrow, which made a counter-side candidate
"fit" that never had, and the record shop's hero landed 2.5 m to the player's LEFT at the door, out
of frame. So a `faceDoor` spec's candidate list is now **sorted by `doorViewScore`**: angle off the
view axis, penalised under 2.2 m and over 7 m. Pure geometry, no stream draws, applied as a stable
sort to the already-shuffled list, so determinism holds.
**Measured over 190 → 189 booth rooms (4 types × 6 archetypes × 8 seeds):**
| | before | after |
|---|---:|---:|
| face within 45° of the player's entry line | 94 (49%) | **173 (92%)** |
| face within 60° | 117 (62%) | **176 (93%)** |
| booth within 35° of the door view axis | 105 (55%) | **142 (75%)** |
| booth within 60° | 153 (81%) | **156 (83%)** |
| facing away (≥120°) | 0 | 0 |
**f. A pre-existing bug this exposed: a counter-side fitting could be planted inside a wall.**
`areaFree` deliberately allows border cells (shelves hug walls) and `cellOf` clamps anything past the
edge back onto the border — but `placeNearCounter`'s candidates are `counter ± gap` with no bound,
unlike every other placer. Measured on the record shop, seed 1990: the corner counter sits at z = D/4,
so `counter.z 2.35` puts the booth at z = 4.35 in a room whose back wall is z = 4.0 — **0.77 m of a
0.86 m half-depth buried in plaster.** R41 never saw it because at the counter's own yaw the booth was
too deep to fit there at all. Fixed with an `inRoom` bound in `placeNearCounter`, for every
`zone:'counter'` fitting; a rejected candidate is not a lost fitting (this placer already falls back to
the aisle).
**g. The venue booth's fallback order.** `stage` was first, and the stage flanks are exactly where the
PA lives — on `pub`/auto/1990 the booth landed **0.24 m from a 1.8 m `speaker_stack`** that hides it
completely. Open floor is now tried first, the stage flanks kept as a fallback rather than a preference.
### 4. THE PLINTH WORKAROUND — clearly marked, REMOVE IT IN R43
`kit41.BOOTH_PLINTH_FIX = { deck_1200_rigged: -0.043 }`, applied **only to the GLB**, never to the
primitive stand-in. E filed the fault; measured again here from the shipped GLB rather than taken on
report (`web/assets/models/procity_fit_deck_1200_rigged_01.glb`, node transforms applied):
| | `deck_1200_rigged` | `deck_1200` (the static twin, same source) |
|---|---:|---:|
| vertices | 8,740 | 11,138 |
| within 30 mm of the model's y-min | **9 (0.10%)** | 729 (6.55%) |
| within 43 mm | 69 (0.79%) | 739 (6.63%) |
| **1st percentile of vertex y** | **0.0524** | 0.0140 |
99% of the rigged deck sits 52 mm above the floor its own bounding box claims, and `glb.js` plants a
part at `y bb.min.y`. **0.043 m drops the feet onto the worktop and buries the nine stray vertices
inside the solid bench, where nothing can see them.** When R43 re-runs `rig_join.py` and the model gets
its plinth back, delete the constant and the `p.dy` term in `djBooth`'s `glbParts` map. Both are tagged
`[R42 §42.2b — WORKAROUND, REMOVE WHEN R43 FIXES THE ASSET]`.
**A second, smaller thing found by the same look, and this one is a real fix not a workaround:** the
worktop is a 0.05 m slab **centred** at y = h, so its top face is at h + 0.025 — and R41 planted every
booth part at y = h, i.e. **25 mm sunk into its own bench**. Invisible while nothing moved; the moment
the plinth correction went in it buried the whole chassis and left a black disc floating on the bench.
All parts (stand-in and GLB) now stand on `TOP = h + 0.025`.
### 5. THE DRAW TABLE — worst room by shop type, F's 16:9 aspect, before → after
Both columns measured on this instrument at the same pinned aspect; BEFORE is the frozen R41 lane-C
tree with E's §42.2a repaint live.
| type | GLB off | GLB on | GLB on + `?stock=real` | `noKit` (the control arm) |
|---|---|---|---|---|
| `record` **(booth)** | 89 → **89** | 81 → **82** (+1) | 73 → **74** (+1) | 63 → 63 (+0) |
| `pub` **(booth)** | 64 → **65** (+1) | 59 → **57** (2) | 59 → **57** (2) | 45 → 45 (+0) |
| `band_room` **(booth)** | 57 → **60** (+3) | 51 → **55** (+4) | 51 → **55** (+4) | 39 → 39 (+0) |
| `pawn` **(booth)** | 83 → **83** | 72 → **72** (+0) | 72 → **72** (+0) | 64 → 64 (+0) |
| `opshop` | 93 → 93 | 105 → 105 | 105 → 105 | 102 → 102 |
| `toy` | 68 → 68 | 86 → 86 | 120 → 120 | 86 → 86 |
| `book` | 71 → 71 | 64 → 64 | 98 → 98 | 53 → 53 |
| `video` | 79 → 79 | 65 → 65 | 65 → 65 | 55 → 55 |
| `milkbar` | 83 → 83 | 70 → 70 | 70 → 70 | 70 → 70 |
| **`dept`** | 88 → 88 | **123 → 123** | **123 → 123** | 116 → 116 |
| `stall` | 89 → 89 | 82 → 82 | 82 → 82 | 72 → 72 |
| `rsl` | 73 → 73 | 60 → 60 | 60 → 60 | 52 → 52 |
| **WORST ROOM** | **93 @ `opshop/hall`** | **123 @ `dept/auto`** | **123 @ `dept/auto`** | 116 @ `dept/auto` |
**WORST ROOM IN THE GAME: 123 draws @ `dept`/auto, unchanged. Law ≤350 → MARGIN 227, unchanged.**
`dept` has no booth, so §42.2b spent **zero draws at the worst room**; the most spent anywhere is +4 at
`band_room`, which sits at 55. **The `noKit` arm is +0 on all twelve types** — the control for the
control: everything I touched lives inside a kit spec, and dropping the kit reproduces the pre-R41 room
exactly as it did in §41.4.
The bay's records are the reason `record` moved by 1 and not by 9: `slot.pool` puts the whole row on
**three shared pooled face materials**, so it batches to at most three meshes — and in a record shop
those are buckets the room's own bins already have, so it merges to zero. Under `?stock=real` the whole
bay is one atlas material. New GLB parts: `headphones_hd25` on `record_hero` and `venue` (+1 draw each);
`pawn` gets none.
### 6. SWEEP · SOAK · DETERMINISM · `?noassets`
- **`drawSweep`, 4 arms × 12 types × 6 archetypes = 288 rooms:** all ≤350, **0 console errors**.
Aspect control (`aspect: 1.25`): **116 @ `dept/auto`** — the instrument demonstrably moves.
- **Soak, 60 rooms build+dispose+rebuild:** avg **3.61 ms**, worst **13.0 ms** (budget 50) · leak geo
**0** / tex **0** · determinism **0** mismatches · path-fails **0** · carves **0**. (Three independent
runs of the whole suite: worst build 17.3 / 9.2 / 13.0 ms — build time is machine noise, every other
number in this section was byte-identical across all three.)
- **576-room corpus** (12 types × 6 archetypes × 8 seeds): **0 path-fails, 0 carves, 0 throws.**
- **Determinism, two FRESH browser contexts**, 240 rooms (12 types × 5 archetypes × 4 seeds), GLB on +
`?stock=real`, comparing placement, GLB id list, booth pose (position + yaw to 5 dp), browse/watch
points, keeper stand and stage: **byte-equal, sha256 `7c78674a…22c0` on both runs, 462,006 bytes.**
- **`?noassets` arm, all 60 type × archetype rooms:** **0 GLB · 0 manifest · 0 wardrobe · 0 stock-pack
requests.** The only 10 requests are the pre-existing `assets/gen/wall-*.jpg` / `tex-*.jpg` shell
wallpapers — the same 10 as R41. Worst primitive room 71 draws, 20 booths built, 0 errors.
**CONTROL:** the same 60 rooms with the manifest handed in fetch **50 GLBs** — the gate is not vacuous.
- **`kitAudit(manifest)`: 96/96 rows checked, 0 dimension errors, 0 missing, 0 pair errors, 0 oversize.**
It covers the new booth parts too (`headphones_hd25` is a catalogue row and a booth part id).
- **Booth count 190 → 189 over 576 rooms.** All three misses are `pub` rooms where the door→counter
path guarantee **pulled** the booth and the §41.4 restore pass could not put it back (`pulled 79,
restored 68, lost 1`) — the guarantee doing its job, not a placement failure. R41 lost two of the
same class. **watchPoints did not suffer: `pub` 361 → 363, `band_room` 384/384, `rsl` 560.**
### 7. `assets/wardrobe/` was spelled in two files (F's tidy)
`'assets/wardrobe/'` was written out in `web/interior_test.html` **and** `web/js/world/interior_mode.js`
— a string that has to match a directory on disk, in two places. It is now declared once, in
`stockpack.js` next to the loader that resolves it, re-exported from `interiors.js`, and imported by the
test page. **→ LANE B/F: `interior_mode.js` can drop its own copy for one import line** (LANE_C_NOTES
§42); I did not touch that file, it is not in this lane's editable set.
### 8. Shots — `docs/shots/laneC/`
`r42_dj_booth_door_ab.png`**the deliverable**: the DOOR view, before/after, same seed, same camera
(the room's own spawn), E's repaint live in both · `r42_dj_booth_before_after.png` — the round's origin
(`r41_record_dj_booth.png` as committed) against the answer · `r42_dj_booth_door.png` (the after door
view alone) · `r42_record_shop.png` (record shop wide) · `r42_dj_booth_variants.png` (band room · pawn ·
a second record shop) · `r42_dj_booth_arms.png` (`?noassets` primitive · default · `?stock=real`).
Every booth-scale shot carries a **1.75 m scale post**; no shot here contains a humanoid.
### 9. Files
Changed: `kit41.js` (`BOOTHS` bay/`stackOn`/`dy`, `BOOTH_PLINTH_FIX`), `fittings.js` (`djBooth` rebuilt,
booth palette, `TOP` surface fix), `stock.js` (`binFan` `slot.axis` + `slot.pool`), `layout.js`
(`faceDoorYaw`, `doorViewScore`, the `inRoom` bound, `placeAnywhere` signature), `theme.js` (`faceDoor`
on the four booth specs, venue fallback order), `stockpack.js` + `interiors.js` (`WARDROBE_BASE` once),
`interior_test.html` (`MEASURE_ASPECT`, the import). `glb.js` deliberately **unchanged** — the booth
bakes its own part heights, so the plinth workaround needed no edit to the loader.
**Not touched:** `web/assets/models/**`, `web/assets/manifest.json`, `pipeline/**`, `web/js/citizens/**`,
`web/js/world/**`, `web/index.html`, `tools/qa.sh`.
--- ---
@ -56,6 +326,12 @@ modes**, which is the control for the control.
| `toy` | **none — untouched by design** | 67 | 67 | 86 | **86** | **+0** | 120 | | `toy` | **none — untouched by design** | 67 | 67 | 86 | **86** | **+0** | 120 |
**WORST ROOM IN THE GAME: 122 draws @ `dept`/auto, GLB on, with or without `?stock=real`.** **WORST ROOM IN THE GAME: 122 draws @ `dept`/auto, GLB on, with or without `?stock=real`.**
> **[CORRECTED IN R42 §42.2b — the true number is 123, margin 227.]** Every figure in this §41.4 table
> was read at a 16:10 viewport, and `drawSweep`'s camera holds a fixed VERTICAL fov while inheriting the
> window's aspect — so a wider window sees more of the room and reads more draws. At 16:9 (F's harness,
> and the sweep's pinned aspect from R42 on) this table reads 14 higher per type and the worst room is
> 123. The rooms did not change; the instrument was unpinned. Reconciliation in § Round 42 (§42.2b) §0.
Law ≤350 → **margin 228**. It was 116 before this round, so §41.4 spent **6 draws at the worst room** and Law ≤350 → **margin 228**. It was 116 before this round, so §41.4 spent **6 draws at the worst room** and
at most 17 anywhere. `dept` deliberately gets the fewest additions of any type precisely because it owns at most 17 anywhere. `dept` deliberately gets the fewest additions of any type precisely because it owns
the worst room; the pub, which this round exists for, could afford 14 and still sit at 59. the worst room; the pub, which this round exists for, could afford 14 and still sit at 59.

View File

@ -1,5 +1,281 @@
# PROCITY-D — progress (Lane D · Citizens) # PROCITY-D — progress (Lane D · Citizens)
## Round 42 (§42.4) — RECAST, WIRED: five bodies swapped at their own index, and the crowd did not move. (2026-08-07)
Lane E retired five ped bodies on ruling 1 and sourced five replacements. This round wires them, and
the whole job is one sentence with a measurement behind every clause: **the town changed clothes and
nothing else changed at all.**
### The swap — IN PLACE, at the same index, in `web/js/citizens/rigs.js`
Decision stated plainly: **the rejects are replaced at their index, not removed.** `pickRig` indexes
`PED_NAMES`, and every citizen stores the result as `pedIndex`, which is in the identity signature
*and* is the impostor atlas's subject index. Deleting a name shortens a pool, renumbers every slot
after it and moves every seeded identity downstream — the R2 fleet-order bug through a different
door, and it would move Lane F's goldens. Replacing at the index moves nothing.
| pool | slot | `fleet.all` | out | in | Mixamo | tris |
|---|---:|---:|---|---|---|---:|
| `normal` | 6 | 6 | `man_elder_01` (robed wuxia elder) | **`man_casual_04`** | Ch31 | 2 533 |
| `normal` | 9 | 9 | `man_soldier_ww2_01` (WW2 infantryman) | **`person_casual_01`** | Ch21 | 2 956 |
| `djs` | 4 | 21 | **`dj_phrtt_01` (unclothed body)** | **`man_smart_01`** | Ch23 | 2 617 |
| `comical` | 0 | 22 | `comical_luchador_01` (EL CHUPACABRA) | **`woman_smart_02`** | Ch41 | 2 494 |
| `comical` | 1 | 23 | `comical_boy_01` (cartoon child) | **`person_youth_01`** | Ch22 | 2 738 |
Pool lengths **unmoved: normal 17 · djs 5 · comical 2 · `fleet.all` 24** (`?classic=1`: 17 / 19).
`comical` is now a misnomer kept on purpose — both members are ordinary people (E's ruling-1 call),
but the KEY is load-bearing for `fleet.comical`, `pickRig`'s 8 % branch and two QA gates, so the name
stays and the meaning is documented at the declaration.
**`dj_phrtt_01` is gone from the default boot, measured at the wire, not asserted from the source:**
a fresh default context fetches **32 ped GLBs and not one of them is a retired body**; `?classic=1`
fetches 21, likewise none. The GLB is still on disk and still on the depot — neither is mine to
delete (see *For Lane E / Lane F* below).
### Determinism — the crowd is not merely reproducible, it is BYTE-IDENTICAL to before the swap
| | result |
|---|---|
| two fresh contexts, same seed → identity signature (`id:pedIndex:pvar:height:speed:edge:forward`) | **byte-equal**, 150 active citizens |
| …and posture signature | **byte-equal** |
| CONTROL: seed 20261991 | **differs** (141 citizens) — byte-equal is not constant |
| CONTROL: every clip GLB delayed 2 s | **identical** — posture is `(citySeed, id)` alone |
| **pre-swap tree vs post-swap tree, same seed, same bookmark** | **0 differing lines of 150** |
| three-way (pre-swap · post-swap · post-bench-rewire) | **all byte-equal** |
**What moved:** which GLB each `pedIndex` points at, i.e. what the crowd looks like — intended.
**What did not move:** every `pedIndex` value, every pool length, every `pickRig` mapping, every
citizen's height/speed/edge/direction, every posture assignment, and the impostor atlas's subject
order. The identity signature diff across the swap is empty, which is the strongest form of this
claim available: not "same seed gives the same crowd", but "the same seed gives the *same crowd it
gave yesterday*, in different clothes".
### Draws — a total cannot answer this question; a difference can
| bookmark | pre-swap | post-swap | post-bench-rewire | budget |
|---|---:|---:|---:|---|
| street_noon | 194 | 194 | 195 | ≤300 |
| crossroads_busy | 108 | 108 | 107 | ≤300 |
| night_crowd | 127 | 127 | 127 | ≤300 |
| market_square | 94 | 96 | 96 | ≤300 |
| night_neon | 111 | 111 | 111 | ≤300 |
| venue_night | 141 | 141 | 141 | ≤300 |
| district_posters | 235 | 235 | 235 | ≤300 |
| patronage_door | 105 | 108 | 108 | ≤300 |
| interior (record) | 57 | 57 | 58 | ≤350 |
**Stated honestly: those totals are not evidence about my change, and I am not going to pretend they
are.** This is a shared tree with four lanes landing work in the same hour, sampled while a
wall-clock crowd walks through frame; ±1 appears run-to-run on an unchanged tree, and the +2 at
`market_square` / +3 at `patronage_door` persisted across both post-swap runs — so they are somebody's
change, and the decomposition below proves they are not the crowd's.
**The measurement that does answer it** (`r42_cast.py` arm 4): render the frame, hide
`citizens.group`, render again. The difference is the crowd's own cost, in one frame, with nothing
else changing.
| bookmark | draws | crowd in | crowd out | **crowd's cost** | near rigs | mid | expected |
|---|---:|---:|---:|---:|---:|---:|---:|
| street_noon | 194 | 194 | 189 | **5** | 4 | 37 | 5 |
| crossroads_busy | 107 | 107 | 97 | **10** | 9 | 30 | 10 |
| night_crowd | 127 | 127 | 124 | **3** | 2 | 10 | 3 |
| night_neon | 123 | 121 | 119 | **2** | 1 | 9 | 2 |
| district_posters | 187 | 187 | 184 | **3** | 2 | 17 | 3 |
| venue_night | 139 | 137 | 133 | **4** | 3 | 4 | 4 |
| market_square | 112 | 110 | 109 | **1** | 0 | 10 | 1 |
| patronage_door | 105 | 105 | 102 | **3** | 2 | 36 | 3 |
**8/8: the whole crowd costs `nearRigs × 1 + 1`** — one draw per near rig (every ped GLB is one
primitive and one material, measured off the bytes for all five new bodies) and exactly one for the
entire instanced billboard layer whatever it holds. At `market_square` the crowd's total cost is
**1 draw**, so the +2 there cannot be mine. A one-for-one swap of one-draw peds has no mechanism by
which to move the street number, and ruling 4's nine draws are untouched. Same LOD tiers, same
`NEAR_MAX = 24` cap, same single mid draw.
### The contract, re-measured on today's tree (E's claims verified, not trusted)
Read straight off the GLB bytes — no browser, no three, no deps — and then replayed through
`rigs.js`'s own filter over **all 54 shipped clips** (8 base + Lane E's 46-clip library):
| ped | nodes/joints | prim/mtl/img | anims | boneset | bindable tracks | worst clip |
|---|---|---|---:|---|---|---:|
| `man_casual_01` (reference) | 67 / 65 | 1 / 1 / 1 WebP | 0 | — | 3 456 / 3 460 | 64 |
| `man_casual_04` | 67 / 65 | 1 / 1 / 1 WebP | 0 | **IDENTICAL** | **3 456 / 3 460** | 64 |
| `person_casual_01` | 67 / 65 | 1 / 1 / 1 WebP | 0 | **IDENTICAL** | **3 456 / 3 460** | 64 |
| `man_smart_01` | 67 / 65 | 1 / 1 / 1 WebP | 0 | **IDENTICAL** | **3 456 / 3 460** | 64 |
| `woman_smart_02` | 67 / 65 | 1 / 1 / 1 WebP | 0 | **IDENTICAL** | **3 456 / 3 460** | 64 |
| `person_youth_01` | 67 / 65 | 1 / 1 / 1 WebP | 0 | **IDENTICAL** | **3 456 / 3 460** | 64 |
E's numbers reproduce exactly, including the facing check (toeheel ΔZ / span **+0.100 … +0.136**,
all positive, front = local +Z). No Draco anywhere. **I did not need to fix an asset.**
**And the control found something E's audit did not.** The gate needs a subject that must FAIL, and
one was already on disk: the retired `comical_boy_01` scores **3 132 / 3 460, worst clip 58**,
because six of its thumb bones — `{Left,Right}HandThumb{2,3,4}` — carry no `mixamorig:` prefix. A
ped that shipped for 41 rounds had been silently dropping six tracks per clip on both hands. It is
being replaced for period reasons; it should have been replaced for correctness reasons too.
### Animation — every new body actually MOVES, not merely loads
Each replacement spawned by name through the real `makeActor` path, playing walk and then idle, 24
mixer steps at 1/30 s. The reference ped runs the identical course as the control.
| body | walk: tracks · mixer Δt · bone travel · stature range · head-above-hips | idle |
|---|---|---|
| `man_casual_04` | 64 · 0.8 s · 83.4 · 1.6871.728 m · +0.586 m | 64 · 0.8 s · 47.2 · 1.7611.799 m · +0.583 m |
| `person_casual_01` | 64 · 0.8 s · 81.9 · 1.6631.722 m · +0.580 m | 64 · 0.8 s · 45.4 · 1.7541.783 m · +0.578 m |
| `man_smart_01` | 64 · 0.8 s · 84.3 · 1.6671.722 m · +0.541 m | 64 · 0.8 s · 46.9 · 1.7481.786 m · +0.539 m |
| `woman_smart_02` | 64 · 0.8 s · 83.6 · 1.6521.721 m · +0.567 m | 64 · 0.8 s · 45.8 · 1.7461.775 m · +0.565 m |
| `person_youth_01` | 64 · 0.8 s · 83.9 · 1.6621.731 m · +0.534 m | 64 · 0.8 s · 47.0 · 1.7361.765 m · +0.529 m |
| `man_casual_01` (control) | 64 · 0.8 s · 85.9 · 1.6781.727 m · +0.577 m | 64 · 0.8 s · 47.8 · 1.7641.805 m · +0.575 m |
**64 bound tracks each** — the reference's own score. The two traps E's clip work guarded against are
checked directly: the **flat-body / R16 fold** (head never closer than +0.53 m above the hips through
either clip; a folded body reads ~0) and the **stature collapse** (never below 0.94 × nominal). Bone
travel is ~83 for walk and ~46 for idle on all six — the skeleton is genuinely moving, and moving the
same amount as the ped that has shipped since R2.
### No-giants gate — green, and now spanning three bind scales
`tools/qa/interior_scale_check.py`: **PASS — 29 interior figs across 6 shops all human-sized
[1.4, 2.0] m and under-ceiling.** The R36 gated-tail leg now spans every native bind scale the
roster contains and `rigs.js`'s span-normalise absorbs all of them:
`woman_dj_01 0.986u→1.63m · woman_dj_02 0.987u→1.69m · woman_dj_03 0.984u→1.76m · dj_techno_01
0.986u→1.85m · man_smart_01 **1.764u**→1.82m` (the legacy roster is ~173180 u, the centimetre
convention). **One line changed in that gate**, and it is declared here because the file is not on
my §42.4 editable list: its `DJ_BODIES` array named `dj_phrtt_01` literally, so the swap would have
turned it red with "NOT IN FLEET". `dj_phrtt_01``man_smart_01` — the same `PED_NAMES.djs` slot,
the same `fleet.all[21]`, so the leg still covers exactly indices 1721. No threshold, rule or
assertion was touched.
### Impostor vs rig at the swap boundary
R2's break was a **systematic** 1.67× — a double-tone-mapped impostor material, every ped at once —
so an absolute tolerance is the wrong instrument. The bake lights the ped with its own key+hemi+env
and the street lights it with the sun, so the billboard/rig luminance ratio is per-albedo and 1.000
was never the contract. Method: stage the subject in the live shell at a fixed spot, once as a near
rig posed at the atlas's own bake phase and once as a single instance of the LIVE atlas; difference
each against an empty frame; count only pixels inside the billboard quad's projected box. Contact
strip: `docs/shots/laneD/r42_impostor_boundary.jpg`.
| | billboard/rig luminance |
|---|---|
| the **19 bodies this round did not touch** (control population) | **0.470 2.100**, median 0.972 |
| worst-matched body in the whole town | `man_dj_streetwear_01` **0.470** — shipped long before this round |
| the five new bodies | 0.960 · 0.673 · 1.171 · 1.323 · 1.098 — **every one inside the untouched roster's own spread** |
| systematic shift (new median ÷ untouched median) | **1.13×** (R2's break was 1.67× on the whole fleet) |
**Verdict: the swap does not widen the near↔mid seam.** Filed for a later round, not faked here: the
seam itself is loose (a 4.5× spread across the roster) because the atlas is baked under fixed studio
light and displayed under the sun — a real improvement is baking under the scene's own key, and that
is a system, which ruling 5 forbids this round.
### Lane B's §42.5 landed mid-round and broke my bench code. The mirror is retired.
B fixed a 41-round-old defect: benches were placed with `yaw = atan2(ux, uz)` — local +Z **down the
street** — while both files' comments said "facing the road". My R41 `benchStationsFor()` was a
faithful copy of that wrong line, so the fix made it 90° stale and `_seatPose` would have put sitters
up to 0.38 m off the plank facing along the street.
**Fixed by deleting the copy, not by rotating it.** `sim.js` now imports `benchStops(plan)` from
`world/furniture.js` and `benchStationsFor(e)` is a thin adapter that wraps the sim edge back into
the two-node plan shape `resolveEdges()` wants. It has no arithmetic of its own; the rule exists once
in the repo, in the function B's placer also calls. The three constants the mirror carried
(`FURN_FOOT`, `BENCH_S0`, `BENCH_STEP`) are gone from `sim.js`.
| | measured |
|---|---|
| bench front vs direction-to-road, 40 stations | **0.0° (min = median = max)** — was 90.0° |
| bench front vs the street's own direction | 90.0° |
| stations matched to real instanced geometry (`r41_citizens.py` arm 6) | **14/14 within 2 cm · 14/14 on yaw** |
| CONTROL: the same stations offset 2 m | **0/14** — "coincides" is a measurement |
| live sitters, off the bench origin | **+0.06 m in front · ±0.38 m along the plank · 0.0° between look direction and the road** |
| identity + posture signatures across the rewire | **byte-identical** (station COUNT unchanged ⇒ same randoms drawn) |
`_seatPose` needed no edit: it was written in the bench's own local frame (+Z front, +X plank), so
the corrected yaw carries straight through. The import is **static on purpose** — the bench check
draws three seeded randoms per crossed station, so a dynamically-imported station list would draw
zero until it landed and "same seed → same crowd" would start depending on the network. `chunks.js`
already imports `furniture.js` on every boot (classic and `?noassets` included), so the game path
gains no fetch, no module and no behaviour. The standalone `citizens_test.html` does gain one
fail-soft depot fetch; smoked green, 507 active citizens, 0 console errors.
### The browse smoke (F's filing) — fixed, and `r41_shots.py` exits 0
The arm scanned up to 8 occupied shops at ~3.1 s each, kept the id of the best, **exited**, and
re-entered it 1625 s later — choosing a subject out of a live simulation and then going back for it.
The fix is not a longer wait or a retry: the frame, the measurements and the JPEG are now taken
**inside the winning room, the moment a candidate beats the incumbent**, and the harness never
leaves. The scan keeps its original "best of several, not first that works" behaviour. The winning
shop's occupancy at entry and at the end of the scan is printed into the sidecar on every run, so the
decay the old path walked into stays legible instead of being a story in a comment.
Result: `tools/qa/r41_shots.py` **rc 0**`Echo Toys & Games`, 3 browse points, **1 browser in
frame playing `browse_open_cabinet`**, keeper on `idle_weight_shift`, 61 draws of the ≤350 interior
law, both figures human-sized. It can go strict.
**The diagnosis, corrected by measuring it.** F filed this as *deterministically* red. It is not, on
today's tree. I ran the R41 arm **verbatim, inside its own harness, after the same street arm and the
same 60 s soak**, and it came back `{browsePoints: 3, browsers: 1}` on the re-entry — green. Two
isolated repros agreed (one found 2 browsers still there after a 16.4 s scan, occupancy 2 → 2). So
the correct claim is not "it was always red and now it is green"; it is that the arm was **flaky by
construction** — it chooses a subject out of a live simulation and then goes back for it 1625 s
later, and whether the occupants are still there is not something the harness controls. That is
exactly the property that cannot go `--strict`. Removing the window removes the flake; the residual
failure mode ("no occupied shop has browse points with browsers at all") is real, deterministic, and
worth failing on.
### Gates
```
tools/qa/r42_cast.py PASS — contract · roster · determinism · draws · animation · impostor
tools/qa/r41_citizens.py PASS — boot ledger, lazy loading, determinism, draw table,
asset-free, bench binding (14/14 + 14/14 yaw), venue clips
tools/qa/interior_scale_check.py PASS — 29 figs / 6 shops, all human-sized, under ceiling
tools/qa/r41_shots.py rc 0 — street + browse interior, browse arm now shot in place
tools/qa/r42_shots.py rc 0 — r42_street_crowd.jpg + r42_pub_crowd.jpg, cast named in both
console errors 0 on default · 0 on ?classic=1 · 0 on the impostor arm
```
### Shots
- `docs/shots/laneD/r42_pub_crowd.jpg`**the before/after**, shot on Lane F's own §41.6 hero recipe
(`?gigs=1`, segment 5, The Exchange Hotel, Lane C's `room.spawn` line, back 5.2 m, eye 1.62 m) so
it is frame-comparable with `docs/shots/laneF_r41/pub_furnished.jpg`. **BEFORE:** a purple
`EL CHUPACABRA` luchador filling the foreground, a second on the stage, and an unclothed figure at
the stage edge. **AFTER:** 13 figures wearing 11 distinct bodies, none of them retired.
- `docs/shots/laneD/r42_street_crowd.jpg` — the recast roster on the footpath, with the corrected
bench pose.
- `docs/shots/laneD/r42_impostor_boundary.jpg` — near rig ‖ mid billboard for the five new bodies
plus the reference, with each pair's measured luminance ratio.
- Both crowd sidecars carry the **R10 human-sized line** *and* a **CAST line** naming every body in
frame and asserting none is retired — because "the luchador is gone" is a claim about the cast and
a JPEG cannot carry it alone.
### For Lane E / Lane F
- **`dj_phrtt_01.glb` is still in `web/models/peds/` and still on the depot** (`_normalized/`,
`_published.json`). It is no longer reachable from the game — nothing names it — but the file is
not mine to delete. **E: withdraw the depot copy.** **F: the §42.6 period-law name gate should read
`PED_NAMES`, not the directory listing**, or it will go red on files that no longer ship.
- **`comical_boy_01` was a real contract break, not only a period one** (3 132/3 460, six unprefixed
thumb bones). If any other Mixamo asset was converted through the same path, check its boneset.
- **The near↔mid impostor seam is loose town-wide** (0.4702.100 across the roster; worst is
`man_dj_streetwear_01`, not a new body). Filed for R43: bake the atlas under the scene's own key.
### Files touched
`web/js/citizens/rigs.js` (the swap + the law at the declaration) · `web/js/citizens/sim.js` (bench
mirror retired → `benchStops` import) · `web/js/citizens/keepers.js` (publish `pedIndex` on the
keeper handle — informational, draws no randoms, so the room's cast is auditable at runtime the way
`band.js` members already were) · `tools/qa/r42_cast.py` (new) · `tools/qa/r42_shots.py` (new) ·
`tools/qa/r41_shots.py` (browse arm) · `tools/qa/interior_scale_check.py` (one array entry, declared
above) · `D-progress.md` · `docs/LANES/LANE_D_NOTES.md` · `docs/shots/laneD/**`. **No git command was
run.** I did not touch `web/models/**`, `web/assets/**`, `pipeline/**`, `web/js/world/**`,
`web/js/interiors/**`, `web/index.html` or `tools/qa.sh`.
---
## Round 41 (§41.3) — the citizens come alive: Lane E's 46 clips wired, 20 of them now in play. (2026-08-07) ## Round 41 (§41.3) — the citizens come alive: Lane E's 46 clips wired, 20 of them now in play. (2026-08-07)
**The measurement that is the round.** A census of the live crowd at the retail heart, 12 samples × **The measurement that is the round.** A census of the live crowd at the retail heart, 12 samples ×

View File

@ -11,6 +11,255 @@
> explicitly asked for its control-arm transcripts in THIS file, and the R41 §41.2 tasking asked for > explicitly asked for its control-arm transcripts in THIS file, and the R41 §41.2 tasking asked for
> its three import tables here, so §40 and §41 live here AND are cross-referenced from the notes.)* > its three import tables here, so §40 and §41 live here AND are cross-referenced from the notes.)*
## Round 42 (§42.1 + §42.2 asset half — THE LOOK) · 2026-08-07
*Entry here by explicit R42 tasking (as R40 and R41 were). Cross-lane hand-off in
`docs/LANES/LANE_E_NOTES.md` §42. The cast half of this round (§42.3) is its own section below.*
**46 assets repainted, 0 draws added, 0 triangles added, 0 vertices moved.** 39 pub props dressed
into a 1990s Australian pub/cafe/milk-bar palette; the record-shop DJ booth diagnosed and given
contrast. Published + sha1-verified; both validators 0 errors.
### The finding that reframed the round: my own R41 note was wrong
R41 §41.2c says *"37 of 39 have no texture at all"*, and the R42 tasking (reasonably) read that as
**no colour**. Measuring the shipped files says something different and more useful:
* **all 37 DO carry `COLOR_0`, and three.js does render it.** `GLTFLoader` sets `vertexColors` on
any primitive with a `COLOR_0` attribute, and `batch.js` never touches a GLB's interior (the
loaded root is a `Group`, not a `Mesh`, and `batchRoom` only buckets `fit.children` that are
meshes). Nothing was suppressing the colour.
* The colour is simply **bleached**. Measured mean vertex colour: `beer_umbrella` sRGB
**238,238,238** · `ash_urn` 225,225,225 · `jukebox` **175,174,173 at saturation 0.02** ·
`old_couch` 176,168,155. **15 of the 37 sit below 0.05 saturation.** The source library's
"colour" is really baked ambient occlusion.
That changes the cheapest correct fix. It is not a missing map to be authored — it is a **palette
to be imposed on colour that is already there**, which is why the whole round is a rewrite of the
existing `COLOR_0` buffer, byte-length for byte-length, and not one new texture. *(Measure the
thing, not the note about the thing — including your own note.)*
### The palette, and why these colours
`pipeline/r42_palette.json` (every asset **by name**, no wildcards — R41 ruling 3). 28 named
swatches drawn from what a suburban Australian pub, cafe and milk bar actually was in 1996:
beer-stained carpet red `#7a2233` · burgundy vinyl `#5e1f2a` · brown vinyl `#6b4526` · dark stained
timber `#2e1c12` · pine `#b08a54` · cream laminex `#dcd0ae` · brass `#a97f2c` · chrome `#c6cbd0` ·
stainless `#98a0a6` · "hotel green" `#2f4a3a` · beer gold `#c98b22` · terracotta `#9c5330`.
Deliberate calls: the `banquette` was a **1990s teal** (37,94,118) that reads as office furniture,
and went burgundy; `beer_umbrella` was pure white and went brewery green; `bin_round` and
`wheelie_bin` went council dark-green; the `pokie` and `planter` kept their own hue because they
had one worth keeping.
### The five routes — chosen by what the source colour IS, not by taste
| route | when | what it does | used on |
|---|---|---|---|
| `ramp` | source is bleached grey (the majority) | luminance → a 25 stop palette gradient, normalised between the asset's own 1st/99th luminance percentiles. **The greyscale IS the baked shading**, so mapping it through a dark→light ramp keeps every AO gradient and adds hue. | 34 |
| `tint` | source already carries meaningful hue | keep the hue relationship, force saturation, remap value | 6 |
| `huesplit` | one asset, two materials, separable by hue | two ramps selected by source hue — "a planter is a pot and a plant, and they are not one ramp" | 2 (`planter`, `topiary`) |
| `factor` | no `COLOR_0` at all (a real texture) | tint the map via `baseColorFactor` | 2 (`keg`, `urinal_trough`) |
| `lut` | source colours are a discrete 512 entry palette where each entry **is a part** | exact colour → target, ordered rules, first match wins, optional position predicates (`cyl` / `y_min` / `y_max`) | 2 top-level (`deck_1200_rigged`, `deck_1200`) + 5 of the 7 per-mesh sub-specs |
**Why this is draw-neutral, stated as mechanism not as hope.** three.js issues one draw per glTF
*primitive*; materials are free. The tool rewrites the `COLOR_0` accessor **in place** — same
accessor, same component type, same element count, same byte length — and only edits the material
block for metallic/roughness/emissive, cloning a material when a per-mesh override would otherwise
leak onto a sibling primitive. Primitive counts are untouched by construction, and `batch.js`
cannot merge or split GLB interiors at all.
### Per-asset result — 46 rows, every delta measured from the shipped file
| id | route | mean sRGB before → after | sat before → after | prims (=draws) | tris | draw Δ | tri Δ |
|---|---|---|---|---:|---:|---:|---:|
| `old_couch` | ramp | `(176, 168, 155)``(109, 73, 40)` | 0.25 → 0.86 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `jukebox` | ramp | `(175, 174, 173)``(132, 96, 46)` | 0.02 → 0.88 | 1 → 1 | 4999 → 4999 | **+0** | **+0** |
| `banquette` | ramp | `(37, 94, 118)``(113, 44, 54)` | 0.9 → 0.85 | 1 → 1 | 4999 → 4999 | **+0** | **+0** |
| `plywood_bar` | ramp | `(162, 150, 133)``(89, 60, 31)` | 0.35 → 0.87 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `bistro_table` | ramp | `(128, 100, 61)``(80, 64, 46)` | 0.79 → 0.66 | 1 → 1 | 4999 → 4999 | **+0** | **+0** |
| `pokie` | tint | `(201, 179, 154)``(177, 150, 119)` | 0.45 → 0.58 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `bain_marie` | ramp | `(181, 182, 181)``(163, 165, 164)` | 0.01 → 0.03 | 1 → 1 | 4958 → 4958 | **+0** | **+0** |
| `pie_warmer` | ramp | `(186, 185, 186)``(151, 145, 138)` | 0.0 → 0.18 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `keg` | factor | (textured — baseColorFactor) | — | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `lounge_chair` | ramp | `(219, 199, 165)``(128, 78, 55)` | 0.47 → 0.83 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `ash_urn` | ramp | `(225, 225, 225)``(164, 170, 175)` | 0.0 → 0.13 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `ashtray_post` | ramp | `(198, 192, 188)``(141, 146, 151)` | 0.12 → 0.13 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `beer_umbrella` | ramp | `(238, 238, 238)``(51, 100, 74)` | 0.0 → 0.73 | 1 → 1 | 4995 → 4995 | **+0** | **+0** |
| `bin_round` | ramp | `(190, 190, 191)``(88, 104, 92)` | 0.01 → 0.3 | 1 → 1 | 4999 → 4999 | **+0** | **+0** |
| `broken_basin` | ramp | `(187, 183, 175)``(190, 185, 169)` | 0.14 → 0.23 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `cable_snake` | ramp | `(88, 88, 89)``(17, 17, 19)` | 0.03 → 0.16 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `can_stack` | ramp | `(181, 174, 168)``(145, 132, 119)` | 0.15 → 0.35 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `cdj_deck` | ramp | `(56, 56, 57)``(64, 66, 68)` | 0.06 → 0.12 | 1 → 1 | 4998 → 4998 | **+0** | **+0** |
| `champ_bucket` | ramp | `(213, 213, 213)``(189, 194, 198)` | 0.0 → 0.1 | 1 → 1 | 4999 → 4999 | **+0** | **+0** |
| `concrete_plinth` | ramp | `(169, 170, 168)``(136, 132, 123)` | 0.02 → 0.19 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `crowd_barrier` | ramp | `(217, 218, 219)``(158, 165, 169)` | 0.02 → 0.14 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `distro_box` | ramp | `(155, 153, 153)``(81, 79, 80)` | 0.03 → 0.05 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `fire_ext` | ramp | `(240, 109, 99)``(88, 45, 22)` | 0.86 → 0.92 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `firepit` | ramp | `(97, 93, 92)``(68, 40, 26)` | 0.12 → 0.82 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `flight_case` | ramp | `(161, 161, 161)``(41, 43, 48)` | 0.01 → 0.25 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `marble_sink` | ramp | `(230, 228, 222)``(211, 205, 189)` | 0.08 → 0.22 | 1 → 1 | 4998 → 4998 | **+0** | **+0** |
| `pallet` | ramp | `(186, 166, 143)``(129, 96, 56)` | 0.44 → 0.82 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `picnic_table` | ramp | `(142, 142, 126)``(123, 91, 54)` | 0.24 → 0.82 | 1 → 1 | 4999 → 4999 | **+0** | **+0** |
| `planter` | huesplit | `(162, 190, 162)``(111, 128, 67)` | 0.3 → 0.74 | 1 → 1 | 4897 → 4897 | **+0** | **+0** |
| `topiary` | huesplit | `(162, 171, 153)``(140, 129, 71)` | 0.23 → 0.76 | 1 → 1 | 4999 → 4999 | **+0** | **+0** |
| `raffle_drum` | ramp | `(214, 214, 215)``(146, 110, 41)` | 0.01 → 0.92 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `rope_post` | ramp | `(230, 214, 170)``(176, 136, 64)` | 0.49 → 0.88 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `sandwich_board` | ramp | `(209, 180, 148)``(123, 92, 54)` | 0.53 → 0.81 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `smoke_machine` | ramp | `(85, 85, 85)``(23, 23, 26)` | 0.01 → 0.16 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `speaker_stack` | ramp | `(100, 100, 101)``(25, 25, 28)` | 0.03 → 0.16 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `strobe_light` | ramp | `(45, 44, 45)``(36, 36, 37)` | 0.04 → 0.07 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `sub_bass` | ramp | `(78, 78, 78)``(24, 24, 27)` | 0.02 → 0.18 | 1 → 1 | 4998 → 4998 | **+0** | **+0** |
| `urinal_trough` | factor | (textured — baseColorFactor) | — | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `wheelie_bin` | ramp | `(104, 108, 111)``(57, 78, 61)` | 0.12 → 0.47 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `deck_1200_rigged` | lut | `(162, 162, 163)``(172, 162, 166)` | 0.01 → 0.12 | 5 → 5 | 3821 → 3821 | **+0** | **+0** |
| `deck_1200` | lut | `(152, 151, 152)``(167, 166, 170)` | 0.01 → 0.06 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `mixer_ttm54i_rigged` | tint | `(171, 164, 158)``(183, 171, 162)` | 0.16 → 0.23 | 2 → 2 | 5000 → 5000 | **+0** | **+0** |
| `mixer_pmc05pro_rigged` | tint | `(173, 167, 166)``(186, 179, 176)` | 0.09 → 0.12 | 2 → 2 | 5000 → 5000 | **+0** | **+0** |
| `mixer_shdj1200_rigged` | tint | `(161, 151, 137)``(179, 165, 149)` | 0.29 → 0.34 | 2 → 2 | 4999 → 4999 | **+0** | **+0** |
| `mixer_pmx9000` | tint | `(162, 157, 156)``(171, 166, 164)` | 0.08 → 0.09 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
| `sl_deck_3` | tint | `(206, 180, 158)``(217, 182, 153)` | 0.44 → 0.54 | 1 → 1 | 5000 → 5000 | **+0** | **+0** |
**Draw delta total: 0. Triangle delta total: 0.** And the stronger claim, checked rather than
asserted: for all 46, the concatenated bytes of every `POSITION`, `NORMAL`, `TEXCOORD_0` and index
accessor are **sha1-identical to the R41 files**. Footprint, height, base origin and the manifest's
own numbers therefore cannot have moved. *(My first pass measured base-origin with a hand-rolled
bbox walker that ignored node rotations and duly "found" three phantom violations on the mixers —
the byte comparison is the honest check, and it is the one reported.)*
### §42.2 — THE DECK: diagnosis first, then the fix
Rendered three ways with a new tool (`pipeline/isolate.py` — a perspective camera at 1.6 m eye
height, on a bench of a stated colour, which is the only rig that can answer "does this read from
where the player stands"). Proof: `docs/shots/laneE/r42_deck_diagnosis.png`, all three panels on
the **R41 file, before any change**.
| hypothesis | test | measurement | verdict |
|---|---|---|---|
| **(a) the asset** | deck alone, neutral `#f2efe6` bench, room light | 7 greys, max saturation **0.02**; the dominant chassis band is **linear 0.05 (sRGB 62) over 2,460 of 4,622 `Body` vertices**; and the only high-contrast feature is a **white ring around a dark disc** — inside-out, because a turntable reads as a dark record on a light platter | **PRIMARY CAUSE** |
| **(b) the composition** | the real `record_hero` trio on Lane C's real bench colour `#3a3128` (`fittings.js` `djBooth`) | chassis rel. luminance 0.050 vs bench 0.033 = **WCAG contrast 1.21:1** (1.36:1 on the `#2a2a30` variant, 1.65:1 on the `#1c1a18` top edge) | **CONTRIBUTING** — it erases what little separation the asset had |
| **(c) the lighting** | (b) again at 2.2x the lamp | brighter slab, same read: a zero-saturation, zero-internal-contrast model gives light nothing to reveal | **NOT THE CAUSE** |
**The fix (LUT, per mesh, 0 draws, 0 tris):** chassis → Technics silver `#b9bdc2` (rel. luminance
0.506, so **6.74:1** against the same bench — x5.6); platter surface → alloy `#c9ced3`; the disc
that already sat on the platter → **black vinyl `#17171a`**, so the record now reads dark-on-light
instead of the other way round; `StartStopBtn` and the front pad → `#c0202a`; the tonearm
(`Headshell`) → graphite `#2c2e33` so it silhouettes against the now-silver top plate; `PitchCap`
`#f0ece2` with a warm `emissiveFactor`. The static twin `deck_1200` is one joined primitive, so
its record disc is found **by position** — measured at y = 0.079, up-facing, r < 0.158 of the
platter centre (-0.0455, 0) — not by node. The three rigged mixers and `mixer_pmx9000` /
`sl_deck_3` got a `tint` lift so the booth reads as gear rather than as a slab.
**No label on the record, and the reason is topology.** The platter disc is a **triangle fan**:
160 vertices at r = 0 and 162 at r = 0.15, nothing between. A centre colour therefore interpolates
across the whole disc — the first take painted a red label and produced a red dinner plate. Flat
vinyl black is the only honest colouring of that topology; a real label needs ~32 triangles of new
geometry, which is filed below rather than smuggled in.
**Ruling 5 honoured: no driver.** Nothing was wired to `Platter_SPIN`, `Arm_YAW`, `Fader_PITCH`,
`Btn_STARTSTOP` or `Fader_CROSS`. They are recorded, intact, and still waiting for R43.
### Node contract — re-verified from the shipped GLB, after the repaint
```
$ python3 pipeline/check_contract.py --list pipeline/_normalized/procity_fit_deck_1200_rigged_01.glb ...
procity_fit_deck_1200_rigged_01.glb Platter_SPIN,Arm_YAW,Fader_PITCH,Btn_STARTSTOP 4/4 PASS
procity_fit_mixer_ttm54i_rigged_01.glb Fader_CROSS 1/1 PASS
procity_fit_mixer_pmc05pro_rigged_01.glb Fader_CROSS 1/1 PASS
procity_fit_mixer_shdj1200_rigged_01.glb Fader_CROSS 1/1 PASS
procity_fit_deck_1200_01.glb - STATIC by design (R41)
```
### A geometry fault found while diagnosing — reported, not fixed
`deck_1200_rigged` **has no plinth**. Measured: **9 of its 8,740 vertices (0.10 per cent) sit below
y = 0.05**, against 822 of 11,138 (7.38 per cent) in the static twin `deck_1200` built from the same
source. The bottom **43 mm of a 119 mm body is absent**, and the model's base origin is held by a
nine-vertex fragment at the back rather than by its own footing — so `glb.js`'s
`position.y = at.y - bb.min.y * s` plants the chassis floating ~43 mm over the bench top. It is
visible in `r42_deck_before_after.png`: the rigged deck (left) is a plate, the static (right) has
feet. This is R41 join/decimate damage, it is geometry rather than look, and repairing it means
re-running `rig_join.py` against ultra's source with a different budget — filed to R43 with the
number rather than attempted in a colour round.
### Gate, publish, validate
**Four-surface transmission gate, with its control demonstrated** (house law: a gate that has never
fired is not a gate):
```
$ python3 pipeline/strip_transmission.py --dry-run <46 repainted GLBs>
46/46 "clean, nothing to do" # 0 transmissive materials
$ # CONTROL ARM - inject a real KHR_materials_transmission into one repainted asset:
$ python3 pipeline/strip_transmission.py --dry-run control_couch.glb
control_couch.glb: material[0] 'DefaultMaterial' - removed KHR_materials_transmission
{'transmissionFactor': 0.85} # the gate fires
```
House-law sweep over all 46, measured from the **served** copies: max tris **5,000** (<=5k), max
texture **1024** (<=1024 WebP), Draco **0**, transmission **0**, `_normalized/` vs
`web/assets/models/` sha1 **46/46 identical**. **0 violations.**
```
$ GOD3_DEPOT=http://100.94.195.115:8788 GOD3_PW=... python3 pipeline/publish.py \
--files pipeline/_r42_publish_list.txt
published 46/46 this run -> _published.json now 174 (was 174).
OK _published.json matches the live depot (all 174 recorded assets live).
$ sha1 verify (fetch each back from the depot, compare bytes)
46/46 identical, 0 mismatched 25.0 MB # -> pipeline/_r42_sha1.json
$ GOD3_DEPOT=http://100.94.195.115:8788 python3 pipeline/validate_manifest.py
manifest v1 - facades 49, skins 92 groups, GLBs 149 (+45 non-manifest GLBs transmission-swept), audio 34
OK - 0 errors, 0 warning(s) # rc=0
$ ... validate_manifest.py --depot
OK - 0 errors, 0 warning(s) # rc=0
```
`manifest.json` is **byte-identical** after `build_manifest.py` (colour is not a manifest fact —
footprint, height, tris, draws and control nodes all unchanged). 46 thumbnails re-rendered through
`thumbs.py` so what Lane C sees in the thumb is what the game now draws.
### Tools added / changed (all in `pipeline/`)
| file | what |
|---|---|
| `palette.py` | **new** — the repaint engine. `ramp`/`tint`/`huesplit`/`lut`/`factor`, in-place `COLOR_0` rewrite, per-mesh overrides, material cloning, `--report`. **Not idempotent by nature** (`ramp`/`tint` normalise against the source's own spread), so every output is stamped `asset.extras.procity_palette` and a stamped input is **skipped unless `--force`** — the pristine pre-R42 GLBs live at `pipeline/.props_orig/r42/` (git-ignored). |
| `isolate.py` | **new** — one staged scene, perspective camera at eye height, optional bench of a stated colour. The tool the deck diagnosis needed and `contact_sheet.py` could not be. Two bugs found by looking at its own output: a hand-rolled camera Euler pair that aimed at the sky (now `to_track_quat`), and a lamp sized for a 0.45 m turntable that burned a 5 m pub corner to white (now scaled to the set). |
| `montage.py` | **new** — labelled before/after grids from finished PNGs, with wrapped captions. A round judged by eye has to pair a render made now against one committed in a previous round, which is not a Blender job. |
| `contact_sheet.py` | `--pitch M` — the default 1 m tile pitch overlaps a 2.68 m beer umbrella onto its neighbours; the 39-prop sheet needs 2.9. Also: `--title` was **silently invisible** at any pitch > ~1.4 (it was parked at a metre offset that scales with pitch and walks off the top of the frame). The caption is now solved for in screen units against the row-0 ground line — the first 39-prop sheet rendered with no title at all and nothing said so. |
| `r42_palette.json` | the by-name spec: 28 swatches + 46 assets, each with its route, its stops/rules and a one-line reason. |
| `_r42_publish_list.txt`, `_r42_sha1.json` | the explicit publish set and the fetch-back digests. |
### Proof shots (`docs/shots/laneE/`)
`r42_pub_contact_39.png` (all 39 dressed, one sheet) · `r42_pub_ab_top5.png` (the top five by
screen area, same scene/lights/camera, R41 vs R42) · `r42_deck_diagnosis.png` (the three-hypothesis
diagnosis, all three panels on the R41 file) · `r42_deck_isolated_ab.png` (the deck alone,
before/after) · `r42_deck_before_after.png` (the `record_hero` booth on Lane C's real bench colour).
### For Lane C (§42.2 composition half)
* Nothing you place changed shape. Same ids, same `footprint`/`height`, same draws, same
`control_nodes`. `manifest.json` is byte-identical — a re-fetch of the depot is all that is needed.
* The booth bench is now the **darkest** thing in that composition. At `#3a3128` the silver chassis
reads at 6.74:1; `#1c1a18` gives 9.19:1 — but a *lighter* bench would reduce it.
* `cdj_deck`, `sl_deck_3` and `mixer_pmx9000` (the `venue` booth) got the same lift, so that booth
reads too.
* The pub palette assumes a **dark, warm floor**. The shots use `#5e2c26`; the props were tuned to
sit on carpet red, not on pale boards.
### Filed to R43
1. **`deck_1200_rigged` has no plinth** (9 of 8,740 verts below y = 0.05; the bottom 43 mm of a
119 mm body). Needs a `rig_join.py` re-run against ultra's source, not a repaint.
2. **A record label needs ~32 triangles.** The platter disc is a fan; colour cannot make a label.
Appending a small label disc to the `Platter` primitive is +32 tris, 0 draws, and would make the
deck unmistakable at any distance.
3. The DJ control-node driver (already filed by the integrator) — the nodes are intact and verified
again this round.
## Round 41 (§41.2 — THE FITTINGS) · 2026-08-07 ## Round 41 (§41.2 — THE FITTINGS) · 2026-08-07
*Entry here by explicit R41 tasking (as R40 was). Cross-lane hand-off for C in *Entry here by explicit R41 tasking (as R40 was). Cross-lane hand-off for C in
@ -1008,3 +1257,238 @@ group or a skeleton mismatch. `pipeline/glb_stat.py --json web/models/clips` is
(new) · `pipeline/AUDIT.md` · `pipeline/README.md` · `E-progress.md` · `docs/LANES/LANE_E_NOTES.md`. (new) · `pipeline/AUDIT.md` · `pipeline/README.md` · `E-progress.md` · `docs/LANES/LANE_E_NOTES.md`.
Outside the repo, for ruling 1 only: `~/Documents/MIRPAMO/library/**`. **No git command was run.** Outside the repo, for ruling 1 only: `~/Documents/MIRPAMO/library/**`. **No git command was run.**
I did not touch `web/assets/manifest.json` (props half's), `web/js/**`, or `tools/qa.sh`. I did not touch `web/assets/manifest.json` (props half's), `web/js/**`, or `tools/qa.sh`.
---
## Round 42 (§42.3) — RECAST THE TOWN *(Lane E, cast half)*
The brief was "audit the shipped roster, replace what does not belong, fill the credits". The
audit found **five rejects, not the one the shot showed**, and the licence question — the one B
filed as unresolvable from this repo — turned out to be **answerable from the asset files
themselves**, which changed the whole shape of the round: nothing had to be replaced *for*
licence, only for period.
### §42.3-A — THE LICENCE, CLOSED BY MEASUREMENT (B's §41 finding, resolved)
B shipped `ped-bodies-base` as amber `unverified` and was right to: nothing in this repo recorded
where the 19 bodies came from. But the answer was **inside the source FBX**, not in a document.
Every ped's source file on ultra carries its own provenance in the FBX header:
```
Original|ApplicationVendor KString "Mixamo, Inc."
Original|ApplicationName KString "mixamo.com"
DocumentUrl KString "working/exporter_<id>_<hash>.tmp" ← Mixamo's server-side exporter
```
Checked on **all 24** character sources: 24/24 carry it. That is machine-readable, per-asset, and
it is in the file rather than in a note about the file.
The second half of the identification came from the GLBs already in the tree. `merge_ped.py`
renames a merged ped's material to `ped_merged`, but the **four peds that were never merged kept
their original material names** — `Ch43_Body`, `Ch09_body`, `Ch18_Body`, `Ch39_Body` — which are
Mixamo's numbered-character ids. Reading the same names out of the source FBX recovers the id for
the other 15. So every ped is now identified to a specific Mixamo character, not to a vendor.
**Zone check:** CITY_SPEC's NPC contract declares `web-ok only (Mixamo, CC0/CC-BY, CGTrader RF)`.
The roster is entirely the **first** of those three. B's specific worry — "if any one of these 19
is CC-BY the credit is owed today and unrecorded" — is answered: **none is CC-BY, no attribution
is owed, and `required: false` is correct** for the same reason it already is on the Mixamo clip
row. The gap was a record-keeping gap, never a compliance one.
### §42.3-B — THE AUDIT (24 shipped bodies, every one rendered and looked at)
Rendered with a new tool, `pipeline/ped_sheet.py`, because `contact_sheet.py` could not do it:
the ped GLBs are centimetre-unit (bbox ~177 "m") and that tool derives its frame height from the
pre-scale bbox, so it emitted a 2 100 × 50 734 px strip. The cast tool normalizes every rig to the
same standing height the way `buildFigure()` does and shoots at eye level — a casting sheet, not a
top-down. Sheets: `docs/shots/laneE/r42_roster_contact.jpg`, `r42_roster_rejects.jpg`.
| file | Mixamo | what it depicts | period verdict | licence | evidence |
|---|---|---|---|---|---|
| man_worker_hivis_01 | Ch17 | hard hat, orange hi-vis vest, dirty work trousers, boots | **SHIP** — the Australian tradie, the single most right model in the roster | Mixamo RF | FBX vendor + `Ch17_Body` |
| man_suit_01 | Ch33 | navy suit, tie, dress shoes | **SHIP** | Mixamo RF | FBX vendor + `Ch33_Suit` |
| man_casual_01 | Ch01 | white polo, blue jeans, sneakers | **SHIP** | Mixamo RF | FBX vendor + `Ch01_Shirt` |
| man_casual_02 | Ch12 | pale blue short-sleeve shirt, dark trousers | **SHIP** | Mixamo RF | FBX vendor + `Ch12_body` |
| man_casual_03 | Ch28 | black long-sleeve, dark trousers, sneakers | **SHIP** | Mixamo RF | FBX vendor + `Ch28_Body` |
| man_grunge_01 | Ch42 | red graphic tee, denim shorts, tattoos | **SHIP** — reads 1996 harder than anything else here | Mixamo RF | FBX vendor + `Ch42_Body` |
| man_athletic_01 | Ch08 | grey trackie top and pants | **SHIP** | Mixamo RF | FBX vendor + `Ch08_Body` |
| man_bouncer_01 | Ch18 | black security uniform, vest, epaulettes | **SHIP** — a pub with a band has a bloke on the door | Mixamo RF | shipped GLB material `Ch18_Body` |
| man_dj_streetwear_01 | Ch06 | black hoodie, headphones, joggers, red/white sneakers | **SHIP, marginal** — silhouette reads 2010s, but it is the record-shop archetype and no better one exists in the pool | Mixamo RF | FBX vendor + `Ch06_body` |
| woman_casual_01 | Ch02 | orange sweatshirt (cat motif), denim shorts, yellow trainers | **SHIP, marginal** — the chunky trainers date it late | Mixamo RF | FBX vendor + `Ch02_Body` |
| woman_casual_02 | Ch26 | green/white striped top, jeans, red heels | **SHIP** | Mixamo RF | FBX vendor + `Ch26_Body` |
| woman_business_01 | Ch07 | navy suit, heels | **SHIP** | Mixamo RF | FBX vendor + `Ch07_Body` |
| woman_athleisure_01 | Ch37 | pink zip jacket, black leggings, trainers | **SHIP, marginal** — technical-fabric leggings read 2010s | Mixamo RF | FBX vendor + `Ch37_Shirt` |
| woman_smart_01 | Ch27 | blazer, trousers, heels | **SHIP** | Mixamo RF | FBX vendor + `Ch27_Suit` |
| woman_chef_01 | Ch13 | white double-breasted chef jacket, dark trousers | **SHIP** — the milkbar/bakery counter | Mixamo RF | FBX vendor + `Ch13_Body` |
| **man_elder_01** | **Ch39** | **robed fantasy elder: purple-and-gold robe with wide sleeves, ornate belt boss, sash, cloth leg wraps, long white beard** | **REJECT** — ruling 1, fantasy. It is not a retiree; it is a wuxia/wizard elder | Mixamo RF | shipped GLB material `Ch39_Body` |
| **man_soldier_ww2_01** | **Ch49** | **WW2 infantryman: olive drab uniform, steel helmet, webbing, ammunition pouches, tall jackboots** | **REJECT** — ruling 1. Visible on the street in the R41 posture shot | Mixamo RF | FBX vendor + `Ch49_body` |
| **comical_boy_01** | **Ch09** | **cartoon child, ~3 heads tall, red helmet, oversized boots** | **REJECT** — ruling 1, cartoon; also breaks the human-sized line on humanoid shots | Mixamo RF | shipped GLB material `Ch09_body` |
| **comical_luchador_01** | **Ch43** | **PURPLE MASKED LUCHADOR. Purple singlet, chest reads `EL CHUPACABRA`, orange arms, purple mask/gloves/boots, barrel torso** | **REJECT** — this is the model in the R41 pub shot. Ruling 1 names it explicitly | Mixamo RF | shipped GLB material `Ch43_Body` |
| woman_dj_01 | — (TRELLIS) | blue hair, olive utility vest, cargo pants | **SHIP** — venue-gated, reads as a 90s raver | on-device, CC0-equiv | credits `ped-bodies-dj` |
| woman_dj_02 | — (TRELLIS) | green hair, black jacket, crop top, neon boots | **SHIP** — venue-gated | on-device, CC0-equiv | credits `ped-bodies-dj` |
| woman_dj_03 | — (TRELLIS) | hooded jacket, crop top, cargo pants | **SHIP** — venue-gated | on-device, CC0-equiv | credits `ped-bodies-dj` |
| dj_techno_01 | — (TRELLIS) | dark hooded jacket, cargo pants, boots | **SHIP** — venue-gated | on-device, CC0-equiv | credits `ped-bodies-dj` |
| **dj_phrtt_01** | — (TRELLIS) | **an unclothed body. No garment exists on the asset** | **REJECT** — see §42.3-C | on-device, CC0-equiv | its own 1024² texture |
**19 ship (3 of them marginal) · 5 reject.** Rejects: 1 luchador, 1 cartoon child, 1 fantasy elder,
1 WW2 soldier, 1 nude.
### §42.3-C — THE ONE THE BRIEF DID NOT ASK ABOUT: `dj_phrtt_01`
Rendering the roster turned up an asset nobody had looked at since R36. `dj_phrtt_01.glb` renders
as a **nude female figure**, and it is not a texture-load failure — I extracted its single embedded
image and looked at it. The GLB has **one** 1024² WebP and that atlas is **bare skin end to end**.
There is no clothing on the asset to fail to load.
Three things make it worse than a period miss:
1. **It is in the default crowd.** `loadPedFleet`'s `opts.djs` defaults to `dance`, and the shell
passes `dance: !CLASSIC` — so every boot that is not `?classic=1` loads it and `pickRig` can
place it in a venue.
2. **It is on the shared depot** (`pipeline/_normalized/dj_phrtt_01.glb`, published, in
`_published.json`), so it is not only in this repo.
3. **It is the R41 deny-list's failure class arriving through a different door.** That list bans
`character_kit_modular/exports/bodies/**` and its 30 `-nsfw` bodies by name. This one was never
on the list because it came from a different pipeline (on-device TRELLIS, R36), and a name
deny-list cannot catch an asset whose name is innocent. **The control the deny-list needs is a
look, and this round is the first time anyone looked.**
Replacement staged (`man_smart_01`, below). I did **not** delete it — see §42.3-F.
### §42.3-D — RULING 2 vs THE CONTRACT: Quaternius measured, and it cannot ship
Ruling 2 says prefer CC0, and names Quaternius. I measured it before using it, and it fails the
ped contract outright:
| | shipped ped | Quaternius `Casual_Male.gltf` |
|---|---|---|
| nodes | 67 | **25** |
| skin joints | 65 | **23** |
| bone naming | `mixamorig12:LeftForeArm` | **`LowerArm.L`, `Torso`, `Abdomen`, `Fist.L`** |
`rigs.js _action()` filters an incoming clip's tracks to bones that exist on the target and builds
a clip from what survives. Against a Quaternius rig **zero** of the 64 canonical tracks survive —
so the model would load, report no error, and stand in bind pose forever. That is precisely the
silent break the brief warned about, and it would take D's 46 clips and the impostor bake with it.
Making it work means a retarget, which is a new system and ruling 5 forbids one this round.
**So the CC0 route is refused on measurement, and the replacements come from the other pool the
ruling names — ultra's Mixamo people-normal FBX.** This costs nothing on the licence side, because
§42.3-A established that pool's licence *is* the roster's licence: same vendor, same regime,
already credited, `required: false`. CC0's advantage was "fixes the look and the licence in one
move"; the licence half of that was already fixed by then.
**A second contract filter did real work inside the Mixamo pool.** Converting 20 candidates showed
bone counts of 65, 67, 68, 69, 83, 86, 87, 88 and **99** — the named Mixamo characters (`Aj`,
`Remy`, `The Boss`, `Sporty Granny`, `claire`, `kaya`, …) carry extra facial/hair/cloth bones and
would break the 65-joint contract exactly like Quaternius does, only less obviously. Only the
**65-bone `Ch##` family** is contract-clean, which is the same family the shipped roster came from.
That cut 20 candidates to 9 before a single aesthetic judgement was made.
### §42.3-E — THE REPLACEMENTS (5 for 5, roster size unchanged)
Of the 9 contract-clean candidates, 4 failed ruling 1 on sight and are recorded as rejected
sources: `woman_raver_01` (Ch03 — cyber visor, reflective-banded rave pants), `comical_girl_skater_01`
(Ch29 — magenta helmet, stylised), `man_jumpsuit_01` (Ch32 — orange coverall **and an untextured
faceless head**), `man_footballer_01` (Ch38 — full football kit with a **club crest**, which is the
quarantine rule's own trigger). The remaining 5 are exactly the 5 slots to fill.
| slot (pool) | out | in | Mixamo | tris | nodes/joints | boneset | clip bind | sha1 |
|---|---|---|---|---|---|---|---|---|
| normal | man_elder_01 | **man_casual_04** | Ch31 | 2 533 | 67 / 65 | MATCH | 3456/3460 | `d855c4cf…` |
| normal | man_soldier_ww2_01 | **person_casual_01** | Ch21 | 2 956 | 67 / 65 | MATCH | 3456/3460 | `2c3b7ff1…` |
| comical | comical_boy_01 | **person_youth_01** | Ch22 | 2 738 | 67 / 65 | MATCH | 3456/3460 | `94c936c1…` |
| comical | comical_luchador_01 | **woman_smart_02** | Ch41 | 2 494 | 67 / 65 | MATCH | 3456/3460 | `0c3ddf21…` |
| djs | dj_phrtt_01 | **man_smart_01** | Ch23 | 2 617 | 67 / 65 | MATCH | 3456/3460 | `68aae4db…` |
Depicting, in order: a bloke in a navy jumper · a bloke in a teal shirt and tan chinos · a teenager
in a white tee and jeans · a woman in a white cardigan and black trousers · a bloke in a waistcoat
over a blue shirt and jeans. With the 19 kept, the roster reads tradie / suit / polo-and-jeans /
grunge kid / teenager / chef / office workers / shop staff.
**Pipeline, now scripted end to end.** The FBX→GLB front end had never existed in this repo (it
lived in 90sDJsim), so recasting had no reproducible first stage. New `pipeline/ped_from_fbx.py`
is it; stages 2 and 3 are the unchanged R4/R7 recipes:
```
ped_from_fbx.py # Blender: FBX -> GLB, T-pose action stripped
gltf-transform weld -> simplify --ratio 0.05 --error 0.12 # 5059k -> 2.53.0k tris
merge_ped.py --atlas 1024 # -> 1 primitive / 1 material / 1 WebP atlas
```
**Contract verification — measured, not assumed.** Every number below is against the reference ped
`man_casual_01.glb`:
- **67 nodes / 65 joints / 1 primitive / 1 material / 1 image / 1024² WebP / no Draco** — all 5.
- **Bone-name set identical to the reference**, all 65, after `_canon` (`mixamorig\d+` → `mixamorig`).
- **Clip binding, the test that matters:** I replayed `rigs.js`'s own filter (canonicalise, keep
`.quaternion`, drop `Hips.quaternion`) over **54 clips** — the 8 base clips plus all 46 of D's
R41 library — against each replacement. Result **3 456 / 3 460 bindable tracks (99.9 %), worst
single clip 64 bound** — **byte-identical to what the shipped reference ped scores**. The 4
unbound are the known `mixamorigReference` node (AUDIT R36 w1), not a new loss.
- **Facing and handedness**, the R13 trap: toe-minus-heel ΔZ / span is **+0.100 … +0.136** on all
five, against **+0.130 … +0.136** on the shipped peds — front is local +Z, which is what
`buildFigure`'s `inner.rotation.y = π` compensates for. A flipped model would have walked
backwards and no gate would have said so.
- **Tri band** 2 4942 956, inside the shipped 2 0674 000 band and on the shipped mean (~2 550).
- **Scale shape:** the raw conversion lands metre-geometry under a 0.01 armature root (the DJ
five's shape). `ped_from_fbx.py` now applies that scale so the output matches the base 17 it is
joining rather than being the one member with a 10⁴ vertex-magnitude difference feeding the same
impostor bake. Verified the fix changed nothing that matters: bone extents identical to 4 dp
before and after (`y = 1.7611`, head bone `1.7761`).
**Draw cost: zero, structurally.** One primitive, one material each — the same 1 draw/ped the R7
merge bought. Five in, five out: **draw-neutral by construction** (ruling 4).
### §42.3-F — WHAT I DID *NOT* DO, AND WHY
**The five rejects are still in the tree and still in `PED_NAMES`.** Deleting them was within my
editable set and I chose not to: `rigs.js` loads them by literal name, so removing the files
shortens the fleet, renumbers every `pickRig` index, changes crowd identity and moves Lane F's
goldens — during a wave where **§42.4 (the wiring) has not run**. The swap is D's, in one commit,
with the determinism gate watching. Nothing is wired; the five replacements are additive.
**Lane D — the swap map is the table in §42.3-E.** Both replacement files for the `comical` pool
are ordinary people, so that pool stops being a curveball pool; that is ruling 1 winning over
flavour and is deliberate. Keep filling slots by index, never by load order (the R2 bug).
**Lane F — your §42.6 period-law name gate has its list.** Red on any of:
`comical_luchador_01`, `comical_boy_01`, `man_elder_01`, `man_soldier_ww2_01`, `dj_phrtt_01`.
Control demonstrated: the same gate must go **green** on the five replacements above, all of which
are present in the tree now.
**A gap in the transmission sweep, for F.** `validate_manifest.py` sweeps `pipeline/_normalized/`
and `web/assets/models/` for non-manifest GLBs — but **not** `web/models/peds/` or
`web/models/clips/`, which `rigs.js` and `clipbank.js` load from by literal path. It happens to be
covered today only because staged copies exist in `_normalized/`. I swept all four surfaces by
hand this round (below); worth making the loop's dir list match the game's load paths.
### §42.3-G — GATES
```
four-surface transmission sweep pipeline/_normalized 174 · web/assets/models 169 ·
web/models/peds 37 · web/models/clips 6
= 386 GLB, transmissive materials: 0
depot copies of the 5 new peds materials=1, transmissive=0, ext=[EXT_texture_webp] (fetched back)
publish.py --files 5/5 OK glb:200 thumb:200 → _published.json 169 → 174
drift check: all 174 recorded assets live
sha1 fetch-back (depot /a/<f>) 5/5 MATCH
man_casual_04 d855c4cfd032f72f7576632b33d194d7cb0975f9 283 204 B
man_smart_01 68aae4db344864f7e90e1b7d2d4820dbec15fa50 262 168 B
person_casual_01 2c3b7ff181c84116fb4f31eca78a9b26f8711caf 454 980 B
person_youth_01 94c936c1e20f9742cd3cfb3ec913e60601e4c3f5 424 724 B
woman_smart_02 0c3ddf21e7f64ed1551535c64899becc9d4261b3 262 184 B
validate_manifest.py OK — 0 errors, 0 warnings
validate_manifest.py --depot OK — 0 errors, 0 warnings
credits.json parses, 15 entries, 0 missing required keys,
ped roster no longer "unverified"
```
**Files I touched, and only these:** `web/models/peds/{man_casual_04,man_smart_01,person_casual_01,person_youth_01,woman_smart_02}.glb`
(new) · the same five in `pipeline/_normalized/` (publish staging) · `web/assets/thumbs/` (same
five PNGs) · `pipeline/{ped_from_fbx.py,ped_sheet.py}` (new) · `pipeline/_published.json` (by
`publish.py`) · `web/assets/credits.json` (rows only — schema untouched, `credits.js` untouched) ·
`docs/shots/laneE/{r42_roster_contact.jpg,r42_roster_rejects.jpg}` (new) · `E-progress.md` ·
`docs/LANES/LANE_E_NOTES.md`. **No git command was run.** I did not touch `web/assets/manifest.json`,
`web/assets/models/**`, `web/js/**`, `tools/qa.sh`, `merge_prims.py`, `rig_join.py` or
`check_contract.py`.

View File

@ -4,6 +4,408 @@
--- ---
## Round 42 (§42.6) — PROVE THE LOOK: three paired frames, two new laws about the cast, and the byte-hash ruling reversed against its own author
*Full runbook detail (gate arms, the control transcripts, the pixel diffs): [LANE_F_NOTES §42](docs/LANES/LANE_F_NOTES.md).
Shots + their index: `docs/shots/laneF_r42/` (README in the folder).*
Every number below is from a command run today on the integrated tree — five lanes' uncommitted work
in one working copy — in fresh headless contexts on port-isolated no-store servers, seed 20261990,
1280×720. **No git command that touches the repository was run** — no status, add, commit, push,
stash or tag. The one exception, declared rather than buried: `git ls-tree -r v2.1 -- web/`, read-only
and against a tag, run once to confirm the four retired ped bodies really are in the v2.1 baseline
before re-pinning the classic covenant around them. It is the identical command `flags_check.py`
itself runs on every invocation. No tag (John's playtest session rules the epoch).
### The four asks, each with its outcome
**① [B] `?pop=0` HAS BEEN A NO-OP FOR 35 ROUNDS. Fixed, and gated.**
`index.html` (B filed it at :454) read `(parseInt(params.get('pop'), 10) || 140) >>> 0`**0 is falsy**, so every
"town frozen" measurement any lane has quoted since the flag landed booted 140 citizens. Now
`Number.isFinite(_pop) ? _pop : 140`: a parsed 0 is finite and wins, an absent or unparseable value is
NaN and falls to the default, negatives still clamp through the `>>> 0`. **Measured both ways in
`r42_repro.py` arm 0** — `?roster=v1&pop=0`**0 active**; **CONTROL**, the same boot with no `pop`
flag → **97 active** (target 140), so the arm is a gate and not a tautology. B's second ask (a gate
pinning a hash must assert `freeze().stillLive`) is ask ② and is enforced inside the gate that does it.
**② [B] THE BYTE-HASH RULING — REVERSED. See the ruling below; it is the round's other headline.**
**③ [C] `interior_mode.js`'s `WARDROBE_BASE` LITERAL IS GONE.** C moved the declaration into
`interiors/stockpack.js` and re-exported it from `interiors.js`; this module now imports and
re-exports **C's** constant instead of keeping a second copy that agrees with it today. `index.html:58`
is unchanged — a re-export, not a rename. The comment above that line says the two sides must spell
the base identically or the `(type, base)` cache key misses silently; the only way to guarantee that
is to have one side. `assets/wardrobe/` is now spelled **once** in the repo.
**④ [D/E] `dj_phrtt_01` — WHAT SHIPS: NOTHING. And the four the note lumped in with it were never
published at all.** The filing said "still on disk and still on the depot"; I checked the depot rather
than the note, read-only:
| body | `web/models/peds/` | `pipeline/_normalized/` | `_published.json` | `GET digalot.fyi/3god/a/<f>.glb` |
|---|---|---|---|---|
| **dj_phrtt_01** (unclothed) | present | present | **present** | **200 · 712,792 B — LIVE** |
| comical_luchador_01 · comical_boy_01 · man_elder_01 · man_soldier_ww2_01 | present | — | — | **404 · never published** |
So the depot job is **one file**, not five. And `web/models/` is **not** gitignored (`web/assets/models/`
is the one that is) and `tools/deploy_digalot_procity.sh` rsyncs **the whole of `web/`** — so today
those bytes would go out on a deploy even though nothing loads them. **Ruling: they do not ship.**
The exact commands are in the box below; F does not run a destructive publish and `web/models/**` is
Lane E's file set. Meanwhile *"impossible to return silently"* is now four assertions, not a promise:
* the name cannot come back into the fleet — `r42_roster.mjs` arm 1 reads `PED_NAMES`;
* it cannot come back through a module or a page — arm 4 scans every loadable surface;
* it cannot come back **on the wire** — D's `r42_cast.py` asserts 0 retired bodies fetched on default
**and** on `?classic=1`, now wired into `qa.sh` Gate 11, and `flags_check.py`'s classic arm now
asserts the same thing from the covenant side (see the red gates below);
* a **sixth** un-vetted body cannot appear at all — arm 5 requires every ped GLB on disk to be
shipped, a base clip, or explicitly recorded as withdrawn in `credits.json`. That arm exists
because a name deny-list could never have caught this one: its name is innocent, and what found it
was Lane E rendering the roster and looking.
```sh
# ── THE REMOVAL, for the integrator (Fable). Run at close, then re-run tools/qa.sh --strict. ──
# 1. the repo copies — all five. (web/models/ IS tracked; git will see the deletions.)
rm web/models/peds/{comical_boy_01,comical_luchador_01,man_elder_01,man_soldier_ww2_01,dj_phrtt_01}.glb
# 2. the publish staging copy — only dj_phrtt_01 was ever staged (pipeline/_normalized/ is gitignored)
rm pipeline/_normalized/dj_phrtt_01.glb
# 3. the depot ledger row, or validate_manifest.py --depot's drift check goes red on a file that has gone
python3 - <<'PY'
import json, pathlib
p = pathlib.Path('pipeline/_published.json')
rows = json.loads(p.read_text())
p.write_text(json.dumps([r for r in rows if 'dj_phrtt_01' not in json.dumps(r)], indent=1) + '\n')
print(f'{len(rows)} -> {len(json.loads(p.read_text()))} rows')
PY
# 4. the depot copy itself — LANE E / the depot host. There is NO delete verb: CITY_SPEC §depot
# documents POST /api/upload, GET /a/<f>, GET /api/list and nothing else, and publish.py only
# uploads. digalot.fyi/3god is a shared CDN (1,223 assets today) that sibling games read, so this
# is a deliberate act on the host, not a script.
# 5. THEN the deny-list can take the names permanently — r41_denylist.mjs is byte-scoped over web/,
# so adding them to BANNED is RED today by construction and GREEN the moment step 1 runs.
# 6. verify. Nothing in the R42 gates breaks: the roster gates read PED_NAMES, not the directory,
# and the quarantine arm's "declared withdrawn" bucket simply empties.
node tools/qa/r42_roster.mjs # expect: 32 GLBs = 24 shipped · 8 base clips · 0 withdrawn · 0 undeclared
python3 pipeline/validate_manifest.py --depot
bash tools/qa.sh --strict
```
### THE RULING: R41's byte-hash ban is REVERSED, and its stated reason is withdrawn as wrong
R41 ruled *no gate may pin a screenshot byte-hash* **"while the sign atlas paints `bold Npx Arial`
before the font resolves."* The symptom was real and I reproduced it independently. **The cause was
not the font.** B proved it in §42.5-C — the atlas canvases are byte-identical across boots,
`document.fonts.size === 0` because PROCITY ships no font faces, and `fonts.ready` resolves 4960 ms
*before* the first `fillText` on 6 of 6 boots. What moves the frame is the **wall-clock crowd**.
I verified B's claim myself and **did not reproduce 5/5 first time.** Two things had to be found, and
both are now load-bearing lines in `tools/qa/r42_repro.py`:
1. **A fixed settle is a bet on how fast the box streams today.** `crossroads_busy` came back 2-of-3
in the gate and 3-of-3 byte-identical standalone; the difference was six browser launches of load
in front of it. The **chunk streamer** is the wall-clock process nobody had named — the crowd is
only the one you can see. The harness now polls `DBG.info()` and requires the whole tuple
(draws · tris · chunks · geometries · textures) to **repeat** before capturing, and prints it, so a
mismatch arrives with its diagnosis instead of as a bare inequality.
2. **Pin the render, not the instrument.** With the crowd frozen, the clock paused, the settled tuple
identical on every boot and the camera transform equal to nine decimal places, two bookmarks still
split. The entire difference was **5362 pixels in a 7×10 box at (1252, 82)**`#pc-fps` in the
`?dbg=1` readout printing `fps 20` on one boot and `fps 21` on the next. *(And an element
screenshot does **not** exclude an overlay: the canvas is full-viewport and the HUD composites on
top of it, so `locator('#canvas').screenshot()` captured the same pixels. `#pc-dbg` is hidden for
the capture and restored after.)*
With both, on this tree:
| `?classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0`, 3 fresh contexts each | frozen, render | frozen, whole page | **unfrozen** (control) |
|---|---|---|---|
| `street_noon` | **1 of 3** | 1 of 3 | **3 of 3** |
| `shopfront_detail` | **1 of 3** | 1 of 3 | **2 of 3** |
| `crossroads_busy` | **1 of 3** | 1 of 3 | **3 of 3** |
| `market_square` | **1 of 3** | 2 of 3 ← the fps digit | 1 of 3 |
| `night_neon` | **1 of 3** | 2 of 3 ← the fps digit | 1 of 3 |
**5 of 5.** And the strongest form available: the hashes were **written in one run and re-measured
green in a second, independent run** (`--goldens docs/shots/laneF_r42/shot_goldens.json`).
> **THE RULING (R42 §42.6), replacing R41's.** A gate **may** pin a screenshot byte-hash. Five
> conditions, every one an assertion in the same run and none of them a hope:
> **(1)** the boot carries `?classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0`;
> **(2)** `DBG.freeze()` is called **after** the pose and `freeze().stillLive` comes back **all-false**
> — *this is B's ask, and a gate that pins a hash without it is forbidden*;
> **(3)** a **second** `freeze()` after the settle removes **zero** draws, proving nothing came back;
> **(4)** the harness settles until `DBG.info()` **stops changing**, never for a fixed number;
> **(5)** the hash is of the **render** with `#pc-dbg` hidden, never of the whole page.
> `?pop=0` is not a substitute for (2) and never was — it covers the fixed roster only, and until
> today it covered nothing at all.
>
> **One clause survives from R41, for a different and now-measured reason: committed cross-round
> golden VALUES stay out of `qa.sh`.** R42 alone moved 46 prop colours, five bodies, a bench yaw and a
> booth composition; a committed pixel value would have gone red on all four while telling us nothing
> about correctness. What the gate pins is the **stability** — N fresh boots, one sha256 — which is
> the property the ruling was ever about, and it cannot rot when Lane E repaints a couch. The values
> are written to `docs/shots/laneF_r42/shot_goldens.json` for anyone who wants them, and
> `r42_repro.py --goldens PATH` re-checks them in one command.
### The new gates F owns, each with its control demonstrated
**`tools/qa/r42_roster.mjs` — RULINGS 1 AND 3 BECOME REPO LAW** (node, zero deps, ~0.1 s, two gates).
It reads `PED_NAMES` out of `rigs.js` and `credits.json`, and **not** the directory listing — Lane D's
filing, and it is right: the five retired files are still on disk *by design* (deleting a name
mid-wave shortens a pool and renumbers every `pickRig` index), so a directory gate would go red on
files that no longer ship while staying green on a body that quietly reappeared in the source.
| `--gate period` (ruling 1) | result |
|---|---|
| 1. no banned body in `PED_NAMES` | **0 of 5**, 24 shipped |
| 2. the five replacements at their own slots | `man_casual_04=normal[6]` · `person_casual_01=normal[9]` · `man_smart_01=djs[4]` · `woman_smart_02=comical[0]` · `person_youth_01=comical[1]` |
| 3. pool shape | **17 / 5 / 2 = 24**, unmoved |
| 4. no banned name on a **loadable** surface (`web/**/*.{js,mjs,html}` + `assets/manifest.json`) | **69 surfaces · 0 hits** |
| 5. **quarantine** — every ped GLB on disk is shipped, a base clip, or declared withdrawn in credits | **37 GLBs = 24 shipped · 8 base clips · 5 declared withdrawn · 0 undeclared** |
| **CONTROL** | R41's own roster replayed through the same predicates trips **all 5** by pool and index, and reports all 5 replacements missing; a *deleted* name shortens `normal` to 16, which arm 3 catches; arm 4's matcher fires on a synthetic reference |
| `--gate licence` (ruling 3) | result |
|---|---|
| every shipped ped resolves to a credits row with a real licence | **24 / 24, 0 `unverified`** — 20 on `ped-bodies-base` (Mixamo RF), 4 on `ped-bodies-dj` |
| every `required:true` row carries an attribution | pass |
| **CONTROL** | restoring R41's amber `unverified` on the roster row turns **20 peds RED**; deleting the `ped-bodies-dj` row turns `dj_techno_01` RED |
*Comments are stripped before parsing, and that is not decoration: every pool in `PED_NAMES` carries a
trailing `// was <retired name>` note, so the first cut of this gate matched inside the comments and
reported a false RED on all five.*
**`tools/qa/r42_budget.py` — THE INTERIOR LAW, WITH THE INSTRUMENT PINNED** (C's §42 ask, in a file I
own rather than an edit to `r41_integration.py`). C settled the two-round 122-vs-123 argument in
§42.2b and neither lane's bookkeeping was at fault: it is the **camera aspect** — a fixed *vertical*
fov inheriting the window's aspect, so a wider window puts more room in the frustum. C asked me to set
`camera.aspect` explicitly inside the sweep rather than inherit it. Done, with two controls:
* the identical sweep at **aspect 1.25 reads 116 @ `dept/auto`** — C's published control value, to
the digit, reproduced on demand instead of relitigated;
* the pinned sweep run in a **1280×1024** window (which would have inherited 1.25) returns
**123 @ `dept/auto`, identical to the 1280×720 run.** That is the actual claim: the number is now a
property of the tree, and the gate cannot drift when someone edits an unrelated `new_page`.
**`tools/qa/r42_repro.py` — THE BYTE-HASH GATE.** Arms and controls in the ruling above.
**`tools/qa/r42_tour.py` — the proof tour** (opt-in `--tour`, warn-level: ~8 min, and pointed at its
real outdir it rewrites the round's committed shots).
**Also wired into `qa.sh` Gate 11:** D's `r42_cast.py` and `r42_shots.py` (scratch outdir, like the
R41 shot gate). **And `r41_shots.py` is PROMOTED from warn-level to strict** — D fixed the browse arm
by removing the window rather than widening it (the frame is taken *inside* the winning room the
moment a candidate beats the incumbent, and the harness never leaves), and D was right to measure my
filing first: I called it deterministically red and it was **flaky by construction**, which is a
different defect with a different fix.
### THE PROOF SHOTS — the deliverable (`docs/shots/laneF_r42/`)
Every AFTER is framed from the committed R41 original's **own camera derivation** — the two interiors
re-run R41's exact standoff recipe, and the street reads R41's published pose straight out of its
sidecar — because a pair whose halves were framed by two different algorithms is not a pair. Every
humanoid frame carries the **R10
human-sized line** and — adopting Lane D's §42.4 idea, which is a good one — a **CAST line** resolving
every figure back to `fleet.all[pedIndex].pedName` with an explicit assertion that none of the five
retired bodies is in the picture.
| pair | before → after | draws | tris |
|---|---|---:|---:|
| **`pub_furnished_ab.jpg`** | white plastic props, **two** purple `EL CHUPACABRA` luchadors, an **unclothed body at the stage edge** → brown vinyl and beer-carpet red, **13 figures wearing 11 bodies, none retired** | 71 → **72** | 222,526 → **222,250** |
| **`record_dj_booth_ab.jpg`** | a black box → Technics silver in a composed booth | 40 → **43** | 36,252 → **41,719** |
| **`street_postures_ab.jpg`** | the R41 roster, benches side-on → recast crowd, benches turned to the road with a sitter on one | 173 → **174** | 43,562 → **42,970** |
**The street AFTER stands on the BEFORE's own camera, parsed out of the committed R41 sidecar.** The
first cut re-ran R41's live-crowd cluster search: one run landed within 40 cm of R41's pose and the
next landed 30 m away behind a wall, because the search scores a crowd that is walking while it
scores. Re-rolling until it looks right is not a method. `(8.58, 16.69) → (12.08, 10.63)` is read out
of `docs/shots/laneF_r41/street_postures.txt` at run time — a number I typed would be a different
claim — so the AFTER stands exactly where the BEFORE stood and the only variable in the pair is the
tree. **+1 draw** at that pose is the ±1 a single bookmark moves run to run (D §42.4 measured the same
on an unchanged tree); the 802-station pin below reads **0 / 0**.
Plus three single frames: **`record_dj_booth_door.jpg`** — the frame R41 never took, the booth from
Lane C's own `room.spawn` at eye 1.62 m, 7.76 m out, because *"it reads as a plain black box"* is a
claim about the **entry** view and R41 answered it with a product photograph from 2.5 m;
**`street_bench.jpg`**; **`credits_panel.jpg`** (the roster row with its per-asset Mixamo evidence, the
withdrawn row, and no ped reading `unverified`).
**The bench, measured in the frame and NOT read back off B's rule.** Instances are identified by
**position only** — a `benchStops(plan)` station within 2 cm — and the angle is then taken between the
instance's own world +Z and the foot of the perpendicular onto **Lane A's** road centreline. Neither
input is the placement rule.
| bench yaw, live scene | R42 |
|---|---|
| stations matched in the streamed window | **61 of 254** |
| **CONTROL:** the same stations offset 3 m | **0 of 61** — "coincides" is a measurement |
| bench FRONT vs the direction to the road | **min = median = max = 0.0°** (R41: 90.0°) |
| bench front vs the street's own direction | **90.0°** (R41: 0.0°) |
*One instrument note, recorded rather than tuned away:* **12 of 61** benches stand nearer a **cross**
street's centreline than their own (a station 14 m from a node is close to the corner), and attributing
those to the geometrically nearest edge reads up to **174.4°** — the bench is correctly facing the
street it belongs to and the instrument is wrong. That is the same trap B named in §42.5-A. The first
cut of this gate failed on exactly that and the fix was to use the station's own `edgeId`, which is
position-derived metadata, not the yaw rule.
### The budgets, restated on the final integrated tree
**STREET (ruling 4) — the pin method of record**, `budget_walk.py --method stepwalk --stepm 2
--fixed-yaw 0 --laps 2` = 802 stations down the main spine at yaw 0, the worst bearing:
| boot | R41 pin | **R42 measured** | law |
|---|---:|---:|---|
| default, noon (seg 2) | 282 / 100,346 | **282 / 100,346** | ≤300 ✅ **byte-exact** |
| default, **NIGHT (seg 5)** | 291 / 120,093 | **291 / 122,183** | ≤300 ✅ — **margin 9, INTACT** |
| `?classic=1`, NIGHT | 269 / 90,580 | **269 / 90,580** | ≤300 ✅ **byte-exact to the R38/R40/R41 pin** |
| `?classic=1`, noon | — | **269 / 90,592** | ≤300 ✅ |
| default, NIGHT, `?clips=0` | 291 / 82,013 | **291 / 84,103** | Δ draws **0** |
**NO LANE SPENT A STREET DRAW.** Every row that has an R41 value reproduces its draw count to the
digit — 282, 291, 269, 291 — on a tree carrying five lanes' work, including a five-body recast, a
46-asset repaint, a bench and streetlight yaw fix and a recomposed DJ booth.
**On the +2,090 night tris** (present on `?clips=0` too, so not the motion library; present on B's
control tree too, so not B's yaw work — it arrives through `models/`/`assets/`): decomposed at the
tri-worst station by rendering, hiding `citizens.group` and re-rendering, **the crowd there is worth
2,5092,716 tris for one near rig plus the whole billboard layer, at 2 draws.** A one-for-one body
swap that trades a 1,564-tri cartoon child for a 2,738-tri teenager moves triangles by more than the
delta observed and cannot move draws. Ruling 4 governs draws; draws did not move.
**INTERIOR — the true numbers, on the pinned instrument:**
| 12 types × 6 archetypes, aspect **16:9** | worst | law | margin |
|---|---:|---|---:|
| GLB off | **93** @ `opshop/hall` | ≤350 | 257 |
| **GLB on** | **123** @ `dept/auto` | ≤350 | **227** |
| GLB on + `?stock=real` | **123** @ `dept/auto` | ≤350 | **227** |
| *control: the same sweep at aspect 1.25* | *116 @ `dept/auto`* | — | — |
| *control: pinned 16:9 in a 1280×1024 window* | *123 @ `dept/auto`* | — | — |
**Worst interior 123, MARGIN 227 — unchanged by R42.** The pub spent nothing at the worst room
(`dept` has no booth); the booth composition costs **+3 draws** where it lands, measured in the
`record_dj_booth` pair (40 → 43), inside C's declared "+4 at most, anywhere". Two rooms move under
`?stock=real` (`toy` 86 → 120, `book` 64 → 98) and neither reaches `dept`.
### Three red gates in F's own file — two caused by other lanes' R42 work landing correctly, one a race
**`tools/flags_check.py` went RED three times across this round's `--strict` runs.** None was a game
defect and none was gettable by loosening a threshold.
**① THE CLASSIC FETCH SURFACE WIDENED — and the covenant's own escape clause is the answer.**
The classic allow-list is *derived from the `v2.1` tag's file tree at gate time* (`git ls-tree`), and
four of D's five swaps sit in `PED_NAMES.normal`/`comical`, which `?classic=1` loads. So classic now
fetches `man_casual_04` · `person_casual_01` · `woman_smart_02` · `person_youth_01`, none of which
existed at v2.1. The gate's own message names the two exits: *"either the asset belongs in classic and
the covenant needs a ruling + a re-pin, or it must be gated out of `?classic`."*
**It is not gettable by gating.** Gating the replacements out of classic means classic keeps shipping
the luchador, and ruling 1 is a repo-wide law, not a default-boot law. So: a ruling, and a re-pin —
of **four named files**, recorded as SUBSTITUTIONS rather than admitted as a class, and **self-verifying**:
a substitution classifies OK *only if the body it replaces is itself in the v2.1 tree*, so the entry
cannot smuggle in an asset that was never baseline. (`man_smart_01` replaces `dj_phrtt_01` in the
`djs` pool, which is gated off classic — deliberately absent from the map, and red if it ever appears.)
**And a second assertion, because the allow-list alone would have passed the dangerous version of
this silently:** classic's wire must contain **0 of the 4 retired bodies**. A substitution that became
an *addition* grows the pool, renumbers every `pickRig` index and moves the seeded crowd — and an
allow-list that only asks "is this file permitted" cannot see that. Now measured: **4 substitutions
present, 0 retired fetched.**
**② `gig night: dancer NOT driven by a dance clip` — the R41 `smoke_djdance` bug, second edition.**
The dancer was on `venue_northern_soul`, 16.1 s, advancing 12.77 → 14.18, `position.y` flat: a
perfectly healthy dancer. The predicate demanded the live clip's duration be one of the **four** R13
Rokoko dance clips — but D's §41.3 gig widening *deliberately* installs Lane E's venue-library clips
into some crowd slots. **The assertion stopped being true in R41** and only stayed green while the
sampled slot happened to hold a Rokoko clip; R42's swap changed which slot got sampled and surfaced
it. Fixed by asking the **ClipBank** whether it holds the clip (`fleet.bank.has(name)`) instead of
pinning a clip identity the game no longer promises. Nothing is weakened — that the four dance clips
load and bind is asserted by the census arm above it and the four-fetch positive control below it,
and that a *clip* rather than the procedural fallback moves the body is `stalled` + `bouncing`, both
untouched. Same species as R41's filing 2: **a gate that pinned an identity instead of the behaviour.**
**③ `no-pump: trip 1 buy failed — {cash0: 191, disabled: True}` — a race, and the third instance of
the same anti-pattern in one file.** The R30b gate drives the REAL dig UI: press E, click the sleeve,
wait **350 ms**, then click whatever BUY button is there. On the third heavy `--strict` run of the day
it read the button still disabled on trip 1 **with $191 in the wallet** — so not affordability, just a
pull panel that had not finished resolving. The same gate was green on the two runs before it with
**byte-identical deltas `[-28, -16, -13, -13, -16]`**, which is the signature of a race, not a
regression. Widening the sleep moves the race; **polling for the precondition removes it**
`wait_for_function` until the control exists AND is enabled, cap 6 s, and the panel's own text is now
returned so a genuine disabled button arrives with its reason instead of a bare `disabled: True`
(which is what made this cost a whole run to diagnose). The **broke-player negative control** passes
`expect_enabled=False`, because there a disabled button is the assertion and waiting six seconds for
it to enable would be waiting for the gate to fail.
**This is exactly what F filed against D in R41 and D corrected back in §42.4** — a harness that bets
a fixed number on a live process. It was in my own file three times over. The rule, now written down
in [LANE_F_NOTES §42-A/§42-F](docs/LANES/LANE_F_NOTES.md): **never `wait_for_timeout` a live thing
into readiness; poll for the precondition and assert it.** The same fix pattern closed the
byte-hash gate's chunk-streamer race in the same round.
### QA
`bash tools/qa.sh --strict`
```
SUMMARY 24 passed · 0 failed · 0 warn · 0 skipped
● QA GREEN — every landed gate passed. (exit 0)
```
**24, up from R41's 18:** six new gates (the two node roster gates, D's `r42_cast.py` and
`r42_shots.py`, F's `r42_budget.py` and `r42_repro.py`) and **zero warn**`r41_shots.py` moved out
of the warn column into the strict one, so the suite has no non-blocking gate left in it.
`node web/js/citygen/selfcheck.js`**ALL GREEN 157,647/157,647, fingerprint `0x5f76e76`** — unmoved.
Nothing this round writes a plan field.
**Gate 11 is new this round** (`--no-r42` skips its browser half, like every other class): the two
node roster gates, D's `r42_cast.py` and `r42_shots.py`, F's `r42_budget.py` and `r42_repro.py`.
`--tour` adds the proof tour opt-in and warn-level: it costs ~8 minutes (the street arm HOLDS until
the live crowd offers its frame — that dwell is the method, not slack) and pointed at its real outdir
it rewrites the round's committed shots, so a routine gate run sends it to a scratch directory.
### Filed back
1. **→ LANE E / the integrator: `dj_phrtt_01.glb` is live on the shared depot** (200, 712,792 B) and
there is **no delete verb** in `publish.py` or in CITY_SPEC's depot contract — only
`POST /api/upload`, `GET /a/<f>`, `GET /api/list`. An append-only shared CDN cannot un-publish a
mistake, which is worth a spec line of its own. The other four rejects are **404** — they were
never published, so the note that lumped all five together was wrong in a way that would have sent
someone hunting for four files that do not exist.
2. **→ LANE E / LANE B: two `credits.json` globs claim the same three files and disagree about their
licence.** `models/peds/woman_*.glb` (`ped-bodies-base`, Mixamo RF) and `models/peds/woman_dj_*.glb`
(`ped-bodies-dj`, on-device TRELLIS) both match `woman_dj_01/02/03`. The gate resolves it by
longest-literal-prefix and reports the ambiguity, and the resolution is right today — but delete the
narrower row and those three silently inherit *"Adobe Mixamo"*, which is the wrong provenance
recorded confidently. Name the DJ five explicitly. (`credits.json` is not in F's editable set.)
3. **→ LANE E: two shipped peds are below the tri band E published.** `man_elder_01` is **1,602** tris
and `comical_boy_01` **1,564**, against §42.3's stated shipped band of 2,0673,999. Both are
retired, so it costs nothing now — but the band was quoted as a contract the replacements were
measured against, and it did not describe the roster it came from.
4. **→ R43, conditional on the removal above:** once step 1 of the removal box runs, add the five
names to `r41_denylist.mjs`'s `BANNED` list. It is byte-scoped over `web/`, so it goes green the
moment the files leave and RED forever after if one comes back — the strongest available form of
"impossible to return silently", and it is unavailable until the bytes are gone.
5. **→ LANE C, for information:** `?stock=real` raises `toy` to **120** draws and `book` to **98**
(from 86 and 64). Still 3 draws under `dept`'s 123. If the wardrobe pattern spreads to more types,
the worst room may stop being `dept` — the law is fine either way, but the *sentence* everyone
quotes would change owner.
6. **→ EVERY LANE: `?pop=0` now means zero, and `DBG.freeze()` is the real knob.** Any measurement in
any lane doc that says "town frozen, `?pop=0`" was taken with 140 citizens in it.
### Files touched (only these)
`web/index.html` (`[Lane F R42]` mark) · `web/js/world/interior_mode.js` (`[Lane F R42]` mark) ·
`tools/qa.sh` (Gate 11 + the R41 shot-gate promotion) · `tools/flags_check.py` (the three red gates
above) · `tools/qa/r42_roster.mjs` (new) ·
`tools/qa/r42_repro.py` (new) · `tools/qa/r42_budget.py` (new) · `tools/qa/r42_tour.py` (new) ·
`docs/shots/laneF_r42/**` (new) · `F-progress.md` · `docs/LANES/LANE_F_NOTES.md` · `README.md`.
**No repository-touching git command was run** (the one read-only `git ls-tree v2.1` is declared at
the top of this section). **No tag.** I did not touch `web/assets/credits.json`, `web/models/**`,
`pipeline/**`, `tools/qa/r41_integration.py`, `tools/qa/r41_denylist.mjs` or any other lane's file.
---
## Round 41 (§41.6) — THE LIBRARY LANDS: five seams wired, six gates added, and the interior margin was never 162 ## Round 41 (§41.6) — THE LIBRARY LANDS: five seams wired, six gates added, and the interior margin was never 162
*Full runbook detail (gate arms, the control transcripts, the A/B method): [LANE_F_NOTES §41](docs/LANES/LANE_F_NOTES.md). *Full runbook detail (gate arms, the control transcripts, the A/B method): [LANE_F_NOTES §41](docs/LANES/LANE_F_NOTES.md).

View File

@ -100,7 +100,7 @@ master off:
| `?classic=1` | off | **the exact v2 boot** — all four below off, byte-identical to the frozen baseline | | `?classic=1` | off | **the exact v2 boot** — all four below off, byte-identical to the frozen baseline |
| `?gigs=0` | **on** | the gig district — venues, week schedule, band/crowd/queue, cover, posters, night pass | | `?gigs=0` | **on** | the gig district — venues, week schedule, band/crowd/queue, cover, posters, night pass |
| `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader | | `?weather=0` · `?tram=0` · `?winmap=0` | **on** | seeded weather · the tram loop · the parallax interior-window shader |
| `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster | | `?roster=v1` · `?pop=N` | stream | chunk-streamed citizen density is the baseline; `?roster=v1` restores the fixed roster. **`?pop=0` means zero as of R42** — the shell read `parseInt(…) \|\| 140` and `0` is falsy, so for 35 rounds every "town frozen" measurement in this project booted 140 citizens. For a genuinely still frame use `?dbg=1` + **`DBG.freeze()`**, which also covers the streamed roster (it ignores `target` entirely) and returns `stillLive` naming every other wall-clock mover in the scene |
| `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock — and, since R41, the **op-shop wardrobe**: 52 named 1990s Australian garment layers on the clothes rails and shelves, one 246 KB atlas, one material, and it costs *fewer* draws and 11 fewer textures than the procedural canvas it replaces. Opt-in: a default boot still builds the canvas, byte-identically | | `?dig=0` · `?stock=real` | dig on | crate-riffle on record bins · real GODVERSE sleeves in the stock — and, since R41, the **op-shop wardrobe**: 52 named 1990s Australian garment layers on the clothes rails and shelves, one 246 KB atlas, one material, and it costs *fewer* draws and 11 fewer textures than the procedural canvas it replaces. Opt-in: a default boot still builds the canvas, byte-identically |
| `?clips=0` | **library on** | turns the R41 **motion library** off and nothing else — 46 Mixamo clips in 6 grouped GLBs (2 fetched at boot, 3 on first demand, 1 never). With it off the crowd is 99.3% walking on one clip; with it on, 78.1% walking, 9.5% sitting on a real bench, 7.1% leaning on a shopfront, 20 distinct clips in play. `?classic=1` is a bad control for this (it also changes the ped pool, the fog and half the shell) which is exactly why this flag exists | | `?clips=0` | **library on** | turns the R41 **motion library** off and nothing else — 46 Mixamo clips in 6 grouped GLBs (2 fetched at boot, 3 on first demand, 1 never). With it off the crowd is 99.3% walking on one clip; with it on, 78.1% walking, 9.5% sitting on a real bench, 7.1% leaning on a shopfront, 20 distinct clips in play. `?classic=1` is a bad control for this (it also changes the ped pool, the fog and half the shell) which is exactly why this flag exists |
| `?verge=0` | **off** | reverts the ground to the **pre-kerb-ruling** geometry (bitumen painted to the full corridor). John's v8 ruling made the band between kerb and shopfront **footpath** — 9 m on the synthetic town's 28 m main, **7 m** on a real 24 m main, and **3 m** on the `side`@12 edge that is 72% of the corpus. The flag stays forever as the gate's falsifiability control: the poster-clearance leg is asserted to FAIL under it, at exactly 14 posters standing on road paint. `?classic=1` forces the ruling off — the covenanted town keeps its paint | | `?verge=0` | **off** | reverts the ground to the **pre-kerb-ruling** geometry (bitumen painted to the full corridor). John's v8 ruling made the band between kerb and shopfront **footpath** — 9 m on the synthetic town's 28 m main, **7 m** on a real 24 m main, and **3 m** on the `side`@12 edge that is 72% of the corpus. The flag stays forever as the gate's falsifiability control: the poster-clearance leg is asserted to FAIL under it, at exactly 14 posters standing on road paint. `?classic=1` forces the ruling off — the covenanted town keeps its paint |
@ -165,8 +165,17 @@ economy design: [`docs/G3_ECONOMY_DESIGN.md`](docs/G3_ECONOMY_DESIGN.md).
**Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on **Release tour:** [`docs/shots/release_v3_1/contact.html`](docs/shots/release_v3_1/) — the money shots on
the default boot, plus a `?classic=1` before/after. **QA:** `tools/qa.sh --strict` (classic regression + the default boot, plus a `?classic=1` before/after. **QA:** `tools/qa.sh --strict` (classic regression +
default-boot gate + smokes + no-giants) `--soak` (the full-week soak); the seated drummer passes the default-boot gate + smokes + no-giants) `--soak` (the full-week soak) `--matrix` (the town matrix)
no-giants gate at the seated stature band `[0.9,1.5] m`. `--tour` (R42's paired before/after proof shots); the seated drummer passes the no-giants gate at the
seated stature band `[0.9,1.5] m`.
**Two laws about the cast, as of R42** (`tools/qa/r42_roster.mjs`, both controls demonstrated every
run): **PROCITY is 1990s Australia** — no fantasy, superhero, luchador, anime or medieval body ships,
gated by name against `PED_NAMES` rather than a directory listing; and **every shipped ped carries a
real `credits.json` row** — an `unverified` licence on a body is a red gate, not a note. The R42 audit
retired five bodies on those two laws, including one nobody had looked at since R36. Screenshot
byte-hashes are pinnable again (R41's ban is reversed): the condition is *nothing in frame runs on
wall-clock time*, asserted via `DBG.freeze().stillLive` — see `docs/shots/laneF_r42/README.md`.
## v7.0 — THE GAME: you're a crate-digger in 90s Australia ## v7.0 — THE GAME: you're a crate-digger in 90s Australia

View File

@ -1,5 +1,260 @@
# LANE B — NOTES (measured) # LANE B — NOTES (measured)
## Round 42 (§42.5) — THE BENCH TURNS TO FACE THE ROAD · `benchStops()` · and the font race that never existed
All numbers this section: today's tree, headless Chromium, **fresh contexts** (never a reused tab),
port-isolated **no-store** servers, 1280×720, seed 20261990. Budget cells use the **pin method of
record** (`budget_walk.py --method stepwalk --stepm 2 --fixed-yaw 0 --laps 2` = 802 stations down the
main spine at yaw 0). ROUND42 **ruling 4 is binding** — the street is 291/300 at night, nine draws —
so every claim below is an **A/B against a control tree**: a HEAD copy of `web/` with `assets/`,
`models/`, `vendor/` **and every `js/` subtree that is not Lane B's** symlinked to the live tree, so
another lane's mid-round wave lands identically on both arms. (It did: the night tri count moved
+2,090 against R41's pin on **both** arms — E's in-flight §42.1/§42.3 work — and 0 between them.)
---
### §42.5-A — THE BENCH FACED THE WRONG WAY. So did the streetlight. (Lane D's filing, verified)
D filed it as "observed while mirroring, not fixed (your file)". **Verified before changing anything**,
by reading the InstancedMesh matrices out of a live scene and matching each instance to the edge whose
**perpendicular offset equals the placement rule** (position only — so the yaw verdict is not
circular). `unmatched: 0` on both arms.
| live scene, seed 20261990 | BEFORE (control tree) | AFTER (this round) |
|---|---|---|
| bench front (local +Z) vs the direction to the road | **90.0° — min = median = max, 60/60** | **0.0° — min = median = max, 60/60** |
| bench front vs the street's own direction | 0.0°, 60/60 | 90.0°, 60/60 |
| lamp head (local +Z, 1.05 m out) gains … toward the road | **0.00 m — 0 of 89 lamps reach over it** | **+1.05 m — 89 of 89** (the whole arm) |
| lamp arm vs the street's own direction | 0.0°, 89/89 | 90.0°, 89/89 |
**The code and its own comments had disagreed for 41 rounds.** `furniture.js:203` said *"facing the
road"*, `:197` said *"arm reaches over the road"*, and both used `yaw = atan2(ux, uz)` — the yaw that
points local +Z **down the street**. D is right on both counts.
**The fix is one named convention, not two more literals.** There are exactly two useful yaws on a
street edge and the file only ever had one:
```
alongYaw = atan2( ux, uz) → local +Z runs DOWN THE STREET (what bench + lamp used)
faceYaw = atan2(-uz, ux) → local +Z runs ACROSS it, along +perp (= alongYaw π/2)
```
`perp` is `(uz, ux)`; an item at offset `side · off` on it has the road at `side · perp`, so the
road-facing yaw is `faceYaw + (side > 0 ? π : 0)`. **The `side` term was already there and already
correct** — the base was wrong, which is exactly why the defect survived reading. The shelter's
`atan2(-uz, ux)` was already `faceYaw`; it is left byte-identical (see the filing below).
**THE DEPOT BENCH IS A DIFFERENT BENCH, and it was 90° out from the primitive.** This box reaches the
CDN, so a default boot draws **9 of its 60 benches from `procity_street_bench_01.glb`** and 51 from
the primitive. Measured off the GLB's own vertex data: it is **0.437 (X) × 0.845 (Y) × 1.215 (Z)** and
**all 318 of its backrest verts (y > 0.55) lie in a slab at x ∈ [0.151, 0.219]** — its plank runs
along **Z** and its sitter faces **X**, where the house law is "+Z is the front". Every GLB bench in
the town therefore stood square to every primitive one. Fixed by rotating the **geometry** once at
load (`GLB_FIX_YAW.bench = +π/2`, maps local X → +Z), **not** the instance — so a station's yaw means
the same thing whichever geometry is drawn, `benchStops()` stays one truth, and D's sitters are right
on both. Confirmed in the scene: the bench-shaped instanced mesh goes from a 1.216 m Z-extent to a
1.216 m X-extent, same 949 verts, same 9 instances, **0 draws / 0 tris**.
**Proof shots** — same bench station (`edge 3, s = 14, side 1`), same camera pose, both arms, R10
human-sized line off the sitter's own bone bbox:
`docs/shots/laneB_r42/bench_seated_{before,after,after_dwired}.jpg`, plus the crowd-frozen geometry
pair `bench_geometry_ab.jpg` and the night `streetlight_night_ab.jpg` (130 draws on both arms).
> **→ LANE D, THIS IS A BREAKING CHANGE FOR YOUR MIRROR, AND IT IS WHY THE EXPORT EXISTS.**
> `sim.js benchStationsFor()` computes `yaw = atan2(ux, uz) + (side > 0 ? π : 0)`. That is now **90°
> wrong**. Positions are untouched (your 14/14 position arm stays green); your **yaw arm goes red**,
> and `_seatPose()` will put sitters up to 0.38 m off the plank and facing along the street — visible
> in `bench_seated_after.jpg`, which ships deliberately un-retouched. `bench_seated_after_dwired.jpg`
> is the same frame with the sitter re-seated off the **real bench transform**: that is what 42.4
> delivers. **Import `benchStops(plan)` in your wave and delete the mirror** (§42.5-B).
---
### §42.5-B — `benchStops(plan)` — the export that retires D's mirror
D asked for it "next to the existing `busShelterStops(plan)` (same shape, same reason it exists)".
Shipped, and the *rule itself* now lives in one function that **both** readers call, so the two cannot
drift: `benchStationsOnEdge(e)` is what `buildChunkFurniture` places from and what `benchStops(plan)`
publishes. A second copy inside the same file would have been the same defect at a shorter distance.
```js
import { benchStops } from '../world/furniture.js';
benchStops(plan) → [{ edgeId, kind, s, side, x, z, yaw, seatLen }]
```
Pure function of the plan — no THREE, no scene, no DOM, no side effects, safe in a worker or in Node.
Returns **every** station in the town (not just streamed ones), in `resolveEdges(plan)` order then
ascending `s`.
| field | type | meaning |
|---|---|---|
| `edgeId` | number | the plan edge id, in `resolveEdges(plan)` order |
| `kind` | `'main'\|'side'\|'lane'` | the edge's class |
| `s` | number, m | distance along the edge from its **A** node: 14, 40, 66, … step 26 |
| `side` | `+1 \| 1` | which footpath, on the **furniture** perpendicular `(uz, ux)`. D's own lane perpendicular is `(uz, ux)` = **minus** this, so D's own-footpath test stays `side === forward`, unchanged |
| `x`, `z` | number, m | the bench origin = the instance translation. **Unchanged by R42** |
| `yaw` | number, rad | the instance's Y rotation. Local **+Z is the bench FRONT** and now points **at the road**. A rig front is local Z after the R13 facing-normalise, so a sitter is still **`yaw + π`** |
| `seatLen` | number, m | **1.6** — the plank, so a caller's ±0.38 m two-up offset is not a second copy of B's geometry. (A depot-GLB bench is a shorter **1.216 m** plank; ±0.38 still fits.) |
**Gated, not asserted** — D's own arm-6 method, pointed at the export instead of the mirror:
**254 stations town-wide; 60 bench instances standing in the streamed window; 60/60 matched by a
station within 2 cm, and 60/60 with the yaw identical to < 1e-6 rad.** **CONTROL:** the same stations
offset 2 m match **0 of 65** on both counters — so "coincides" is a measurement, not a hit on a
neighbouring prop.
---
### §42.5-C — `await document.fonts.ready`: shipped, and it fixes NOTHING. Here is what does.
F's R41 ruling: no gate may pin a screenshot byte-hash while *"the sign atlas paints `bold Npx Arial`
before the font resolves"*. That diagnosis was mine originally (R41 §41.5-D) and F reproduced the
symptom independently. **The symptom is real. The cause is not the font.**
**1. The atlas is already deterministic.** Three boots per bookmark, hashing every live sign-atlas
canvas: **4149 canvases per bookmark and the identical sha1 list on all three boots**, every
bookmark. At shot time `document.fonts.status === 'loaded'`, `check('bold 74px Arial, sans-serif')` is
true, and a fixed probe string measures **859.2744140625 px to the last digit on every boot**.
**2. The API cannot even see this game's text.** `document.fonts.size === **0**` at document start and
at `ready` — the CSS Font Loading API tracks **font faces**, and PROCITY ships none (house law: all
text is canvas, `Arial` is resolved by the platform). An await on `document.fonts.ready` is an await
on an empty set.
**3. And it already resolved long before the first sign was painted.** 6 boots (3 default, 3 classic),
instrumented from an init script that hooks `CanvasRenderingContext2D.fillText`:
| | `document.fonts.ready` | first `fillText` in the process | order |
|---|---|---|---|
| default × 3 | 42.5 / 49.3 / 50.1 ms | 102.3 / 109.2 / 105.1 ms | ready first by **+55 to +60 ms** |
| classic × 3 | 38.6 / 40.5 / 50.3 ms | 93.4 / 89.5 / 101.3 ms | ready first by **+49 to +55 ms** |
**6 of 6 boots.** There is no window in which a sign could be painted early.
**IT IS SHIPPED ANYWAY, at the only site where it is free** — a module-level `await` at the top of
`buildings.js`. Every importer (`chunks.js` → the shell → every `buildChunkBuildings` call) is
downstream of it, so no atlas can be painted early, and **`buildChunkBuildings` stays synchronous**:
the streamer's 4 ms/frame budget is untouched and no chunk build becomes async. **Measured added boot
latency, 6 boots per arm, control vs treatment:** first sign paint **102.28 ms → 102.62 ms (+0.34 ms
on the mean)** against a control arm whose own spread is **93.7110.6 ms**; `DBG.ready` **3,822 →
3,802 ms**. It is inside the noise in both directions. It is kept because it costs nothing and because
it is the invariant a future webfont would silently break — not because it closed anything.
**WHAT ACTUALLY MOVES THE FRAME: THE CROWD.** The citizen sim advances on **wall-clock** time, so two
boots settled for the same 2.5 s are two different streets. Two fresh contexts, `?classic=1`,
`street_noon`, diffed pixel-for-pixel:
| | changed pixels of 921,600 | bounding box |
|---|---|---|
| as everyone has been booting it | **983** | rows **361418** — the pedestrian band, nothing else |
| with the crowd genuinely out | **0** | *none* |
**AND THE FREEZE RECIPE EVERY LANE QUOTES DOES NOT FREEZE ANYTHING.** `?roster=v1&pop=0` boots
**140 citizens** (96 active in the window, measured), because `index.html:454` reads
`(parseInt(params.get('pop'), 10) || 140)` and **`0` is falsy**. Every "town frozen" measurement in
this project since the flag was added has had a live crowd in it.
**THE KNOB: `DBG.freeze()`** (`dbg.js`, `?dbg=1` only, no game state a player can reach). Two
mechanisms because the roster has two modes — `setPopulation(0)` empties the fixed roster, and hiding
`citizens.group` covers the chunk-streamed default whose active set is per-chunk density and ignores
`target` entirely. Returns `{ draws: {before, after}, peds: {active, hidden}, stillLive: {weather,
tram, magpie, washing, gigs} }` — the draw delta is the proof the crowd left the frame, and
`stillLive` names the other wall-clock movers so a harness asserts instead of assuming.
#### The 3-boot hash table — F's method, four arms
`?classic=1`, `dbg=1&roster=v1&pop=0&weather=0&tram=0&magpie=0&washing=0`, **a fresh context per
(bookmark, boot)**, 2.5 s settle, sha256 of the PNG.
| bookmark | BEFORE (control tree) | AFTER (fonts fix in) | AFTER + `DBG.freeze()` | BEFORE + the same freeze |
|---|---|---|---|---|
| `street_noon` | **3 of 3** | **3 of 3** | **1 of 3** ✅ | **1 of 3** ✅ |
| `shopfront_detail` | **3 of 3** | **3 of 3** | **1 of 3** ✅ | **1 of 3** ✅ |
| `crossroads_busy` | **3 of 3** | **3 of 3** | **1 of 3** ✅ | **1 of 3** ✅ |
| `market_square` | 1 of 3 | 1 of 3 | 1 of 3 ✅ | 1 of 3 ✅ |
| `night_neon` | 1 of 3 | 1 of 3 | 1 of 3 ✅ | 1 of 3 ✅ |
| **stable** | **2/5** | **2/5 — the fix changed nothing** | **5/5** | **5/5** |
The fourth column is the control that matters: the **untouched HEAD tree** also goes 5/5 under the
same freeze, so the thing that made the bookmarks reproducible is the freeze, not any R42 code.
**On `market_square`:** R41 has B calling it rock-solid and F calling it 3-of-3. On this box today it
is **stable in all four arms**. Both readings were honest; the bookmark simply depends on whether a
citizen happens to be in that frame.
> **→ LANE F, THE ANSWER TO YOUR QUESTION: YES — byte-hash screenshot goldens become possible, and
> the R41 ruling can be reversed, but not for the reason it was made.** The condition is not "the
> font await landed"; it is **"the frame contains nothing that runs on wall-clock time"**. A golden
> boot is `?classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0` **+ `DBG.freeze()` before the
> shot** — `?pop=0` is not a substitute and never was. All five bookmarks pin at 1 of 3 that way, on
> both trees. Two further asks, both in your files: **(a) fix `index.html:454`** (`Number.isFinite`,
> not `||`) so `?pop=0` means zero, and **(b)** if a gate pins a hash, pin it on a `DBG.freeze()`
> boot and assert `freeze().stillLive` is all-false in the same run, or the golden will rot the first
> time someone shoots without a flag.
---
### §42.5-D — THE BUDGET: nothing was spent (ruling 4)
Pin method of record, 802 stations, control tree vs treatment tree, same box, same run:
| synthetic, pin (802 stations, yaw 0, laps 2) | CONTROL | R42 | delta | law |
|---|---|---|---|---|
| default, noon (seg 2) | 282 / 100,346 | **282 / 100,346** | **0 / 0** | ≤300 ✅ |
| default, **NIGHT** (seg 5) | 291 / 122,183 | **291 / 122,183** | **0 / 0** | ≤300 ✅ — **the nine-draw margin is intact** |
| `?classic=1`, NIGHT | 269 / 90,580 | **269 / 90,580** | **0 / 0** | ≤300 ✅ **byte-exact to the R38/R40/R41 pin** |
| `?classic=1`, noon | 269 / 90,592 | **269 / 90,592** | **0 / 0** | ≤300 ✅ |
**0 console errors on all eight runs.** Night draws are still **291/300 — Lane B spent 0 of the 9.**
Structurally it cannot: a yaw is a rotation in an instance matrix that already existed, and the GLB
normalisation is one `rotateY` on a geometry that was already merged. The bookmark frames confirm it
frame-by-frame: `night_neon` **98 draws / 14,454 tris on both arms**, and the crowd-frozen bench pose
**130 draws on both arms**.
**On the night tri number:** R41 pinned 120,093 and today both arms read **122,183**. The +2,090 is on
the **control** too — it arrived with another lane's in-flight R42 work through the symlinked
`models/`/`assets/`. Recorded so nobody attributes it to this round.
### §42.5-E — CLASSIC, and the goldens
`furniture.js` and the new `buildings.js` await contain **no classic flag test at all**, so classic and
default are identical to each other by construction (the `ground.js` idiom) and the fix lands on both.
What must not move, did not:
| classic evidence, control vs treatment | result |
|---|---|
| fetch surface (blob: object URLs excluded — they carry a random UUID per boot) | **127 URLs on both arms · added `[]` · removed `[]`** |
| HUD DOM (`innerHTML` length : body children) | **2355 : 8 on both — identical** |
| help line · `#pc-creditlink` · `PROCITY.budget` | unchanged / absent / `{draws:300, tris:200000, radius:2, auto:2, diagnostic:false}` on both |
| `night_neon` frame | **98 draws / 14,454 tris on both — delta 0 / 0** |
| pin, NIGHT | **269 / 90,580 — byte-exact** |
| page errors · console errors | **0 · 0**, on `?dbg=1`, `?classic=1`, `?noassets=1`, `?roster=v1&pop=0` |
The only difference the probe finds anywhere is `DBG.freeze` existing — and `DBG` exists only under
`?dbg=1`, which is not a player boot.
`node web/js/citygen/selfcheck.js`**ALL GREEN 157,647/157,647**, fingerprint **`0x5f76e76`
UNMOVED**. No plan golden can move: furniture is placed from the plan and writes nothing back to it.
### §42.5-F — filed, not fixed (each with its measurement)
1. **→ F: `index.html:454` `?pop=0` is a no-op** (`|| 140`). Above.
2. **→ D: retire `benchStationsFor()` this wave.** Above. Your gate's yaw arm is red until you do.
3. **→ a future round: the bus shelter has the same defect the bench had.** `syaw = atan2(-uz, ux)`
is `faceYaw` **without** the `side` term, and the shelter always stands on `+perp`, so its open
front (+Z) reads **away** from the road on the primitive — exactly the bench's bug. **Deliberately
not touched:** `busShelterStops()`'s yaw is consumed by `tram.js`, and the shipped shelter is a
depot GLB whose own axis convention is **unmeasured**. Measure the GLB, then flip both together.
4. **→ E, an asset-contract note:** the depot bench is a **1.216 m** plank where B's primitive is
**1.6 m**, and it was authored front-to-X. B normalises the rotation at its own load seam
(above), but a depot prop that disagrees with the house axis is a class of bug, not an instance —
the shelter is the next one waiting.
**Files:** `web/js/world/furniture.js` · `web/js/world/buildings.js` · `web/js/world/dbg.js` ·
`docs/shots/laneB_r42/` · these docs. **No git commands run** (lane treaty; Fable commits).
---
## Round 41 (§41.5) — THE CREDITS SURFACE, and A's D3: 81 venue lots stop wearing op-shop paint ## Round 41 (§41.5) — THE CREDITS SURFACE, and A's D3: 81 venue lots stop wearing op-shop paint
All numbers this section: today's tree, headless Chromium, **fresh contexts**, port-isolated All numbers this section: today's tree, headless Chromium, **fresh contexts**, port-isolated

View File

@ -1,9 +1,88 @@
# LANE C — cross-lane notes (PROCITY-C) # LANE C — cross-lane notes (PROCITY-C)
## Round 42 §42.2b — the booth composes. And the 122-vs-123 answer.
Full tables in `C-progress.md` § Round 42 (§42.2b). Shots: `docs/shots/laneC/r42_*.png`.
**Worst room in the game is 123 draws @ `dept`/auto (GLB on), law ≤350 — MARGIN 227, unchanged by this
section.** `dept` has no booth; §42.2b spent 0 draws at the worst room and at most +4 anywhere.
### → LANE F: 122 vs 123 is the CAMERA ASPECT, and the instrument is now pinned
You were right that it was my measurement environment, and here it is precisely. `info.render.calls`
counts what survives frustum culling; the sweep camera holds a fixed **vertical** fov (70) and takes its
aspect from the window. Wider window ⇒ more room in frame ⇒ more draws. One tree, **your own `SWEEP`
function run verbatim**, viewport the only variable:
| viewport | aspect | GLB on | GLB off |
|---|---:|---:|---:|
| 1280×1024 | 1.250 | 116 | 88 @ `stall/hall` |
| **1280×800** | 1.600 | **122** | 92 | ← my R41 harness |
| **1280×720** | 1.778 | **123** | 93 | ← `r41_integration.py` `new_page()` |
| 1920×1080 | 1.778 | 123 | 93 |
| 2560×1080 | 2.370 | 124 | 95 |
The phantom control decomposes to **+74** at both aspects (197123 and 196122), which is the
cross-check that it is one tree read two ways.
* **123 is the number.** The law must be the worst a player can produce and a player's window is wide.
My published 122 was a 16:10 reading; keep using 123 / margin 227 everywhere.
* **`PROCITY_C.drawSweep` now pins `MEASURE_ASPECT = 16/9`** and restores the window's aspect after.
`drawSweep({ glb: true, aspect: 1.25 })` reads **116** — a control arm for the sweep itself, one line.
* **Suggestion for `r41_integration.py` arm 2, your call:** set `S.camera.aspect` explicitly inside
`SWEEP` rather than inheriting the viewport, so the gate cannot drift when someone changes `new_page`.
Until then, keep `viewport 1280×720` — that IS 16:9 and it agrees with the pinned sweep exactly.
* Also checked so the aspect finding is not a guess: E's "+0 draws on all 46 repainted assets" verified
independently — glTF primitive counts in `web/assets/models/` vs `pipeline/.props_orig/r42/`,
**46/46 identical**.
### → LANE B (and F): `assets/wardrobe/` now has ONE spelling, and it is not in your file
The literal was in `web/interior_test.html` and in `web/js/world/interior_mode.js:69`. It is now declared
once in `web/js/interiors/stockpack.js` and re-exported from `interiors.js`; the test page imports it.
`interior_mode.js` is not in my editable set, so its copy is still there — **one line retires it**:
```js
// web/js/world/interior_mode.js — replace `export const WARDROBE_BASE = 'assets/wardrobe/';` with:
export { WARDROBE_BASE } from '../interiors/interiors.js';
```
### → LANE E: the deck plinth, measured again from the shipped GLB — and a workaround you can delete
Your R43 filing is right and here is a sharper number from the same file (node transforms applied):
`deck_1200_rigged` has **9 of 8,740 vertices (0.10%) within 30 mm of its own y-minimum**, 69 (0.79%)
within 43 mm, and its **1st percentile of vertex y is 0.0524** — 99% of the deck sits 52 mm above the
floor its bounding box claims. The static twin `deck_1200` reads 729/11,138 (6.55%) within 30 mm and a
1st percentile of 0.0140.
**I have compensated in placement and marked it loudly:** `kit41.BOOTH_PLINTH_FIX =
{ deck_1200_rigged: -0.043 }`, applied to the GLB only (the primitive stand-in is already correct).
When R43 re-runs `rig_join.py`, **delete the constant and the `p.dy` term in `djBooth`'s `glbParts`
map** — both carry the tag `[R42 §42.2b — WORKAROUND, REMOVE WHEN R43 FIXES THE ASSET]`.
Two more, for you rather than for me:
* Your repaint reads. Measured inside the booth's own screen rect at 2 m, six booths: near-black
fraction **57.5% → 26.7%** and distinct colours **228 → 389** across both halves of §42.2 together.
* I kept the deck's SEAT near-black (`#1c1a18`, **9.19:1** against your `#b9bdc2`) and took the bench
MASS to timber (`#5a4630`, 4.73:1). Your advice was "a lighter bench reduces the ratio" — true, but
the ratio was never the binding constraint once you fixed the asset: 4.73:1 is well over the 3:1 WCAG
threshold for a graphical object, and a 0.11 m deck is framed by the 0.05 m slab it stands on, not by
the metre of cabinet below it.
* **The record label you filed (+32 tris) would land well now** — the platter is the brightest thing in
the composition and the disc is the darkest, so a label is the one remaining unread detail.
### → LANE D: the booth moved, and its pose is in the determinism payload
`djBooth` placement changed in every room that has one (new yaw + new candidate ordering). Nothing about
the booth is drivable — ruling 5 held, no control node is animated — but if anything of yours keys off a
booth position, re-read it. `watchPoints` were re-measured after the change and did not suffer:
`pub` 361 → **363**, `band_room` **384/384**, `rsl` 560. `browsePoints` unchanged at 3.00/room.
## Round 41 §41.4 — the 110 fittings are placed. Three hand-offs, one warning. ## Round 41 §41.4 — the 110 fittings are placed. Three hand-offs, one warning.
Full tables in `C-progress.md` § Round 41 (§41.4). Shots: `docs/shots/laneC/r41_*.png`. Full tables in `C-progress.md` § Round 41 (§41.4). Shots: `docs/shots/laneC/r41_*.png`.
**Worst room in the game is now 122 draws @ `dept`/auto (GLB on), law ≤350 — margin 228.** ~~**Worst room in the game is now 122 draws @ `dept`/auto (GLB on), law ≤350 — margin 228.**~~
**[CORRECTED IN R42 §42.2b] 123 @ `dept`/auto, margin 227.** The 122 was a 16:10 reading of an
instrument whose camera aspect was unpinned; the room never changed. See § Round 42 above.
### → LANE F: one line turns the wardrobe on in the game, and one number needs restating ### → LANE F: one line turns the wardrobe on in the game, and one number needs restating

View File

@ -6,6 +6,133 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
--- ---
## ROUND 42 (§42.4) — RECAST, WIRED: five bodies swapped in place, and the bench mirror retired
Summary in `D-progress.md` §Round 42; this file keeps the parts a future lane needs to *reuse*.
Gates: `tools/qa/r42_cast.py` (new, six arms), `tools/qa/r42_shots.py` (new, two frames),
`tools/qa/r41_citizens.py`, `tools/qa/interior_scale_check.py`, `tools/qa/r41_shots.py`.
### THE LAW: a roster swap is an IN-PLACE assignment, never a delete-and-append
`pickRig` indexes `PED_NAMES` and `fleet.all` (= `normal ++ comical`), and every citizen stores its
result as `pedIndex`, which is *in the identity signature* and *is the impostor atlas's subject
index*. So the pool LENGTHS are the identity, and:
- **Replacing a name at its index** changes the wardrobe and nothing else. Measured: the identity
signature over 150 active citizens is **byte-identical** before and after the swap — 0 differing
lines, same seed, same bookmark, fresh contexts.
- **Deleting a name** shortens the pool, renumbers every slot after it, moves every seeded identity
downstream and re-indexes the atlas. That is the R2 fleet-order bug arriving through a different
door. Lane E deliberately left the rejects in `PED_NAMES` for exactly this reason so the swap
could be one atomic edit.
The same law applies to the gated `djs` tail: it is APPENDED to `normal`, so `?classic=1` still
shortens the pool from the end without renumbering a base slot.
### VERIFYING A PED CONTRACT FROM THE GLB BYTES — and the one-line trap that makes it vacuous
`tools/qa/r42_cast.py` arm 1 reads the GLB JSON chunk directly (no browser, no three, no deps) and
replays **rigs.js's own filter** over every clip the fleet can bind — the 8 base ped clips plus Lane
E's 46-clip library, 54 in all — counting bindable tracks per ped. A contract-clean ped scores
**3 456 / 3 460**, worst single clip 64, sole unbound node `mixamorig:Reference`.
**The trap:** glTF animation channels use `target.path = "rotation"`, but three's GLTFLoader renames
the track to `.quaternion`, and `_rotOnly` filters on `.quaternion`. Filter the raw glTF path and you
match **zero tracks on every ped**, the totals come out `0/0`, and the arm passes while measuring
nothing. Map `rotation→quaternion`, `translation→position` before comparing.
**Every gate needs a control that can fail, and this one was on disk already:** the retired
`comical_boy_01` scores **3 132 / 3 460** (worst clip 58) because six of its thumb bones —
`{Left,Right}HandThumb{2,3,4}` — are named without the `mixamorig:` prefix. A ped that shipped for
41 rounds had been silently dropping six tracks per clip. That is what "the node count is not the
test" means in practice.
### DRAW-NEUTRALITY IS NOT MEASURABLE AS A TOTAL. Measure the crowd as a difference.
A street total is a number about the whole town on a shared tree with four other lanes landing work
in the same hour, sampled while a wall-clock crowd walks through frame. Diffing two of them cannot
resolve a five-body swap. What can: render the frame, hide `citizens.group`, render again, and take
the difference — the crowd's own cost, in one frame, with nothing else changing.
Measured at all 8 bookmarks: **the whole crowd costs `nearRigs × 1 + 1`** — one draw per near-tier
rig (every ped GLB is one primitive and one material since the R7 merge) and exactly one for the
entire instanced billboard layer however many mid citizens it holds (37 at `street_noon`, 36 at
`patronage_door`). There is no mechanism by which a one-for-one swap can move that. Use this
decomposition instead of a total whenever the question is "did MY change cost draws".
### THE IMPOSTOR SEAM: 1.000 was never the contract, so use the roster as its own control
R2's bug was a **systematic** 1.67× — a double-tone-mapped impostor material, every ped at once. The
instrument for "does the bake still match its rigs" is therefore not an absolute tolerance. The bake
lights the ped with its own key+hemi+env and the street lights it with the sun, so the
billboard/rig luminance ratio is per-albedo: the **19 bodies this round did not touch span
0.4702.100 (median 0.972)**, and the worst-matched body in the town is `man_dj_streetwear_01` at
0.470 — shipped long before this round.
So the arm asks two questions instead of one: is there a **systematic** shift (new-five median
1.098 vs untouched median 0.972 = **1.13×**), and is any individual body **outside the spread the
town already ships** (none is). Method: stage the subject in the live shell at a fixed spot in front
of the camera, once as a near rig posed at the atlas's own bake phase (`0.35 + idx·0.017`) and once
as a single instance of the LIVE atlas; screenshot each against an EMPTY frame and count only the
pixels that differ. Crop to the billboard quad's own projected box — an unbounded full-frame
difference reported 68 000 "ped" pixels the moment a distant chunk streamed in. Absolute ratios
wobble a little run to run (ACES makes a global exposure ramp non-linear), so every subject is
measured **inside one run** and the claim is the within-run comparison.
### THE BENCH MIRROR IS GONE — and this is why mirrors are not gates
R41 shipped a copy of Lane B's bench placement rule in `sim.js` and filed the seam. B shipped
`benchStops(plan)` in §42.5 *and, in the same round, fixed the rule the mirror had copied*: benches
had been placed with `yaw = atan2(ux, uz)` — local +Z **down the street** — while both files' comments
said "facing the road". The mirror was faithful to a wrong line, and a faithful mirror of a wrong
line is still wrong.
```js
import { benchStops } from '../world/furniture.js';
// benchStationsFor(e) is now a thin adapter: wrap the sim edge back into the two-node plan shape
// resolveEdges() wants, and hand it to Lane B's own rule. No arithmetic of its own.
```
Three things worth carrying forward:
1. **The import must be STATIC.** The bench-stop check draws three seeded randoms per crossed
station, so a dynamically-imported station list draws zero randoms until it lands and "same seed
→ same crowd" starts depending on the network. `chunks.js` already imports `furniture.js` on
every boot (classic and `?noassets` included), so on the game path this costs no fetch, no module
and no behaviour. On the standalone `citizens_test.html` it does add one depot fetch
(`procity_street_bench_01.glb`, fail-soft): page smoked green, 507 active, 0 console errors.
2. **`_seatPose` needed no edit**, because it was written in the bench's own local frame (+Z front,
+X plank). The corrected yaw carries straight through it. Live sitters measured: **+0.06 m in
front of the bench origin, ±0.38 m along the plank, 0.0° between the sitter's look direction and
the direction to the road.**
3. **The station COUNT is what determinism depends on**, not the yaw — same cadence, same side rule,
same `len 6` end margin ⇒ same number of randoms drawn. Identity and posture signatures are
byte-identical across the rewire (three-way: pre-swap, post-swap, post-bench-rewire).
Gate: `r41_citizens.py` arm 6 now reads **14/14 stations matched to real instanced geometry within
2 cm and 14/14 on yaw**, control at 2 m offset matching 0/14. Station-level geometry check across 40
stations: bench front vs direction-to-road **0.0° (min = median = max)**, front vs street 90.0°.
### A SCAN-THEN-RE-ENTER SHOT HARNESS PHOTOGRAPHS THE PAST
`r41_shots.py`'s browse arm walked up to 8 occupied shops at ~3.1 s each, kept the id of the best,
exited, and re-entered it 1625 s later. Browsers are a function of LIVE patronage occupancy, so
whether they are still there on the re-entry is not something the harness controls.
**Measure the filing before you fix it.** F filed this as *deterministically* red; run verbatim in
its own harness on today's tree it came back **green** (`browsePoints: 3, browsers: 1`), and two
isolated repros agreed (one: 2 browsers still present, occupancy 2 → 2, 16.4 s scan). So the defect
is not "always red" — it is **flaky by construction**, and that is precisely the property that cannot
go `--strict`. Fixing a flake by widening a timeout or retrying hides it; the fix is to **take the
frame inside the room, the moment a candidate beats the incumbent, and never leave.** The winning
shop's occupancy at entry and at the end of the scan is printed into the sidecar on every run so the
window stays legible rather than becoming a story in a comment.
Generalise: any harness that CHOOSES a subject by scanning a live simulation and then RETURNS to it
is racing that simulation. Shoot in place, or freeze first (Lane B's `DBG.freeze()`, R42 §42.5).
---
## ROUND 41 (§41.3) — THE CITIZENS COME ALIVE: 46 shipped · 29 wired · 20 on the street ## ROUND 41 (§41.3) — THE CITIZENS COME ALIVE: 46 shipped · 29 wired · 20 on the street
Summary in `D-progress.md` §Round 41; this is the measured detail. Lane E's `web/models/clips/*.glb` Summary in `D-progress.md` §Round 41; this is the measured detail. Lane E's `web/models/clips/*.glb`

View File

@ -1,5 +1,66 @@
# LANE E — cross-lane notes # LANE E — cross-lane notes
## Round 42 — THE LOOK: 46 assets repainted at zero draw cost ⟨42.1 + 42.2a⟩
Shots: `docs/shots/laneE/r42_pub_contact_39.png` · `r42_pub_ab_top5.png` · `r42_deck_diagnosis.png`
· `r42_deck_isolated_ab.png` · `r42_deck_before_after.png`. Full tables: `E-progress.md`
§ Round 42 (§42.1 + §42.2 asset half).
### → EVERY LANE: the R41 note "37 of 39 have no texture" was misleading, and it cost a round
They all carry `COLOR_0` and three.js renders it — `GLTFLoader` sets `vertexColors` on any
primitive that has the attribute, and `batch.js` never descends into a GLB instance (the loaded
root is a `Group`, not a `Mesh`). The props were not colourless; they were **bleached**
(15 of 37 below 0.05 saturation, `beer_umbrella` at sRGB 238,238,238). If you are about to author
a texture for a PROCITY prop, check `python3 pipeline/palette.py --report <glb>` first — the colour
channel may already be there and simply be the wrong colour.
### → LANE C (§42.2 composition half, and interiors generally)
* **Nothing changed shape.** Same ids, same `footprint`/`height`, same `tris`, same `draws`, same
`control_nodes`. `manifest.json` is byte-identical after a `build_manifest.py` re-run. Every
`POSITION`/`NORMAL`/`TEXCOORD_0`/index accessor is sha1-identical to R41's file. A depot re-fetch
is the entire integration.
* **Draw delta on all 46 is +0**, checked per asset, not assumed. Colour is not a draw: three.js
issues one draw per glTF primitive, and the repaint rewrites `COLOR_0` in place without adding a
primitive. The R41 draw win (14,140 → 117) is untouched.
* **The DJ booth now reads.** Chassis silver `#b9bdc2` against your `djBooth` bench `#3a3128` is
**6.74:1** (it was **1.21:1**). If you want more separation the `#1c1a18` top-edge tone gives
9.19:1 — but a *lighter* bench reduces it, so the bench should stay dark.
* **The `venue` booth got the same treatment** (`cdj_deck`, `mixer_pmx9000`, `sl_deck_3`), so it
reads too, not just `record_hero`.
* **The pub palette assumes a dark warm floor.** The proof shots stand the props on `#5e2c26`
carpet; they were tuned for beer-stained carpet red, not pale boards.
### → LANE F (§42.6, the gates)
* The transmission gate ran over all 46 with its **control arm demonstrated**: 0 real hits, and an
injected `transmissionFactor: 0.85` on a repainted `old_couch` fires it. Repeatable in one line —
see `E-progress.md` § Gate, publish, validate.
* Publish: 46/46, sha1 fetch-back 46/46 identical (`pipeline/_r42_sha1.json`), 25.0 MB.
`validate_manifest.py` and `--depot` both **0 errors, 0 warnings**.
* Node contract re-verified **after** the repaint, from the shipped GLB:
`deck_1200_rigged` 4/4, the three rigged mixers 1/1 each. A contract gate on these will stay green.
### → R43 (filed with numbers, not fixed in a colour round)
1. **`deck_1200_rigged` has no plinth.** 9 of 8,740 vertices sit below y = 0.05 (the static twin
has 822 of 11,138) — the bottom **43 mm of a 119 mm body** is gone, and base origin is held by a
nine-vertex fragment, so the chassis floats ~43 mm above the bench. R41 join/decimate damage;
needs a `rig_join.py` re-run against ultra's source, not a repaint.
2. **A record label needs ~32 triangles.** The platter disc is a triangle fan (160 verts at r = 0,
162 at r = 0.15, nothing between), so any centre colour interpolates across the whole disc.
Colour cannot make a label; +32 tris on the existing `Platter` primitive can, at 0 draws.
### Reusable tooling (all `pipeline/`)
| tool | use it when |
|---|---|
| `palette.py` | a GLB's baked vertex colour is wrong. Five routes (`ramp`/`tint`/`huesplit`/`lut`/`factor`), in-place `COLOR_0` rewrite, per-mesh overrides. `--report` prints mean sRGB + saturation + prims + tris for any GLB. **Not idempotent** — outputs are stamped `asset.extras.procity_palette` and re-runs are refused without `--force`; pristine sources live in `pipeline/.props_orig/r42/`. |
| `isolate.py` | "does this read from where the player stands?" — perspective camera at eye height, on a bench of a stated colour. `contact_sheet.py`'s ortho-over-white cannot answer contrast questions. |
| `montage.py` | pairing a render made now against one committed in an earlier round, with wrapped captions. |
| `contact_sheet.py --pitch M` | a set that mixes a 2.68 m umbrella with a 0.2 m ashtray; the default 1 m pitch overlaps them. (`--title` was also silently invisible at any pitch > ~1.4 — fixed by solving the caption's position in screen units, not metres.) |
## Round 41 — 110 interior fittings land, and the number that mattered was DRAWS ⟨41.2⟩ ## Round 41 — 110 interior fittings land, and the number that mattered was DRAWS ⟨41.2⟩
Shots: `docs/shots/laneE/r41_3dstore_40.png` · `r41_pub_42.png` · `r41_djgear_31.png` · Shots: `docs/shots/laneE/r41_3dstore_40.png` · `r41_pub_42.png` · `r41_djgear_31.png` ·
@ -1786,3 +1847,71 @@ gate 3 after your sign-off.
motion — check `kb` before picking. motion — check `kb` before picking.
- **Standing lever for D/F:** finger channels are 49.7% of the clip payload; - **Standing lever for D/F:** finger channels are 49.7% of the clip payload;
`clips_pack.py --drop-fingers` takes the set 3.34 MB → 1.71 MB in one minute. `clips_pack.py --drop-fingers` takes the set 3.34 MB → 1.71 MB in one minute.
---
## Round 42 (§42.3) — the cast: provenance, the ped contract, and two new pipeline tools
Full write-up in `E-progress.md` §42.3. This file keeps the parts a future lane needs to *reuse*.
### The ped contract, written down (it was only ever implicit)
Measured off `web/models/peds/man_casual_01.glb`. A ped GLB that does not match this will load,
report nothing, and animate wrong:
| property | value | why it bites |
|---|---|---|
| nodes | **67** | 65 joints + 1 mesh node + `Armature` |
| skin joints | **65** | the canonical Mixamo set |
| bone names | `mixamorig<N>:<Bone>` | `rigs.js _canon` rewrites `mixamorig\d+`→`mixamorig`; the **colon and the bone base-name survive the rewrite and must match** |
| primitives / materials / images | **1 / 1 / 1** | the R7 one-draw-per-ped merge |
| texture | **1024² WebP**, `EXT_texture_webp`, no Draco | roster standard |
| tris | **2 067 4 000** | shipped band |
| facing | front = local **+Z** | `buildFigure` applies `rotation.y = π`; a Z model moon-walks |
| animations | **0** | a baked action would beat the fleet idle via `spawnRig`'s `rig.anims[0]` fallback |
**The test that actually proves it** is not the node count — it is replaying `rigs.js`'s own track
filter over every clip and counting bindable tracks. A correct ped scores **3 456/3 460 across the
54 shipped clips**, identical to `man_casual_01`. Anything less is a silent break.
### Two source pools, and what disqualifies each
- **Quaternius (CC0)** — 25 nodes, 23 joints, `Torso`/`Abdomen`/`LowerArm.L`. Not mixamorig ⇒ **zero**
tracks bind. Unusable without a retarget. Ruling 2 preferred it; measurement refused it.
- **Mixamo `Ch##` family** (ultra `~/Documents/3D=models/characters/people-normal/`, the renamed
`*_01.fbx` files) — 65 bones, contract-clean, and it is what the shipped roster already is.
- **Mixamo *named* characters** (`Aj`, `Remy`, `The Boss`, `Sporty Granny`, `claire`, `kaya`,
`Lola B Styperek`, …) — **67 to 99 bones**. Extra facial/hair/cloth bones break the 65-joint
contract in the same way Quaternius does, only less visibly. **Check the bone count before you
fall in love with a character.** This is why the roster still has no genuine pensioner: the only
convincing one in the library is `Sporty Granny`, at **99 bones**. Filed, not faked.
### Provenance is inside the assets — look there first
A Mixamo FBX carries `Original|ApplicationVendor = "Mixamo, Inc."`, `Original|ApplicationName =
"mixamo.com"` and a `working/exporter_<id>_<hash>.tmp` DocumentUrl in its own header. A Mixamo
character's **material names carry its character id** (`Ch43_Body`). `merge_ped.py` destroys the
second signal by renaming the material to `ped_merged` — so **read the character id off the source
FBX, or off the GLB before it is merged**, and record it at conversion time. R41 filed this roster
as unlicensable from the repo; it was legible from the files the whole time.
### New tools
| tool | what it is for |
|---|---|
| `pipeline/ped_from_fbx.py` | Mixamo FBX → raw ped GLB. The missing first stage (it lived in 90sDJsim). Strips the T-pose action, applies the armature scale so output matches the base roster's shape, reports bone/mixamorig counts so a contract break shows at conversion time. |
| `pipeline/ped_sheet.py` | The **cast** contact sheet. `contact_sheet.py` cannot render peds: they are centimetre-unit (bbox ~177 "m") and it derives frame height from the pre-scale bbox — it emitted a 2 100 × 50 734 px strip. This normalizes every rig to one standing height like `buildFigure()` and shoots at eye level. Use it for anything humanoid. |
Both run on ultra as well as here (`scp` the file, Blender is at the same path). Blender 5.x
renamed the EEVEE engine id back to `BLENDER_EEVEE`; `ped_sheet.py` picks whichever the build offers.
### Standing items this round created
- **`dj_phrtt_01.glb` is an unclothed body** and is in the default (non-`?classic`) crowd *and* on
the shared depot. Replacement staged; removal is Lane D's in §42.4, and the depot copy should be
withdrawn with it. The R41 deny-list could not catch it — a name deny-list cannot see an asset
whose name is innocent, and this one arrived through the R36 on-device pipeline, not the banned
directory. **The control it needed was a render.**
- **`validate_manifest.py`'s non-manifest sweep misses `web/models/peds/` and `web/models/clips/`**,
both of which the game loads from by literal path. Currently masked because staged copies sit in
`pipeline/_normalized/`. One dir-list line for whoever owns that gate next.

View File

@ -3000,6 +3000,226 @@ favour by 141 draws.
**No tag.** **No tag.**
## §42 — R42: PROVE THE LOOK (§42.6). The reusable half: how to take a byte-stable frame, how to gate a cast, and how to pin a draw sweep.
*(PROCITY-F, 2026-08-07. Wave 3; five lanes landed first. Every number is from a command run that day
on the integrated tree, fresh headless contexts off port-isolated no-store servers, seed 20261990,
1280×720. Round summary + all the tables: [`F-progress.md`](../../F-progress.md) § Round 42. **No
repository-touching git command was run** — the single exception, a read-only `git ls-tree -r v2.1`
against a tag, is declared in F-progress §42.6. **No tag.**)*
### 42-A — HOW TO TAKE A BYTE-STABLE FRAME OF THIS GAME (the recipe, and the three things that break it)
R41 banned screenshot byte-hashes and blamed a font race. The ban is **reversed** (the ruling is in
`F-progress.md`); this is the part a future round needs to *reuse*. The condition is **"nothing in the
frame runs on wall-clock time"**, and PROCITY has **four** such processes, not one:
| the mover | how you take it out | how you PROVE it is out |
|---|---|---|
| the citizen crowd | `?dbg=1` + **`DBG.freeze()`**, called **after** the pose | its draw delta (`freeze().draws.before .after`) is the crowd's own cost, and a **second** `freeze()` must remove **0** |
| weather · tram · magpie · washing · gigs | the `=0` flags | **`freeze().stillLive` all-false** — it names them, so a harness asserts instead of assuming |
| **the chunk streamer** | you cannot; you **wait it out** | poll `DBG.info()` and require `(drawCalls, tris, chunks, geometries, textures)` to **repeat** before capturing |
| **the `?dbg=1` HUD's own fps digit** | hide `#pc-dbg` for the capture | the whole-page hash differs while the render hash does not |
Three traps, each of which cost a debugging pass:
1. **`?pop=0` was a no-op for 35 rounds** (`index.html` read `parseInt(…) || 140`; `0` is falsy).
Fixed in R42, but every historical "town frozen" number in every lane doc was taken with 140
citizens in the scene. `?pop=N` also covers the **fixed** roster only — the chunk-streamed default
ignores `target` entirely, which is why `freeze()` also hides `citizens.group`.
2. **A fixed settle is a bet on machine load.** `crossroads_busy` read 2-of-3 inside a full gate run
and 3-of-3 byte-identical standalone. Same tree, same code. The streamer had simply landed a
different number of chunks by 2.5 s. **Never `wait_for_timeout` a scene into stability; poll it.**
3. **An element screenshot does NOT exclude an overlay.** The canvas is full-viewport and the HUD is a
DOM layer composited on top of it, so `locator('#canvas').screenshot()` captured the identical
pixels as a page screenshot — the diff bbox did not move by one pixel. Hide the overlay.
The last one is worth stating as a rule because it is not obvious and it wasted the most time:
**with the crowd frozen, the clock paused, the settled scene tuple identical across boots and the
camera transform equal to nine decimal places, the only thing still moving in a PROCITY frame was
`fps 20` vs `fps 21` — 5362 pixels at (1252, 82). The instrument was the last non-determinism in
the picture.**
Recipe, copyable:
```py
boot(pg, 'classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0')
pg.evaluate(f"() => window.DBG.shot('{bookmark}')") # pose + pin the clock FIRST
tup = settle(pg) # poll DBG.info() until it repeats
fz = pg.evaluate("() => window.DBG.freeze()") # crowd out; keep its draw delta
pg.wait_for_timeout(400)
fz2 = pg.evaluate("() => window.DBG.freeze()") # must remove 0 draws
assert not any(fz2['stillLive'].values())
sel = pg.evaluate("() => { const c = PROCITY.renderer.domElement; if (!c.id) c.id='pc-gl-shot';"
" const d = document.getElementById('pc-dbg'); if (d) d.style.visibility='hidden';"
" return '#'+c.id; }")
sha256(pg.locator(sel).screenshot(type='png')) # the render, not the page
```
### 42-B — GATING A CAST: read the source of truth, not the directory
`tools/qa/r42_roster.mjs`. The reusable shape, because this class of gate will come round again the
moment anyone retires an asset:
* **The shipped set is `PED_NAMES` in `rigs.js`, not `ls web/models/peds/`.** A retired body stays on
disk on purpose — `rigs.js` loads by literal name, so deleting a file shortens a pool and renumbers
every `pickRig` index, every stored `pedIndex`, and the impostor atlas's subject order (Lane D
§42.4). A directory gate is therefore red on files that no longer ship and green on a body that
quietly reappeared in the source. **Point the gate at what the loader reads.**
* **Strip comments before parsing.** Every pool in `PED_NAMES` carries a trailing `// was <retired
name>` note. The first cut matched inside those and reported a **false RED on all five**. A gate
that reads source has to read it the way the language does.
* **Four containment surfaces, not one.** (1) the roster; (2) every **loadable** surface —
`web/**/*.{js,mjs,html}` + `assets/manifest.json`, deliberately *excluding* `credits.json`, which
records the withdrawal on purpose and loads nothing; (3) the **wire** (D's `r42_cast.py` asserts 0
retired GLBs fetched on default and classic); (4) a **quarantine** arm — every ped GLB on disk must
be shipped, a base clip, or explicitly recorded as withdrawn. (4) is the one that catches the next
`dj_phrtt_01`: a name deny-list cannot catch an asset whose name is innocent, and what found that
one was somebody rendering the roster and looking at it.
* **Controls in memory, not on disk.** `r41_denylist.mjs` plants a real file because it is a whole-tree
byte scan and there is no other way to exercise it. These gates are pure functions of two documents,
so the control mutates a parsed copy: the identical predicate runs, and a crashed run cannot leave a
plant behind in a tree five lanes are working in.
* **Resolve licence coverage per ASSET, then report the ambiguity.** `models/peds/woman_*.glb` and
`models/peds/woman_dj_*.glb` both match `woman_dj_01`, and they state **different** licences. The
gate tie-breaks on longest literal prefix and prints every multi-match, because a glob that
accidentally covers a file is indistinguishable from a record until you look.
### 42-C — PINNING A DRAW SWEEP: `info.render.calls` is a function of the window
`tools/qa/r42_budget.py`, and this settles a two-round argument. C published the worst interior as
**122** and F measured **123** on two independent instruments; F wrote it off as "C's measurement
environment". Neither lane's bookkeeping was wrong. **`renderer.info.render.calls` counts what
survives frustum culling, and the sweep camera holds a fixed VERTICAL fov (70) while inheriting its
aspect from the window** — so a wider window puts more room in the frame and issues more draws:
| viewport | aspect | worst room (GLB on) | source |
|---|---:|---:|---|
| 1280×1024 | 1.250 | **116** | C's table — **reproduced by F**, to the digit, as this gate's aspect control |
| 1280×800 | 1.600 | 122 | C's table (C's R41 harness — this is where the published 122 came from) |
| **1280×720 / 1920×1080** | **1.778** | **123** | C's table — **reproduced by F**. The law's number |
| 2560×1080 | 2.370 | 124 | C's table |
**The law must be the worst a player can produce and a player's window is wide, so 123 / margin 227.**
The fix is one line and it is a general one: **set `camera.aspect` explicitly inside the measurement
and restore it after.** Then ship the two controls that make "pinned" a measurement:
* the same sweep at **1.25 reads 116**, reproducing C's published value to the digit — the argument on
demand instead of relitigated;
* the pinned sweep in a **1280×1024 window returns 123**, identical to the 1280×720 run. That is the
claim that matters: the number is a property of the **tree**, and editing an unrelated `new_page`
can no longer move a budget.
`r41_integration.py` arm 2 is correct today only because its `new_page` happens to be 16:9. Left alone
deliberately (not in F's R42 editable set) — but that coincidence is exactly what this pin removes.
### 42-D — PAIRING AGAINST A COMMITTED FRAME, and measuring a yaw without reading back the rule
* **A pair whose halves were framed by two different algorithms is not a pair.** `r42_tour.py` reuses
`r41_tour.py`'s camera derivations verbatim (the street cluster search, the `room.spawn` standoff
line) so the only variable between the halves is the tree. The proof it worked: pub **71 → 72**
draws and **222,526 → 222,250** tris, street **173 → 173**. Where a number *did* move it is the
round's own work — booth **40 → 43**, inside C's declared "+4 at most".
* **The BEFORE is whatever is in git.** `docs/shots/laneF_r41/*.jpg` cannot be re-shot, which is what
makes them evidence. Compositing is `pipeline/montage.py` (Lane E's tool, written for exactly this)
— no second copy in `tools/`.
* **Two lines on every humanoid frame.** The R10 human-sized line, and — adopting Lane D's §42.4 idea
— a **CAST line** resolving each figure to `fleet.all[pedIndex].pedName` plus an assertion that no
retired body is present. R41's pub sidecar listed **one** figure because its measure read keepers
only; reading `interiorMode.crew` as well turns that into 13. *A JPEG cannot carry a claim about who
is in it.*
* **Measuring a corrected yaw without becoming its mirror.** Identify instances by **position only**
(a `benchStops()` station within 2 cm; CONTROL: the same stations offset 3 m match **0 of 61**),
then take the angle between the instance's own world +Z and the foot of the perpendicular onto
**Lane A's** road centreline. Neither input is B's placement rule. Result **min = median = max =
0.0°** over 61 instances (R41: 90.0°).
**The trap, and it fired:** attributing a bench to the geometrically **nearest** edge reads up to
**174.4°**, because a station 14 m from a node is often closer to the cross street than to its own.
Use the station's own `edgeId` — position-derived metadata, still not the yaw rule. This is the same
trap B named in §42.5-A when they matched instances by expected perpendicular offset.
### 42-E — A SHARED APPEND-ONLY DEPOT CANNOT UN-PUBLISH A MISTAKE
`digalot.fyi/3god` holds 1,223 assets and sibling games read it. CITY_SPEC's depot contract is
`POST /api/upload` · `GET /a/<f>` · `GET /api/list`, and `publish.py` only uploads. **There is no
delete verb anywhere in this repo.** When R42's audit found an unclothed body in the default crowd,
removing it from the game was a five-line change and removing it from the CDN was not a repo
operation at all. Two lessons: *check the depot rather than the note* (four of the five retired bodies
were reported as "on the depot" and are **404** — never published), and a publish path with no
withdraw path is a licence and content risk, not a convenience gap.
`web/models/` is **not** gitignored (`web/assets/models/` is the one that is) and
`tools/deploy_digalot_procity.sh` rsyncs the whole of `web/` — so an asset that nothing loads still
**ships** until someone deletes the file.
### 42-F — THREE WAYS A GOOD GATE GOES RED WHEN NOTHING IS BROKEN
All three fired in `tools/flags_check.py` across R42's `--strict` runs. Two were caused by other
lanes' work landing *correctly*; the third was a race that had been latent for rounds. None was
gettable by loosening a number. They are three distinct species and the distinction is worth carrying:
**1. A COVENANT that is derived from history meets a law that is retroactive.**
`classic_regression`'s allow-list is computed at gate time from `git ls-tree v2.1 -- web/` — the
strongest possible authority, and the reason the R36 same-origin breach was catchable at all. R42's
period law retired four bodies that ARE in that tree, so classic's wire changed and the gate said so.
The escape clause was already written into the gate's own failure message — *"either the asset belongs
in classic and the covenant needs a ruling + a re-pin, or it must be gated out of `?classic`"* — and
the answer was the first, because ruling 1 is repo-wide and gating the replacements out of classic
means classic keeps shipping the luchador.
**Re-pin the four names, not the class, and make the exception SELF-VERIFYING.** A substitution entry
is `{new: old}` and classifies OK *only if `old` is itself in the v2.1 tree*, so the mechanism cannot
be used to admit an asset that was never baseline. And add the assertion the allow-list structurally
cannot make: **the retired names must be ABSENT from the wire.** An allow-list answers "may this file
be here"; it cannot answer "did the file it replaced actually leave", and a substitution that quietly
became an addition would grow the pool, renumber every `pickRig` index and move the seeded crowd.
Measured after the re-pin: **4 substitutions present, 0 of 4 retired fetched.**
*(Trap while writing it: `_v2_baseline()` strips three characters off `web/models/…`, so its paths keep
the leading slash. Re-deriving the key instead of comparing the constant as written cost a whole gate
run — every substitution failed its own self-verification and the gate reported four exceptions that
"do not hold". The self-verifying design worked exactly as intended; the bug was mine.)*
**2. A GATE that pinned an IDENTITY the game had stopped promising.**
`gig night: dancer NOT driven by a dance clip` demanded the live clip's duration be one of the four
R13 Rokoko dance clips. It went red on a dancer playing `venue_northern_soul` — 16.1 s, advancing
12.77 → 14.18, `position.y` flat — i.e. a perfectly healthy dancer. D's §41.3 gig widening
*deliberately* installs Lane E's venue-library clips into some crowd slots, so the assertion had been
false since R41 and only stayed green while the sampled slot happened to hold a Rokoko clip; R42's
roster swap changed which slot got sampled.
This is the **third** instance of the same species in two rounds (R41: `smoke_djdance` read
`mixer._actions[0]`; R41: the same arm measured "advance" without modulo). The rule that falls out:
> **Assert the BEHAVIOUR the feature still promises, and ask the runtime — never pin the identity of
> the thing that happens to be doing it.** `fleet.bank.has(clipName)` admits D's widened set without
> admitting a name the game never loaded. Nothing is weakened, because the identity claims live in
> arms of their own: the bind census (every dance clip binds n1 tracks, sole unbound
> `mixamorigReference`) and the positive control (the boot fetched all four dance files).
A useful smell test for any gate that samples "one of the N things": if the feature owner widened the
set of legal answers last round, your gate is now a coin toss with a good reputation.
**3. A HARNESS THAT BETS A FIXED NUMBER ON A LIVE PROCESS.** Third species, and it turned up three
times in one round in F's own files:
| where | the bet | what it cost | the fix |
|---|---|---|---|
| `r42_repro.py` | 2.5 s settle after `DBG.shot` | `crossroads_busy` 2-of-3 in a loaded gate run, 3-of-3 standalone | poll `DBG.info()` until the tuple repeats |
| `r42_tour.py` | compute the bench pose in one poll, shoot it after the loop | a bench frame with **0 figures** and a caption about a sitter | measure, pose and capture **in the same beat** |
| `flags_check.py` `_dig_buy` | 350 ms, then click whatever BUY button is there | the whole R30b no-pump gate red on trip 1 with $191 in the wallet | `wait_for_function` until the control exists AND is enabled |
**The rule: never `wait_for_timeout` a live thing into readiness; poll for the precondition and assert
it.** Widening the sleep moves the race and buys a slower gate; polling removes it. Two corollaries
learned the hard way this round: (a) **return the diagnostic with the failure**`disabled: True`
alone cost a whole run, `disabled: True` plus the panel's own text would not have; and (b) **a
negative control needs an opt-out** — the broke-player arm WANTS a disabled button, so waiting six
seconds for it to enable is waiting for the gate to fail (`expect_enabled=False`).
This is the identical defect F filed against Lane D in R41 (`r41_shots.py` re-entering a room it had
scanned 20 s earlier). D fixed it in §42.4 by removing the window rather than widening it, which is
why that gate is strict as of this round — and it was in F's own files three times over.
## §41 — R41: THE LIBRARY LANDS (§41.6). The wiring, the six gates, and the instrument that was wrong for two rounds. ## §41 — R41: THE LIBRARY LANDS (§41.6). The wiring, the six gates, and the instrument that was wrong for two rounds.
*(PROCITY-F, 2026-08-07. Five lanes landed first; F consumed. Every number here is from a command run *(PROCITY-F, 2026-08-07. Five lanes landed first; F consumed. Every number here is from a command run

View File

@ -0,0 +1,21 @@
# docs/shots/laneB_r42 — Lane B, Round 42 §42.5
Seed **20261990**, headless Chromium (SwiftShader), 1280×720, fresh context per arm, port-isolated
no-store servers. **BEFORE** frames are shot from a control tree: a HEAD copy of `web/` with
`assets/`, `models/`, `vendor/` and every non-Lane-B `js/` subtree symlinked to the live tree, so
another lane's mid-round work is identical on both arms. Measurements behind every caption are in
`docs/LANES/LANE_B_NOTES.md` §42.
| file | what it shows |
|---|---|
| `bench_seated_before.jpg` | The bench with its front **along the street** — measured 90.0° off the road on 60/60 benches. A rigged citizen seated on it, human-sized line (R10) from that citizen's own bone bounding box. |
| `bench_seated_after.jpg` | **Same bench station** (edge 3, s = 14, side 1), **same camera pose**, after the fix: the plank runs across the footpath and the front faces the road (0.0°, 60/60). The sitter is still placed by Lane D's mirrored station rule, which is now 90° stale — shipped **un-retouched on purpose**, because that is the state until D imports `benchStops()` in 42.4. |
| `bench_seated_after_dwired.jpg` | The same frame with the sitter re-seated off the **real bench transform** read out of the scene. This is what 42.4 delivers. |
| `bench_geometry_ab.jpg` | The pure-geometry pair, crowd frozen (`DBG.freeze()`), same pose: the bench end-on vs broadside. **130 draws on both arms.** |
| `streetlight_night_ab.jpg` | Night, crowd frozen, same pose: the lamp arm goes from pointing up the footpath (head gains 0.00 m toward the road, 0 of 89) to reaching over the carriageway (+1.05 m, 89 of 89). |
| `bench_shots.json` | The station, camera pose, sitter id, measured stature and seated height for each frame. |
**These are evidence, not goldens** — but unlike R41, a byte-hash golden is now *possible*: all five
`dbg.js` bookmarks hash identically across three fresh boots on a `?classic=1` boot with
`DBG.freeze()` called before the shot. See LANE_B_NOTES §42.5-C for the four-arm table and the
conditions.

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

View File

@ -0,0 +1,128 @@
{
"before": {
"station": {
"edgeId": 3,
"kind": "main",
"s": 14,
"side": -1,
"x": 11.6987594167066,
"z": 10.638458076687336,
"tx": -0.9824416082305859,
"tz": 0.18657032566112977,
"ax": 0.18657032566112977,
"az": 0.9824416082305859,
"d": 15.812582399450616
},
"shoot": {
"human": {
"lo": 0.0034336708800447938,
"hi": 1.3654786648794415,
"height": 1.836464258655906,
"foot": [
660.284559679239,
504.58513081690006,
0.967094090436199
],
"head": [
660.284559679239,
388.5086472419495,
0.967094090436199
],
"id": "0,1#5",
"seatedHeight": 1.362
},
"pose": {
"px": 7.308948287404834,
"pz": 14.322146208038625,
"yaw": -0.8726379935492392
},
"draws": 136
},
"waited_s": 5.5,
"label": "BEFORE"
},
"after": {
"station": {
"edgeId": 3,
"kind": "main",
"s": 14,
"side": -1,
"x": 11.6987594167066,
"z": 10.638458076687336,
"tx": -0.9824416082305859,
"tz": 0.18657032566112977,
"ax": 0.18657032566112977,
"az": 0.9824416082305859,
"d": 15.812582399450616
},
"shoot": {
"human": {
"lo": 0.003200653722198143,
"hi": 1.3666547535468285,
"height": 1.836464258655906,
"foot": [
660.284559679239,
504.6049890539,
0.967094090436199
],
"head": [
660.284559679239,
388.4084183673645,
0.967094090436199
],
"id": "0,1#5",
"seatedHeight": 1.363
},
"pose": {
"px": 7.308948287404834,
"pz": 14.322146208038625,
"yaw": -0.8726379935492392
},
"draws": 136
},
"waited_s": 5.4,
"label": "AFTER"
},
"after_dwired": {
"station": {
"edgeId": 3,
"kind": "main",
"s": 14,
"side": -1,
"x": 11.6987594167066,
"z": 10.638458076687336,
"tx": -0.9824416082305859,
"tz": 0.18657032566112977,
"ax": 0.18657032566112977,
"az": 0.9824416082305859,
"d": 15.812582399450616
},
"shoot": {
"human": {
"lo": 0.00033813002001510314,
"hi": 1.3694640042256039,
"height": 1.836464258655906,
"foot": [
608.3600871617285,
509.02147195275944,
0.9661389972578007
],
"head": [
608.3600871617285,
388.98044693104265,
0.9661389972578007
],
"id": "0,1#5",
"seatedHeight": 1.369
},
"pose": {
"px": 7.308948287404834,
"pz": 14.322146208038625,
"yaw": -0.8726379935492392
},
"draws": 136
},
"waited_s": 4.8,
"label": "AFTER+D"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 272 KiB

View File

@ -1,5 +1,6 @@
PROCITY R41 §41.3 — r41_browse_interior.jpg PROCITY R41 §41.3 — r41_browse_interior.jpg
seed 20261990 · The Op Shop (opshop) · 1 patronage occupants · 3 browse points seed 20261990 · Echo Toys & Games (toy) · 1 patronage occupants at entry · 3 browse points · 1 browsers
figures: [{"kind": "keeper", "shopId": 413, "type": "opshop", "h": 1.826, "clip": "idle_happy_1", "pending": null, "stature": 1.843, "footY": 0.012, "pos": [3.5, -2.26]}, {"kind": "browser", "shopId": 413, "type": "shop", "h": 1.779, "clip": "browse_hold_idle", "pending": null, "stature": 1.759, "footY": 0, "pos": [-1.23, -0.17]}] shot IN PLACE (R42 §42.4): the frame is taken inside the winning room during the scan, never on a re-entry. Occupancy of that shop at entry 1 → 1 by the end of the scan — the decay the old re-entry path walked into.
draws 69 / interior budget 350 · tris 36223 figures: [{"kind": "keeper", "shopId": 179, "type": "toy", "h": 1.662, "clip": "idle_weight_shift", "pending": null, "stature": 1.624, "footY": 0.006, "pos": [2.9, -2.13]}, {"kind": "browser", "shopId": 179, "type": "shop", "h": 1.586, "clip": "browse_open_cabinet", "pending": null, "stature": 1.546, "footY": 0.006, "pos": [1.14, -0.45]}]
HUMAN-SIZED LINE — 2 figures, stature (feet→crown) measured off the posed skeleton: keeper 1.843m (101% of its 1.826m nominal) · browser 1.759m (99% of its 1.779m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal). draws 61 / interior budget 350 · tris 35919
HUMAN-SIZED LINE — 2 figures, stature (feet→crown) measured off the posed skeleton: keeper 1.624m (98% of its 1.662m nominal) · browser 1.546m (97% of its 1.586m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 191 KiB

View File

@ -1,6 +1,6 @@
PROCITY R41 §41.3 — r41_street_postures.jpg PROCITY R41 §41.3 — r41_street_postures.jpg
seed 20261990 · synthetic · MIDDAY · camera (11.6, 58.5) yaw -1.890 seed 20261990 · synthetic · MIDDAY · camera (13.5, 68.3) yaw -0.876
8 near-tier rigs in frame · states {'walk': 7, 'bench-sit': 1} · 3 distinct clips: @walk, sit_hands_thighs, walk_shopping_bag 5 near-tier rigs in frame · states {'walk': 2, 'bench-sit': 2, 'lean': 1} · 5 distinct clips: @walk, lean_wall_shoulders, sit_hands_thighs, sit_impatient, walk_shopping_bag
draws 126 / budget 300 · tris 52858 draws 130 / budget 300 · tris 48176
clips resident: 3 groups / 24 clips / 1600384 B clips resident: 3 groups / 24 clips / 1600384 B
HUMAN-SIZED LINE — 8 figures, stature (feet→crown) measured off the posed skeleton: walk 1.595m (95% of its 1.671m nominal) · bench-sit 1.414m (77% of its 1.827m nominal) · walk 1.722m (98% of its 1.758m nominal) · walk 1.65m (96% of its 1.719m nominal) · walk 1.7m (97% of its 1.746m nominal) · walk 1.815m (98% of its 1.853m nominal) · walk 1.715m (98% of its 1.75m nominal) · walk 1.75m (100% of its 1.748m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal). HUMAN-SIZED LINE — 5 figures, stature (feet→crown) measured off the posed skeleton: walk 1.786m (99% of its 1.799m nominal) · walk 1.703m (97% of its 1.748m nominal) · bench-sit 1.264m (69% of its 1.836m nominal) · lean 1.728m (91% of its 1.895m nominal) · bench-sit 1.274m (76% of its 1.67m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).

View File

@ -0,0 +1,669 @@
{
"seed": 20261990,
"contract": {
"ref": {
"nodes": 67,
"joints": 65,
"prims": 1,
"mats": 1,
"imgs": 1,
"tris": 2132
},
"refBind": [
3456,
3460
],
"rows": [
{
"name": "man_casual_04",
"tris": 2533,
"bind": [
3456,
3460
]
},
{
"name": "person_casual_01",
"tris": 2956,
"bind": [
3456,
3460
]
},
{
"name": "man_smart_01",
"tris": 2617,
"bind": [
3456,
3460
]
},
{
"name": "woman_smart_02",
"tris": 2494,
"bind": [
3456,
3460
]
},
{
"name": "person_youth_01",
"tris": 2738,
"bind": [
3456,
3460
]
}
]
},
"roster": {
"default": {
"normal": [
"man_worker_hivis_01",
"man_suit_01",
"man_casual_01",
"man_casual_02",
"man_casual_03",
"man_grunge_01",
"man_casual_04",
"man_athletic_01",
"man_bouncer_01",
"person_casual_01",
"man_dj_streetwear_01",
"woman_casual_01",
"woman_casual_02",
"woman_business_01",
"woman_athleisure_01",
"woman_smart_01",
"woman_chef_01",
"woman_dj_01",
"woman_dj_02",
"woman_dj_03",
"dj_techno_01",
"man_smart_01"
],
"comical": [
"woman_smart_02",
"person_youth_01"
],
"all": [
"man_worker_hivis_01",
"man_suit_01",
"man_casual_01",
"man_casual_02",
"man_casual_03",
"man_grunge_01",
"man_casual_04",
"man_athletic_01",
"man_bouncer_01",
"person_casual_01",
"man_dj_streetwear_01",
"woman_casual_01",
"woman_casual_02",
"woman_business_01",
"woman_athleisure_01",
"woman_smart_01",
"woman_chef_01",
"woman_dj_01",
"woman_dj_02",
"woman_dj_03",
"dj_techno_01",
"man_smart_01",
"woman_smart_02",
"person_youth_01"
]
},
"fetched": [
"dance_drink.glb",
"dance_medium.glb",
"dance_party.glb",
"dance_sway.glb",
"dj_techno_01.glb",
"idle.glb",
"look.glb",
"man_athletic_01.glb",
"man_bouncer_01.glb",
"man_casual_01.glb",
"man_casual_02.glb",
"man_casual_03.glb",
"man_casual_04.glb",
"man_dj_streetwear_01.glb",
"man_grunge_01.glb",
"man_smart_01.glb",
"man_suit_01.glb",
"man_worker_hivis_01.glb",
"person_casual_01.glb",
"person_youth_01.glb",
"sit.glb",
"walk.glb",
"woman_athleisure_01.glb",
"woman_business_01.glb",
"woman_casual_01.glb",
"woman_casual_02.glb",
"woman_chef_01.glb",
"woman_dj_01.glb",
"woman_dj_02.glb",
"woman_dj_03.glb",
"woman_smart_01.glb",
"woman_smart_02.glb"
],
"classic": {
"roster": {
"normal": [
"man_worker_hivis_01",
"man_suit_01",
"man_casual_01",
"man_casual_02",
"man_casual_03",
"man_grunge_01",
"man_casual_04",
"man_athletic_01",
"man_bouncer_01",
"person_casual_01",
"man_dj_streetwear_01",
"woman_casual_01",
"woman_casual_02",
"woman_business_01",
"woman_athleisure_01",
"woman_smart_01",
"woman_chef_01"
],
"comical": [
"woman_smart_02",
"person_youth_01"
],
"all": [
"man_worker_hivis_01",
"man_suit_01",
"man_casual_01",
"man_casual_02",
"man_casual_03",
"man_grunge_01",
"man_casual_04",
"man_athletic_01",
"man_bouncer_01",
"person_casual_01",
"man_dj_streetwear_01",
"woman_casual_01",
"woman_casual_02",
"woman_business_01",
"woman_athleisure_01",
"woman_smart_01",
"woman_chef_01",
"woman_smart_02",
"person_youth_01"
]
},
"fetched": [
"idle.glb",
"man_athletic_01.glb",
"man_bouncer_01.glb",
"man_casual_01.glb",
"man_casual_02.glb",
"man_casual_03.glb",
"man_casual_04.glb",
"man_dj_streetwear_01.glb",
"man_grunge_01.glb",
"man_suit_01.glb",
"man_worker_hivis_01.glb",
"person_casual_01.glb",
"person_youth_01.glb",
"walk.glb",
"woman_athleisure_01.glb",
"woman_business_01.glb",
"woman_casual_01.glb",
"woman_casual_02.glb",
"woman_chef_01.glb",
"woman_smart_01.glb",
"woman_smart_02.glb"
]
}
},
"determinism": {
"n": 150,
"idsig_equal": true,
"psig_equal": true
},
"draws": {
"street_noon": {
"max": 194,
"tris": 37183,
"nearAtMax": 4,
"midAtMax": 37,
"withCrowd": 194,
"without": 189,
"near": 4,
"mid": 37,
"budget": 300
},
"crossroads_busy": {
"max": 107,
"tris": 41610,
"nearAtMax": 9,
"midAtMax": 29,
"withCrowd": 107,
"without": 97,
"near": 9,
"mid": 30,
"budget": 300
},
"night_crowd": {
"max": 127,
"tris": 46524,
"nearAtMax": 2,
"midAtMax": 10,
"withCrowd": 127,
"without": 124,
"near": 2,
"mid": 10,
"budget": 300
},
"night_neon": {
"max": 123,
"tris": 45490,
"nearAtMax": 2,
"midAtMax": 9,
"withCrowd": 121,
"without": 119,
"near": 1,
"mid": 9,
"budget": 300
},
"district_posters": {
"max": 187,
"tris": 64936,
"nearAtMax": 2,
"midAtMax": 17,
"withCrowd": 187,
"without": 184,
"near": 2,
"mid": 17,
"budget": 300
},
"venue_night": {
"max": 139,
"tris": 40224,
"nearAtMax": 3,
"midAtMax": 4,
"withCrowd": 137,
"without": 133,
"near": 3,
"mid": 4,
"budget": 300
},
"market_square": {
"max": 112,
"tris": 50560,
"nearAtMax": 0,
"midAtMax": 10,
"withCrowd": 110,
"without": 109,
"near": 0,
"mid": 10,
"budget": 300
},
"patronage_door": {
"max": 105,
"tris": 26228,
"nearAtMax": 2,
"midAtMax": 35,
"withCrowd": 105,
"without": 102,
"near": 2,
"mid": 36,
"budget": 300
}
},
"anim": [
{
"name": "man_casual_04",
"height": 1.75,
"legs": [
{
"mode": "walk",
"clip": "Walk",
"tracks": 64,
"advanced": 0.8,
"travel": 83.448,
"minStature": 1.687,
"maxStature": 1.728,
"minHeadAboveHips": 0.586
},
{
"mode": "idle",
"clip": "Idle",
"tracks": 64,
"advanced": 0.8,
"travel": 47.222,
"minStature": 1.761,
"maxStature": 1.799,
"minHeadAboveHips": 0.583
}
]
},
{
"name": "person_casual_01",
"height": 1.75,
"legs": [
{
"mode": "walk",
"clip": "Walk",
"tracks": 64,
"advanced": 0.8,
"travel": 81.858,
"minStature": 1.663,
"maxStature": 1.722,
"minHeadAboveHips": 0.58
},
{
"mode": "idle",
"clip": "Idle",
"tracks": 64,
"advanced": 0.8,
"travel": 45.422,
"minStature": 1.754,
"maxStature": 1.783,
"minHeadAboveHips": 0.578
}
]
},
{
"name": "man_smart_01",
"height": 1.75,
"legs": [
{
"mode": "walk",
"clip": "Walk",
"tracks": 64,
"advanced": 0.8,
"travel": 84.288,
"minStature": 1.667,
"maxStature": 1.722,
"minHeadAboveHips": 0.541
},
{
"mode": "idle",
"clip": "Idle",
"tracks": 64,
"advanced": 0.8,
"travel": 46.941,
"minStature": 1.748,
"maxStature": 1.786,
"minHeadAboveHips": 0.539
}
]
},
{
"name": "woman_smart_02",
"height": 1.75,
"legs": [
{
"mode": "walk",
"clip": "Walk",
"tracks": 64,
"advanced": 0.8,
"travel": 83.573,
"minStature": 1.652,
"maxStature": 1.721,
"minHeadAboveHips": 0.567
},
{
"mode": "idle",
"clip": "Idle",
"tracks": 64,
"advanced": 0.8,
"travel": 45.834,
"minStature": 1.746,
"maxStature": 1.775,
"minHeadAboveHips": 0.565
}
]
},
{
"name": "person_youth_01",
"height": 1.75,
"legs": [
{
"mode": "walk",
"clip": "Walk",
"tracks": 64,
"advanced": 0.8,
"travel": 83.853,
"minStature": 1.662,
"maxStature": 1.731,
"minHeadAboveHips": 0.534
},
{
"mode": "idle",
"clip": "Idle",
"tracks": 64,
"advanced": 0.8,
"travel": 47.012,
"minStature": 1.736,
"maxStature": 1.765,
"minHeadAboveHips": 0.529
}
]
},
{
"name": "man_casual_01",
"height": 1.75,
"legs": [
{
"mode": "walk",
"clip": "Walk",
"tracks": 64,
"advanced": 0.8,
"travel": 85.931,
"minStature": 1.678,
"maxStature": 1.727,
"minHeadAboveHips": 0.577
},
{
"mode": "idle",
"clip": "Idle",
"tracks": 64,
"advanced": 0.8,
"travel": 47.781,
"minStature": 1.764,
"maxStature": 1.805,
"minHeadAboveHips": 0.575
}
]
}
],
"impostor": {
"man_worker_hivis_01": {
"rigLum": 31.68,
"rigPx": 3183,
"billLum": 29.19,
"billPx": 1875,
"ratio": 0.9214,
"new": false
},
"man_suit_01": {
"rigLum": 17.52,
"rigPx": 2796,
"billLum": 13.87,
"billPx": 1734,
"ratio": 0.7912,
"new": false
},
"man_casual_01": {
"rigLum": 60.09,
"rigPx": 3148,
"billLum": 58.39,
"billPx": 1943,
"ratio": 0.9717,
"new": false
},
"man_casual_02": {
"rigLum": 26.72,
"rigPx": 2492,
"billLum": 27.5,
"billPx": 1550,
"ratio": 1.0294,
"new": false
},
"man_casual_03": {
"rigLum": 9.32,
"rigPx": 2999,
"billLum": 5.95,
"billPx": 1865,
"ratio": 0.6383,
"new": false
},
"man_grunge_01": {
"rigLum": 44.17,
"rigPx": 2784,
"billLum": 22.74,
"billPx": 1732,
"ratio": 0.5149,
"new": false
},
"man_casual_04": {
"rigLum": 11.27,
"rigPx": 2907,
"billLum": 10.82,
"billPx": 1804,
"ratio": 0.9599,
"new": true
},
"man_athletic_01": {
"rigLum": 74.39,
"rigPx": 3016,
"billLum": 60.37,
"billPx": 1695,
"ratio": 0.8115,
"new": false
},
"man_bouncer_01": {
"rigLum": 3.36,
"rigPx": 3300,
"billLum": 4.54,
"billPx": 2015,
"ratio": 1.3535,
"new": false
},
"person_casual_01": {
"rigLum": 28.38,
"rigPx": 2638,
"billLum": 19.11,
"billPx": 1619,
"ratio": 0.6732,
"new": true
},
"man_dj_streetwear_01": {
"rigLum": 16.42,
"rigPx": 3393,
"billLum": 7.72,
"billPx": 2061,
"ratio": 0.47,
"new": false
},
"woman_casual_01": {
"rigLum": 64.97,
"rigPx": 2938,
"billLum": 48.37,
"billPx": 1951,
"ratio": 0.7444,
"new": false
},
"woman_casual_02": {
"rigLum": 53.23,
"rigPx": 2513,
"billLum": 51.6,
"billPx": 1464,
"ratio": 0.9695,
"new": false
},
"woman_business_01": {
"rigLum": 14.41,
"rigPx": 2598,
"billLum": 13.76,
"billPx": 1608,
"ratio": 0.9547,
"new": false
},
"woman_athleisure_01": {
"rigLum": 48.64,
"rigPx": 2701,
"billLum": 62.9,
"billPx": 6974,
"ratio": 1.2933,
"new": false
},
"woman_smart_01": {
"rigLum": 54.63,
"rigPx": 10356,
"billLum": 67.15,
"billPx": 10739,
"ratio": 1.2291,
"new": false
},
"woman_chef_01": {
"rigLum": 81.36,
"rigPx": 3819,
"billLum": 95.29,
"billPx": 2984,
"ratio": 1.1713,
"new": false
},
"woman_dj_01": {
"rigLum": 37.45,
"rigPx": 3139,
"billLum": 46.22,
"billPx": 2208,
"ratio": 1.2343,
"new": false
},
"woman_dj_02": {
"rigLum": 37.86,
"rigPx": 4145,
"billLum": 48.67,
"billPx": 2582,
"ratio": 1.2855,
"new": false
},
"woman_dj_03": {
"rigLum": 33.31,
"rigPx": 3616,
"billLum": 38.29,
"billPx": 2080,
"ratio": 1.1492,
"new": false
},
"dj_techno_01": {
"rigLum": 15.15,
"rigPx": 3520,
"billLum": 31.82,
"billPx": 2265,
"ratio": 2.1003,
"new": false
},
"man_smart_01": {
"rigLum": 27.74,
"rigPx": 2725,
"billLum": 32.5,
"billPx": 1882,
"ratio": 1.1713,
"new": true
},
"woman_smart_02": {
"rigLum": 60.12,
"rigPx": 2679,
"billLum": 79.55,
"billPx": 1579,
"ratio": 1.3232,
"new": true
},
"person_youth_01": {
"rigLum": 51.49,
"rigPx": 2356,
"billLum": 56.52,
"billPx": 1380,
"ratio": 1.0977,
"new": true
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@ -0,0 +1,10 @@
PROCITY R42 §42.4 — r42_pub_crowd.jpg (THE BEFORE/AFTER)
BEFORE: docs/shots/laneF_r41/pub_furnished.jpg — same seed, same recipe, and the
purple EL CHUPACABRA luchador (comical_luchador_01) in the gig crowd.
seed 20261990 · The Exchange Hotel (pub) · segment 5 · ?gigs=1 · gig state on
camera [2.38, 3.66] → [0, -0.97] (Lane C's room.spawn line, back 5.2 m, eye 1.62 m — Lane F's §41.6 hero recipe verbatim, so the pair is comparable)
13 figures in the room, 13 in frame · 72 draws of the ≤350 interior law · 222250 tris · room 6.9x8.05 m
CAST (every figure in the room) — 13 figures wearing 11 distinct bodies: man_athletic_01×1, man_casual_02×1, man_casual_04×1, man_smart_01×1, man_suit_01×1, man_worker_hivis_01×2, woman_athleisure_01×1, woman_casual_02×1, woman_dj_02×1, woman_smart_01×1, woman_smart_02×2. PERIOD LAW: none of the five retired bodies is in this frame.
HUMAN-SIZED LINE (R10) — 13 figure(s), stature (feet→crown) measured off the posed skeleton: band[woman_athleisure_01] 1.733m (100% of its 1.727m nominal) · band[woman_smart_02] 1.687m (100% of its 1.685m nominal) · band[man_casual_04] 1.785m (101% of its 1.774m nominal) · band[man_worker_hivis_01] 1.214m (68% of its 1.796m nominal) · crowd[man_athletic_01] 1.816m (105% of its 1.734m nominal) · crowd[man_worker_hivis_01] 1.637m (98% of its 1.672m nominal) · crowd[man_casual_02] 1.618m (101% of its 1.607m nominal) · crowd[woman_smart_01] 1.58m (99% of its 1.603m nominal) · crowd[man_smart_01] 1.745m (99% of its 1.771m nominal) · crowd[man_suit_01] 1.723m (100% of its 1.718m nominal) · crowd[woman_dj_02] 1.805m (102% of its 1.774m nominal) · crowd[woman_smart_02] 1.731m (98% of its 1.766m nominal) · keeper[woman_casual_02] 1.371m (82% of its 1.676m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

View File

@ -0,0 +1,8 @@
PROCITY R42 §42.4 — r42_street_crowd.jpg (THE RECAST ROSTER, ON THE STREET)
seed 20261990 · synthetic · MIDDAY · camera (13.5, 68.3) yaw -0.876
4 near-tier rigs in frame · states {'walk': 2, 'bench-sit': 1, 'lean': 1} · 4 distinct clips: @walk, lean_wall_shoulders, sit_impatient, walk_shopping_bag
draws 129 / budget 300 · tris 47662
CAST (near rigs + mid billboards in frame) — 5 figures wearing 5 distinct bodies: man_casual_03×1, man_casual_04×1, woman_athleisure_01×1, woman_chef_01×1, woman_dj_01×1. PERIOD LAW: none of the five retired bodies is in this frame.
HUMAN-SIZED LINE (R10) — 4 figure(s), stature (feet→crown) measured off the posed skeleton: walk[woman_dj_01] 1.768m (98% of its 1.799m nominal) · walk[man_casual_04] 1.727m (99% of its 1.748m nominal) · bench-sit[woman_chef_01] 1.244m (68% of its 1.836m nominal) · lean[woman_athleisure_01] 1.729m (91% of its 1.895m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -0,0 +1,58 @@
# R42 §42.6 — the proof shots (Lane F)
R42 is a **look** round. Three faults were visible in R41's own committed proof shots and John said
fix them, so the deliverable is those frames taken again on the integrated tree, **paired against the
committed originals in `../laneF_r41/`**. Those files are in git, nobody can re-shoot them, and that
is exactly what makes them the BEFORE arm.
Regenerate: `tools/.venv/bin/python tools/qa/r42_tour.py` (seed 20261990, fresh headless context per
frame, own no-store server, 1280×720). `bash tools/qa.sh --tour` runs it to a scratch outdir;
`PROCITY_SHOT_OUT=docs/shots/laneF_r42 bash tools/qa.sh --tour` rewrites these deliberately.
## The pairs
| file | the pair, and what moved between the halves |
|---|---|
| `pub_furnished_ab.jpg` | **THE PUB.** Before: white plastic-looking props, **two** purple `EL CHUPACABRA` luchadors, and `dj_phrtt_01` — an unclothed body — at the stage edge. After: brown vinyl, beer-carpet red, laminex; 13 figures wearing 11 bodies, none of them retired. Same room (The Exchange Hotel), same seed, same segment, same camera recipe: **71 → 72 draws, 222,526 → 222,250 tris.** (Fittings read 13 → 12: Lane C recomposed the booth in §42.2b, so a spec count either side of the pair is not a like-for-like — the draws and tris are.) |
| `record_dj_booth_ab.jpg` | **THE BOOTH.** Before: a black box you cannot read as a turntable. After: a Technics-silver chassis (E §42.2a, contrast 1.21:1 → 6.74:1) in a composed booth (C §42.2b — bench 3 → 7 boxes, record bay, headphones). R41's exact camera. **40 → 43 draws, 36,252 → 41,719 tris** — C's "+4 at most, anywhere". |
| `street_postures_ab.jpg` | **THE STREET.** The AFTER stands on **R41's own published camera**, parsed out of `../laneF_r41/street_postures.txt` at run time. (Re-running R41's live-crowd cluster search landed 40 cm away on one run and 30 m away behind a wall on the next — re-rolling until it looks right is not a method.) Five bodies recast; the bench + streetlight yaw fixed. **173 → 174 draws** — the ±1 a single bookmark moves run to run; the 802-station pin is 0/0. |
## The single frames
| file | what it proves |
|---|---|
| `record_dj_booth_door.jpg` | the frame R41 never took: the booth **from where a player actually stands** — Lane C's own `room.spawn`, eye 1.62 m, 7.76 m out. The reported fault was about the entry view, so this is the frame that answers it. |
| `street_bench.jpg` | Lane B's §42.5-A, **measured in the frame and not read back off B's rule**: instances identified by position only (a `benchStops()` station within 2 cm; CONTROL — the same stations offset 3 m match **0 of 61**), then the angle taken between the instance's own world +Z and the foot of the perpendicular onto Lane A's road centreline. **61 of 254 stations in the streamed window · front-vs-road min = median = max = 0.0° · front-vs-street 90°.** In R41 those were 90° and 0°. |
| `credits_panel.jpg` | ruling 3 on screen: the roster row with its per-asset Mixamo evidence, and the row recording the bodies **withdrawn** on ruling 1. No ped row reads `unverified`. (`unverified` still appears twice in the panel — on the house-props row, a different and still-open debt, deliberately not hidden.) |
| `pub_furnished.jpg` · `record_dj_booth.jpg` · `street_postures.jpg` | the AFTER halves as full-size frames, with their sidecars. |
## The two lines on every humanoid frame
* **R10 human-sized line** — stature (feet→crown) off the **posed** skeleton for every figure, as a
ratio of that figure's own nominal height. Fails on a giant (>2.0 m) or a fold (<55% of nominal),
never on an absolute seated band.
* **CAST line** — Lane D's §42.4 idea, adopted. Every figure resolved back to the ped GLB it is
wearing (`fleet.all[pedIndex].pedName`) plus an explicit assertion that none of the five retired
bodies is in the picture. *"The luchador is gone"* is a claim about the cast, and a JPEG cannot
carry it alone. R41's pub sidecar listed **one** figure (its measure read keepers only); this one
reads the gig crew too, which is why the pub cast is 13 names.
## `shot_goldens.json` — and the ruling that made it legal
R41 ruled **no gate may pin a screenshot byte-hash**, blaming a sign-atlas font race. That diagnosis
was wrong (Lane B §42.5-C: the atlas is byte-identical across boots and `document.fonts` is an empty
set in this game). **The ruling is reversed.** The condition is *"nothing in frame runs on wall-clock
time"*, and it is assertable: `?classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0` + `DBG.freeze()`
+ `freeze().stillLive` all-false. Measured here: **5 of 5 bookmarks pin to one sha256 over 3 fresh
boots**, and these hashes, written in one run, re-measured green in a second independent run.
Two things had to be found first, and both are in `tools/qa/r42_repro.py`'s comments: settle until
`DBG.info()` **stops changing** rather than for a number someone picked (the chunk streamer is the
wall-clock process nobody had named), and **pin the render, not the instrument** — with everything
else frozen, the last thing moving was 5362 px at (1252, 82): `#pc-fps` reading `fps 20` on one boot
and `fps 21` on the next.
These are **evidence, not goldens**: `shot_goldens.json` is not wired into `qa.sh`, because five
lanes change the art every round and a committed pixel value would go red on every legitimate repaint
while telling us nothing. The gate pins the *stability*; the file is there for anyone who wants the
value.

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@ -0,0 +1,5 @@
PROCITY R42 §42.6 — credits_panel (RULING 3, on screen)
seed 20261990, ?plansrc=osm&town=katoomba — an OSM boot, so the ODbL obligation is live and the HUD link carries it: '© OpenStreetMap contributors · credits (F2)'. F2 opens Lane B's §41.5 surface.
WHAT CHANGED THIS ROUND: R41 shipped the ped roster as amber `unverified` — B could not establish the bodies' provenance from anything in this repo. Lane E §42.3-A answered it from INSIDE the assets: all 24 source FBX carry Original|ApplicationVendor = 'Mixamo, Inc.' in their own headers, and the four unmerged peds kept Mixamo's numbered character ids in their material names (Ch43_Body = the luchador), which recovers the id for the other 20. So the row now reads a real licence with per-asset evidence, `required: false` is correct, and no attribution is owed. A second row records the four bodies WITHDRAWN on ruling 1 — 'why is that gone' is a question a credits panel should be able to answer.
On screen: the roster row (True), the withdrawn row (True), the Mixamo Ch ids (True), the © OpenStreetMap contributors credit (True) and ODbL 1.0 (True). 'unverified' still appears 2x in the panel — on the PROPS row (props-house-library, 18 house props), which is a different, still-open debt and is deliberately not hidden. NO ped row is unverified: True.
Zero draws by construction (DOM); credits.json is fetched on first open, not at boot.

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

View File

@ -0,0 +1,8 @@
PROCITY R42 §42.6 — pub_furnished (AFTER)
seed 20261990, The Exchange Hotel (pub), segment 5, ?gigs=1 — the SAME recipe as docs/shots/laneF_r41/pub_furnished.jpg (Lane C's room.spawn line, back 5.2 m, eye 1.62 m), so the pair differs only by what landed this round.
WHAT CHANGED: Lane E §42.1 repainted 46 props in place via a COLOR_0 rewrite (draw delta +0, tri delta +0, every POSITION/NORMAL/index accessor sha1-identical to R41) — the white blobs are brown vinyl and beer-carpet red because they always carried vertex colour and it was bleached to 0.02 saturation, not missing. Lane E §42.3 + Lane D §42.4 swapped five bodies at their own index: the purple EL CHUPACABRA luchador and the UNCLOTHED dj_phrtt_01 that stood at the stage edge in the before are both gone from the fleet.
12 fittings · 72 draws of the ≤350 interior law (222250 tris) · camera [2.38, 3.66] → [0, -0.97].
HUMAN-SIZED LINE (R10) — 13 figure(s), stature (feet→crown) measured off the posed skeleton: band[Idle] 1.722m (100% of its 1.727m nominal) · band[Idle] 1.703m (101% of its 1.685m nominal) · band[Idle] 1.83m (103% of its 1.774m nominal) · band[Sit] 1.226m (68% of its 1.796m nominal) · crowd[venue_cheer] 1.751m (101% of its 1.734m nominal) · crowd[venue_northern_soul] 1.642m (98% of its 1.672m nominal) · crowd[Idle] 1.617m (101% of its 1.607m nominal) · crowd[venue_cheer] 1.589m (99% of its 1.603m nominal) · crowd[Animation] 1.726m (97% of its 1.771m nominal) · crowd[venue_clap_stand] 1.723m (100% of its 1.718m nominal) · crowd[venue_cheer] 1.931m (109% of its 1.774m nominal) · crowd[venue_headphones] 1.747m (99% of its 1.766m nominal) · keeper[venue_bartending] 1.433m (86% of its 1.676m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
CAST (the whole room) — 13 figure(s) wearing 11 distinct bodies: man_athletic_01x1, man_casual_02x1, man_casual_04x1, man_smart_01x1, man_suit_01x1, man_worker_hivis_01x2, woman_athleisure_01x1, woman_casual_02x1, woman_dj_02x1, woman_smart_01x1, woman_smart_02x2. PERIOD LAW (ruling 1): none of the five retired bodies (comical_luchador_01, comical_boy_01, man_elder_01, man_soldier_ww2_01, dj_phrtt_01) is in this frame.

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

View File

@ -0,0 +1,8 @@
PROCITY R42 §42.6 — record_dj_booth (AFTER, R41's own camera recipe)
seed 20261990, The Kelpie Sound Co. (record), midday. Camera derived exactly as in R41: 2.5 m off the booth group's bounding-box centre along Lane C's room.spawn line, eye 1.45 m — so this is a PAIR with docs/shots/laneF_r41/record_dj_booth.jpg.
WHAT CHANGED: Lane E §42.2a repainted the deck chassis Technics silver (#b9bdc2) — contrast against the booth bench went 1.21:1 → 6.74:1 — and Lane C §42.2b composed the booth (bench 3 → 7 boxes, a record bay, headphones, and the booth turned to face the door: entry-line alignment 49% → 92%). Near-black pixels inside the booth's own screen rect: 57.5% → 26.7%.
43 draws of the ≤350 interior law (41719 tris) · camera [1.59, -1.83] → [1.6, -4.33].
HUMAN-SIZED LINE (R10) — 1 figure(s), stature (feet→crown) measured off the posed skeleton: keeper[venue_headphones] 1.682m (98% of its 1.718m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
CAST (the record shop) — 1 figure(s) wearing 1 distinct bodies: woman_dj_03x1. PERIOD LAW (ruling 1): none of the five retired bodies (comical_luchador_01, comical_boy_01, man_elder_01, man_soldier_ww2_01, dj_phrtt_01) is in this frame.

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

View File

@ -0,0 +1,8 @@
PROCITY R42 §42.6 — record_dj_booth_door (THE PLAYER'S REAL VIEW)
seed 20261990, The Kelpie Sound Co. (record), midday. The camera stands at Lane C's own `room.spawn` — the exact spot the player is put down on when the door opens — at eye height 1.62 m, looking at the booth: 7.76 m away, spawn [1.56, 3.42] → booth [1.6, -4.33].
R41 shot this booth from 2.5 m at eye 1.45 m, which is a product photograph. The fault John reported — 'it reads as a plain black box' — is a claim about the ENTRY view, so this is the frame that answers it. C §42.2b moved the booth to face this line (alignment 49% → 92%).
58 draws of the ≤350 interior law (55081 tris).
HUMAN-SIZED LINE (R10) — 1 figure(s), stature (feet→crown) measured off the posed skeleton: keeper[venue_headphones] 1.67m (97% of its 1.718m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
CAST (the record shop) — 1 figure(s) wearing 1 distinct bodies: woman_dj_03x1. PERIOD LAW (ruling 1): none of the five retired bodies (comical_luchador_01, comical_boy_01, man_elder_01, man_soldier_ww2_01, dj_phrtt_01) is in this frame.

View File

@ -0,0 +1,21 @@
{
"schema": "procity-shot-goldens/2",
"seed": 20261990,
"viewport": [
1280,
720
],
"flags": "classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0",
"recipe": "DBG.shot(bookmark) -> settle until DBG.info() repeats (draws\u00b7tris\u00b7chunks\u00b7geometries\u00b7textures) -> DBG.freeze() -> 400 ms -> freeze() again (must remove 0 draws, stillLive all-false) -> hide #pc-dbg -> sha256 of a PNG of the RENDERER CANVAS (never the whole page: the HUD fps digit is the last non-determinism in a frozen frame)",
"settle": "adaptive, cap 12000 ms, step 250 ms",
"boots_agreeing": 3,
"regenerate": "tools/.venv/bin/python tools/qa/r42_repro.py --goldens <this file> --write-goldens",
"recheck": "the same command without --write-goldens",
"hashes": {
"street_noon": "add299d58d191aca71cca0268176a52b1e4b39d4532a5a8b51942bd2331a0888",
"shopfront_detail": "a591a235a8727dba1dd44193a69f8f3a5f155de38d2fdb69ee1a172bb67e5850",
"crossroads_busy": "e54cf85688eb660c43eac3fc234268a7bd1025b1e6598a5ec56257c2e7561320",
"market_square": "5913ebbbbd72084ed4af4af4bafc26a8631d2e0f396c20da29aead883138c181",
"night_neon": "c580587a8ea1516cfc11e56c2c990ce571ac5ed3e39c72d0a693583e04f3e167"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@ -0,0 +1,10 @@
PROCITY R42 §42.6 — street_bench (Lane B §42.5-A, measured in this frame)
seed 20261990, midday. Camera (8.409, 6.176) → (11.405, 10.696) — 5 m to the SIDE of the bench and 2.4 m out into the carriageway, deliberately NOT the cluster search's pose: that one stands on the bench's own front line, and a person facing the camera looks identical whichever way the bench under them is turned. In profile, 'the sitter faces the road' is something you can see.
THE MEASUREMENT, and it is not B's arithmetic read back: bench instances are identified by POSITION ONLY (a `benchStops(plan)` station within 2 cm — CONTROL: the same stations offset 3 m match 0 of 61), and then the angle is computed between the instance's own world +Z and the foot of the perpendicular onto Lane A's road centreline for the edge that station names. Neither input is the placement rule.
61 of 254 town-wide stations are standing in the streamed window (17 in this frame). Bench FRONT vs the direction to the road: min 0° · median 0° · max 0°. Front vs the street's own direction: median 90°. In R41 those two were 90° and 0°.
ONE INSTRUMENT NOTE, recorded rather than tuned away: 12 of 61 benches stand nearer a CROSS street's centreline than their own, because a station 14 m from a node is close to the corner. Attributing those to the geometrically nearest edge reads up to 174.4° and is WRONG — the bench correctly faces the street it belongs to. This is the same trap Lane B named in §42.5-A when they matched instances by expected perpendicular offset.
IN THIS FRAME: 1 citizen(s) on a bench, 4 figure(s) total, 127 draws of the ≤300 street law (40606 tris).
HUMAN-SIZED LINE (R10) — 4 figure(s), stature (feet→crown) measured off the posed skeleton: bench-sit[sit_impatient] 1.365m (74% of its 1.836m nominal) · walk[Walk] 1.805m (98% of its 1.846m nominal) · walk[walk_shopping_bag] 1.733m (96% of its 1.8m nominal) · walk[Walk] 1.636m (98% of its 1.671m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
CAST (the bench frame) — 4 figure(s) wearing 3 distinct bodies: man_athletic_01x1, person_youth_01x2, woman_chef_01x1. PERIOD LAW (ruling 1): none of the five retired bodies (comical_luchador_01, comical_boy_01, man_elder_01, man_soldier_ww2_01, dj_phrtt_01) is in this frame.

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

View File

@ -0,0 +1,8 @@
PROCITY R42 §42.6 — street_postures (AFTER)
seed 20261990, midday (seg 2). Camera (8.58, 16.69) → (12.08, 10.63) — **R41's own published pose**, parsed out of docs/shots/laneF_r41/street_postures.txt rather than re-derived. R41 chose it with a live-crowd cluster search; re-running that search on a live crowd lands somewhere different every time, so the AFTER stands where the BEFORE stood and the only variable in the pair is the tree.
4 rigs in frame · posture states ['walk'] · 2 distinct clips (Walk, walk_shopping_bag) · 4 distinct bodies · 174 draws of the ≤300 street law (42970 tris) · clip bank {'groups': 3, 'groupList': ['idles.glb', 'locomotion.glb', 'sitlean.glb'], 'clips': 24, 'bytes': 1600384, 'manifest': True, 'catalogue': 46}.
R41 read 173 draws / 43,562 tris at this pose. The recast is draw-neutral by construction (one primitive, one material per ped — Lane D §42.4).
HUMAN-SIZED LINE (R10) — 4 figure(s), stature (feet→crown) measured off the posed skeleton: walk[Walk] 1.779m (97% of its 1.836m nominal) · walk[walk_shopping_bag] 1.786m (99% of its 1.799m nominal) · walk[walk_shopping_bag] 1.755m (97% of its 1.8m nominal) · walk[Walk] 1.769m (96% of its 1.846m nominal). ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal).
CAST (the street) — 4 figure(s) wearing 4 distinct bodies: man_athletic_01x1, person_youth_01x1, woman_chef_01x1, woman_dj_01x1. PERIOD LAW (ruling 1): none of the five retired bodies (comical_luchador_01, comical_boy_01, man_elder_01, man_soldier_ww2_01, dj_phrtt_01) is in this frame.

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -207,3 +207,35 @@ rotations (int16-normalized is ~2× smaller at a worst measured error of **0.003
`library/manifest.json` licence label is reconciled to ultra's `MOTIONLIB/SOURCES.md`. `library/manifest.json` licence label is reconciled to ultra's `MOTIONLIB/SOURCES.md`.
**R41 ships one licence regime: Mixamo.** CMU/100STYLE/ACCAD are CC-BY — they wait on 41.5's credits **R41 ships one licence regime: Mixamo.** CMU/100STYLE/ACCAD are CC-BY — they wait on 41.5's credits
surface. surface.
### 11. Palette — `palette.py` + `isolate.py` + `montage.py` *(round-42 §42.1/§42.2a: DONE, free, on-device)*
**The R41 fittings shipped geometrically correct and visually unfinished.** They were not
untextured — they carry `COLOR_0` and three.js renders it — they were *bleached* (15 of 37 pub
props below 0.05 saturation). The fix is a palette imposed on colour that is already there, which
costs **zero draws**: the `COLOR_0` accessor is rewritten in place, so primitive count, triangle
count and every `POSITION`/`NORMAL`/index byte are untouched. Adding baseColour maps instead would
have re-imported the cost `merge_prims.py` bought back in R41 (14,140 → 117 interior draws).
```bash
python3 pipeline/palette.py --report web/assets/models/procity_fit_jukebox_01.glb # what colour IS it?
python3 pipeline/palette.py --spec pipeline/r42_palette.json \
--src pipeline/.props_orig/r42 --out pipeline/_normalized # repaint the named set
python3 pipeline/strip_transmission.py --dry-run pipeline/_normalized/procity_fit_*.glb
python3 pipeline/check_contract.py --list pipeline/_normalized/procity_fit_deck_1200_rigged_01.glb
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/thumbs.py -- web/assets/thumbs pipeline/_normalized/<set>.glb
GOD3_DEPOT=... GOD3_PW=... python3 pipeline/publish.py --files pipeline/_r42_publish_list.txt
```
⚠️ **`palette.py` is NOT idempotent.** `ramp` and `tint` normalise against the *source's own*
luminance spread, so a second pass re-stretches an already-stretched file. Every output is stamped
`asset.extras.procity_palette`; a stamped input is skipped unless `--force`. Point `--src` at the
pristine copies in `pipeline/.props_orig/r42/` (git-ignored) when re-running. Routes, per-asset
stops/rules and the reason for each are in **`pipeline/r42_palette.json`** — every asset by name.
Proof tools: `isolate.py` renders ONE staged scene from a perspective camera at eye height on a
bench of a stated colour (the only rig that can answer "does this read from where the player
stands" — it is what diagnosed the DJ booth); `montage.py` builds labelled before/after grids from
finished PNGs; `contact_sheet.py --pitch M` spaces tiles when the set mixes a 2.68 m umbrella with
a 0.2 m ashtray.

View File

@ -6,6 +6,10 @@
"dj_phrtt_01.glb", "dj_phrtt_01.glb",
"dj_techno_01.glb", "dj_techno_01.glb",
"look.glb", "look.glb",
"man_casual_04.glb",
"man_smart_01.glb",
"person_casual_01.glb",
"person_youth_01.glb",
"procity_fit_aircon_unit_01.glb", "procity_fit_aircon_unit_01.glb",
"procity_fit_arcade_cabinet_01.glb", "procity_fit_arcade_cabinet_01.glb",
"procity_fit_ash_urn_01.glb", "procity_fit_ash_urn_01.glb",
@ -167,5 +171,6 @@
"vintage-cash-register.glb", "vintage-cash-register.glb",
"woman_dj_01.glb", "woman_dj_01.glb",
"woman_dj_02.glb", "woman_dj_02.glb",
"woman_dj_03.glb" "woman_dj_03.glb",
"woman_smart_02.glb"
] ]

View File

@ -0,0 +1,46 @@
procity_fit_old_couch_01.glb
procity_fit_jukebox_01.glb
procity_fit_banquette_01.glb
procity_fit_plywood_bar_01.glb
procity_fit_bistro_table_01.glb
procity_fit_pokie_01.glb
procity_fit_bain_marie_01.glb
procity_fit_pie_warmer_01.glb
procity_fit_keg_01.glb
procity_fit_lounge_chair_01.glb
procity_fit_ash_urn_01.glb
procity_fit_ashtray_post_01.glb
procity_fit_beer_umbrella_01.glb
procity_fit_bin_round_01.glb
procity_fit_broken_basin_01.glb
procity_fit_cable_snake_01.glb
procity_fit_can_stack_01.glb
procity_fit_cdj_deck_01.glb
procity_fit_champ_bucket_01.glb
procity_fit_concrete_plinth_01.glb
procity_fit_crowd_barrier_01.glb
procity_fit_distro_box_01.glb
procity_fit_fire_ext_01.glb
procity_fit_firepit_01.glb
procity_fit_flight_case_01.glb
procity_fit_marble_sink_01.glb
procity_fit_pallet_01.glb
procity_fit_picnic_table_01.glb
procity_fit_planter_01.glb
procity_fit_topiary_01.glb
procity_fit_raffle_drum_01.glb
procity_fit_rope_post_01.glb
procity_fit_sandwich_board_01.glb
procity_fit_smoke_machine_01.glb
procity_fit_speaker_stack_01.glb
procity_fit_strobe_light_01.glb
procity_fit_sub_bass_01.glb
procity_fit_urinal_trough_01.glb
procity_fit_wheelie_bin_01.glb
procity_fit_deck_1200_rigged_01.glb
procity_fit_deck_1200_01.glb
procity_fit_mixer_ttm54i_rigged_01.glb
procity_fit_mixer_pmc05pro_rigged_01.glb
procity_fit_mixer_shdj1200_rigged_01.glb
procity_fit_mixer_pmx9000_01.glb
procity_fit_sl_deck_3_01.glb

278
pipeline/_r42_sha1.json Normal file
View File

@ -0,0 +1,278 @@
{
"procity_fit_old_couch_01.glb": {
"local": "c8f367e9f18e3a0c693b670fe650939eaec009fc",
"depot": "c8f367e9f18e3a0c693b670fe650939eaec009fc",
"bytes": 571024,
"match": true
},
"procity_fit_jukebox_01.glb": {
"local": "c5b1ee8534fca949745b4259a90163fa0547c195",
"depot": "c5b1ee8534fca949745b4259a90163fa0547c195",
"bytes": 571132,
"match": true
},
"procity_fit_banquette_01.glb": {
"local": "6245072ef115170f3305c061f80f74a1e76cbad6",
"depot": "6245072ef115170f3305c061f80f74a1e76cbad6",
"bytes": 571128,
"match": true
},
"procity_fit_plywood_bar_01.glb": {
"local": "94f0532052d699ba93e82ab1d080999eeba18b24",
"depot": "94f0532052d699ba93e82ab1d080999eeba18b24",
"bytes": 571316,
"match": true
},
"procity_fit_bistro_table_01.glb": {
"local": "7b5867a91c47ba9503758a35989c563a32dce1d8",
"depot": "7b5867a91c47ba9503758a35989c563a32dce1d8",
"bytes": 571128,
"match": true
},
"procity_fit_pokie_01.glb": {
"local": "0399f1f32b52c7b382e2343634d1036cfeddef27",
"depot": "0399f1f32b52c7b382e2343634d1036cfeddef27",
"bytes": 571060,
"match": true
},
"procity_fit_bain_marie_01.glb": {
"local": "4910464006180a88e656d8a0bdd4ab64be1b5bc6",
"depot": "4910464006180a88e656d8a0bdd4ab64be1b5bc6",
"bytes": 566308,
"match": true
},
"procity_fit_pie_warmer_01.glb": {
"local": "e51f066c465a3ae8c1b26ac117bf3a152718f431",
"depot": "e51f066c465a3ae8c1b26ac117bf3a152718f431",
"bytes": 570268,
"match": true
},
"procity_fit_keg_01.glb": {
"local": "52e36abf727ad0eb021de809b97a773293b8b530",
"depot": "52e36abf727ad0eb021de809b97a773293b8b530",
"bytes": 701964,
"match": true
},
"procity_fit_lounge_chair_01.glb": {
"local": "e507bbc918ee55810d67c5398d50e2998d9d777f",
"depot": "e507bbc918ee55810d67c5398d50e2998d9d777f",
"bytes": 571100,
"match": true
},
"procity_fit_ash_urn_01.glb": {
"local": "84659e1aa8b6dbae98c92ad0a4373c6f6b2a80f3",
"depot": "84659e1aa8b6dbae98c92ad0a4373c6f6b2a80f3",
"bytes": 571012,
"match": true
},
"procity_fit_ashtray_post_01.glb": {
"local": "f3bbbd1077c140733b1f648e129ba55c80d7effb",
"depot": "f3bbbd1077c140733b1f648e129ba55c80d7effb",
"bytes": 571028,
"match": true
},
"procity_fit_beer_umbrella_01.glb": {
"local": "bf3ef5083f1344cde0c06ebd0b4f14f2117f460a",
"depot": "bf3ef5083f1344cde0c06ebd0b4f14f2117f460a",
"bytes": 570456,
"match": true
},
"procity_fit_bin_round_01.glb": {
"local": "cfb167e748c76acd4cb916179b05dcc6b6dc27e9",
"depot": "cfb167e748c76acd4cb916179b05dcc6b6dc27e9",
"bytes": 570828,
"match": true
},
"procity_fit_broken_basin_01.glb": {
"local": "c44933d15df2bd8965fb19cf9e6ac26c792d28ad",
"depot": "c44933d15df2bd8965fb19cf9e6ac26c792d28ad",
"bytes": 571316,
"match": true
},
"procity_fit_cable_snake_01.glb": {
"local": "d31ca44b4828d3c04332933a2c9144eca73655ec",
"depot": "d31ca44b4828d3c04332933a2c9144eca73655ec",
"bytes": 571320,
"match": true
},
"procity_fit_can_stack_01.glb": {
"local": "bb748e5bb699b1574d5be325765bbeb1bf5eda9c",
"depot": "bb748e5bb699b1574d5be325765bbeb1bf5eda9c",
"bytes": 571172,
"match": true
},
"procity_fit_cdj_deck_01.glb": {
"local": "99237a7d08a5882e1044ea5230f79701c069e88d",
"depot": "99237a7d08a5882e1044ea5230f79701c069e88d",
"bytes": 570656,
"match": true
},
"procity_fit_champ_bucket_01.glb": {
"local": "7e092a9d3123901eff9c35cf7224fb4e146cedc7",
"depot": "7e092a9d3123901eff9c35cf7224fb4e146cedc7",
"bytes": 571136,
"match": true
},
"procity_fit_concrete_plinth_01.glb": {
"local": "a54fea56fddaaf844deb8460c72f5902e091ffd8",
"depot": "a54fea56fddaaf844deb8460c72f5902e091ffd8",
"bytes": 570668,
"match": true
},
"procity_fit_crowd_barrier_01.glb": {
"local": "206de5968eced2b80cfab639e1f59db0d1728527",
"depot": "206de5968eced2b80cfab639e1f59db0d1728527",
"bytes": 571024,
"match": true
},
"procity_fit_distro_box_01.glb": {
"local": "26322ad27ec4dac52c155e4dd1b037112693d1c0",
"depot": "26322ad27ec4dac52c155e4dd1b037112693d1c0",
"bytes": 571028,
"match": true
},
"procity_fit_fire_ext_01.glb": {
"local": "dcdc1e3c22ec46db4b0eb65e24a57319e63e442d",
"depot": "dcdc1e3c22ec46db4b0eb65e24a57319e63e442d",
"bytes": 571172,
"match": true
},
"procity_fit_firepit_01.glb": {
"local": "333d494a1b8536c28586ca15d092a0290bf8fcb2",
"depot": "333d494a1b8536c28586ca15d092a0290bf8fcb2",
"bytes": 570940,
"match": true
},
"procity_fit_flight_case_01.glb": {
"local": "3fa5d83267d6de6d099aff6340491b412e65cf62",
"depot": "3fa5d83267d6de6d099aff6340491b412e65cf62",
"bytes": 570760,
"match": true
},
"procity_fit_marble_sink_01.glb": {
"local": "4cfc028c587187c6eda800623a4aed5fba4ca4fa",
"depot": "4cfc028c587187c6eda800623a4aed5fba4ca4fa",
"bytes": 570512,
"match": true
},
"procity_fit_pallet_01.glb": {
"local": "115756c996d14be3e6ac84fe3dc9b22f587d2c5c",
"depot": "115756c996d14be3e6ac84fe3dc9b22f587d2c5c",
"bytes": 571172,
"match": true
},
"procity_fit_picnic_table_01.glb": {
"local": "d25be21f6bb75e30db2c50dfe5f9fe70cdf5ef20",
"depot": "d25be21f6bb75e30db2c50dfe5f9fe70cdf5ef20",
"bytes": 570828,
"match": true
},
"procity_fit_planter_01.glb": {
"local": "94fad658b8ad79cf277ae6f1c3a2f3c311cf49c1",
"depot": "94fad658b8ad79cf277ae6f1c3a2f3c311cf49c1",
"bytes": 559572,
"match": true
},
"procity_fit_topiary_01.glb": {
"local": "75823053cb8948eba8ed79c0cd7ab32f294a7db0",
"depot": "75823053cb8948eba8ed79c0cd7ab32f294a7db0",
"bytes": 571204,
"match": true
},
"procity_fit_raffle_drum_01.glb": {
"local": "3674c0756468b32a66c6555a95cce9b8b1dc0e4f",
"depot": "3674c0756468b32a66c6555a95cce9b8b1dc0e4f",
"bytes": 571244,
"match": true
},
"procity_fit_rope_post_01.glb": {
"local": "28e5098e5e66a8b7329bb875f105969b20102429",
"depot": "28e5098e5e66a8b7329bb875f105969b20102429",
"bytes": 569896,
"match": true
},
"procity_fit_sandwich_board_01.glb": {
"local": "143535e6af683ea840db674e3309cc0353f1e557",
"depot": "143535e6af683ea840db674e3309cc0353f1e557",
"bytes": 570940,
"match": true
},
"procity_fit_smoke_machine_01.glb": {
"local": "adee756e240c41483d9cfd3d01b578e44f29e648",
"depot": "adee756e240c41483d9cfd3d01b578e44f29e648",
"bytes": 571172,
"match": true
},
"procity_fit_speaker_stack_01.glb": {
"local": "6980ea349f12f505e34add2a54afcd939c003943",
"depot": "6980ea349f12f505e34add2a54afcd939c003943",
"bytes": 571028,
"match": true
},
"procity_fit_strobe_light_01.glb": {
"local": "27b2fc5557933310b1993d36ba66dac373734e03",
"depot": "27b2fc5557933310b1993d36ba66dac373734e03",
"bytes": 571028,
"match": true
},
"procity_fit_sub_bass_01.glb": {
"local": "40b5f0f21c7e4e7bccc0517de47265795bdb1a41",
"depot": "40b5f0f21c7e4e7bccc0517de47265795bdb1a41",
"bytes": 571016,
"match": true
},
"procity_fit_urinal_trough_01.glb": {
"local": "6628e71e811b58043e14cb0f247f9f5f6df7be2d",
"depot": "6628e71e811b58043e14cb0f247f9f5f6df7be2d",
"bytes": 745108,
"match": true
},
"procity_fit_wheelie_bin_01.glb": {
"local": "b1132400613d4d126576785ec9bac6a5676f2968",
"depot": "b1132400613d4d126576785ec9bac6a5676f2968",
"bytes": 571244,
"match": true
},
"procity_fit_deck_1200_rigged_01.glb": {
"local": "48509ef48afe8eb4bd47692c795f66759b55acf0",
"depot": "48509ef48afe8eb4bd47692c795f66759b55acf0",
"bytes": 378880,
"match": true
},
"procity_fit_deck_1200_01.glb": {
"local": "dcc66b46786f624aed9ef5b7ca2f1c7646f9937b",
"depot": "dcc66b46786f624aed9ef5b7ca2f1c7646f9937b",
"bytes": 476856,
"match": true
},
"procity_fit_mixer_ttm54i_rigged_01.glb": {
"local": "7b6683d351950f202d7d4e2c33f9e31b05d817ae",
"depot": "7b6683d351950f202d7d4e2c33f9e31b05d817ae",
"bytes": 318912,
"match": true
},
"procity_fit_mixer_pmc05pro_rigged_01.glb": {
"local": "fab6e18d0512436b7e0132917f7d4b47f7a75eb8",
"depot": "fab6e18d0512436b7e0132917f7d4b47f7a75eb8",
"bytes": 290600,
"match": true
},
"procity_fit_mixer_shdj1200_rigged_01.glb": {
"local": "4524666a6900a6db69c358e6e01a06fd261fe10b",
"depot": "4524666a6900a6db69c358e6e01a06fd261fe10b",
"bytes": 303848,
"match": true
},
"procity_fit_mixer_pmx9000_01.glb": {
"local": "2b1a4ce143b07bcaad75aa46f808c95799d3bac9",
"depot": "2b1a4ce143b07bcaad75aa46f808c95799d3bac9",
"bytes": 331648,
"match": true
},
"procity_fit_sl_deck_3_01.glb": {
"local": "f70b667c701d19f1df1bbb2bbb925c024aafa527",
"depot": "f70b667c701d19f1df1bbb2bbb925c024aafa527",
"bytes": 353560,
"match": true
}
}

View File

@ -17,6 +17,9 @@ each asset as the ruler.
Options (after OUT.png, in any order among the file list): Options (after OUT.png, in any order among the file list):
--cols N tiles per row (default: ceil(sqrt(n))) --cols N tiles per row (default: ceil(sqrt(n)))
--pitch M world metres between tile centres (default 1.0). Raise it when the set holds a
2.7 m beer umbrella next to a 0.2 m ashtray: at the default pitch the big asset
overlaps its neighbours and the sheet stops being readable.
--tile N pixel size per tile (default 256) --tile N pixel size per tile (default 256)
--fit per-tile auto-zoom instead of one shared scale (looks prettier, lies about size) --fit per-tile auto-zoom instead of one shared scale (looks prettier, lies about size)
--title TEXT caption drawn at the top of the sheet --title TEXT caption drawn at the top of the sheet
@ -28,6 +31,7 @@ ARGV = sys.argv[sys.argv.index("--") + 1:]
OUT = ARGV[0] OUT = ARGV[0]
rest = ARGV[1:] rest = ARGV[1:]
COLS = TILE = None COLS = TILE = None
PITCH_OPT = None
FIT = False FIT = False
TITLE = "" TITLE = ""
files = [] files = []
@ -36,6 +40,8 @@ while i < len(rest):
a = rest[i] a = rest[i]
if a == "--cols": if a == "--cols":
COLS = int(rest[i + 1]); i += 2 COLS = int(rest[i + 1]); i += 2
elif a == "--pitch":
PITCH_OPT = float(rest[i + 1]); i += 2
elif a == "--tile": elif a == "--tile":
TILE = int(rest[i + 1]); i += 2 TILE = int(rest[i + 1]); i += 2
elif a == "--title": elif a == "--title":
@ -92,7 +98,7 @@ def main():
wipe() wipe()
scn = bpy.context.scene scn = bpy.context.scene
THETA = math.radians(58) # camera tilt from straight-down THETA = math.radians(58) # camera tilt from straight-down
PITCH = 1.0 # world metres between tile centres, ACROSS (x) PITCH = PITCH_OPT or 1.0 # world metres between tile centres, ACROSS (x)
# Rows are separated along world Y, which the tilted camera foreshortens by cos(THETA) ~ 0.53, # Rows are separated along world Y, which the tilted camera foreshortens by cos(THETA) ~ 0.53,
# while object HEIGHT projects at sin(THETA) ~ 0.85. Using one pitch for both axes therefore # while object HEIGHT projects at sin(THETA) ~ 0.85. Using one pitch for both axes therefore
# stacks every tall asset on top of the row behind it. Space the rows out by the reciprocal. # stacks every tall asset on top of the row behind it. Space the rows out by the reciprocal.
@ -205,9 +211,15 @@ def main():
cam.data.ortho_scale = max(need_h, need_w * res_y / res_x) cam.data.ortho_scale = max(need_h, need_w * res_y / res_x)
if TITLE: if TITLE:
# well clear of row 0: tall assets project upward in screen space from y=0, so a title # SOLVE for the title's world Y instead of guessing a metre offset. A camera rotated theta
# parked at +0.6 m lands on top of them. # about X puts a point at screen-vertical (Y - tgt.y)*cos(theta) + (z - tgt.z)*sin(theta);
label(TITLE, W / 2, PITCH_Y * 0.72 + tall, PITCH * 0.085, (0.05, 0.05, 0.1, 1)) # the old "PITCH_Y*0.72 + tall" guess scaled with PITCH and silently walked off the top of
# the frame at --pitch 2.9 (the 39-prop sheet rendered with no title at all and nobody
# would have known). Target the screen offset the frame budget actually reserves.
base = (0 - tgt.y) * math.cos(THETA) - tgt.z * math.sin(THETA) # row-0 ground line, in screen units
want = base + tall * math.sin(THETA) + PITCH * 0.18 # clear the tallest asset
yt = tgt.y + (want + tgt.z * math.sin(THETA)) / math.cos(THETA)
label(TITLE, W / 2, yt, PITCH * 0.16, (0.05, 0.05, 0.1, 1))
# Blender 5.x renamed the EEVEE enum back to BLENDER_EEVEE; keep both spellings working. # Blender 5.x renamed the EEVEE enum back to BLENDER_EEVEE; keep both spellings working.
engines = scn.bl_rna.properties["render"].fixed_type.properties["engine"].enum_items.keys() \ engines = scn.bl_rna.properties["render"].fixed_type.properties["engine"].enum_items.keys() \

215
pipeline/isolate.py Normal file
View File

@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Render ONE staged scene — a prop (or a group of props on a bench) seen from player eye height.
WHY THIS EXISTS (R42 §42.2, the DJ-booth diagnosis)
---------------------------------------------------
`contact_sheet.py` answers "is this metre-correct and what colour is it" from a tilted ortho camera
over a white sweep. It cannot answer "does this READ as a turntable from where the player stands",
because that question is about contrast *against the thing it sits on*, at a perspective camera at
1.6 m, at the size it occupies on screen. The R41 booth fault ("a plain black box") is exactly that
class of question, and diagnosing it needs the asset rendered THREE ways alone on neutral, on the
dark bench Lane C actually builds, and at the room's light level — so the cause can be attributed to
the asset, the composition or the lighting instead of guessed at.
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/isolate.py -- OUT.png [opts] GLB[@x,y,z,ry] ...
Options (any order, before or after the file list):
--res W,H output pixels (default 800,620)
--bench W,D,H,HEX a bench under the props; props are planted ON it (default: none, floor)
--wall HEX back-wall colour (default e8e2cf, a pale interior wall)
--floor HEX floor colour (default 8a6a44)
--cam D,EYE,YAW,AIM camera distance m, eye height m, yaw deg, aim height m (default 1.5,1.62,18,0.95)
--exposure F film exposure (default 1.0)
--lamp E ceiling lamp energy in W (default 34)
A GLB may carry a placement suffix: `path.glb@x,y,z,ry` (metres, degrees). `y` is measured from the
bench top when a bench is present, otherwise from the floor.
View transform is **Standard**, for the same reason `contact_sheet.py` uses it: this is a colour
judgement and AgX is a film emulation that bleaches exactly the pale props this round is about.
"""
import bpy, sys, os, math
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
OUT = ARGV[0]
rest = ARGV[1:]
RES = (800, 620)
BENCH = None # (w, d, h, (r,g,b))
WALL = "e8e2cf"
FLOOR = "8a6a44"
CAM = (1.5, 1.62, 18.0, 0.95)
EXPOSURE = 1.0
LAMP = 34.0
files = []
def hexcol(h):
h = h.lstrip("#")
s = [int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)]
# sRGB -> linear, because Blender base colours are linear and a hex is not
return tuple((c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4) for c in s)
i = 0
while i < len(rest):
a = rest[i]
if a == "--res":
RES = tuple(int(v) for v in rest[i + 1].split(",")); i += 2
elif a == "--bench":
p = rest[i + 1].split(","); BENCH = (float(p[0]), float(p[1]), float(p[2]), p[3]); i += 2
elif a == "--wall":
WALL = rest[i + 1]; i += 2
elif a == "--floor":
FLOOR = rest[i + 1]; i += 2
elif a == "--cam":
CAM = tuple(float(v) for v in rest[i + 1].split(",")); i += 2
elif a == "--exposure":
EXPOSURE = float(rest[i + 1]); i += 2
elif a == "--lamp":
LAMP = float(rest[i + 1]); i += 2
else:
files.append(a); i += 1
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
def flat(name, rgb, rough=0.85, metal=0.0):
m = bpy.data.materials.new(name)
m.use_nodes = True
b = m.node_tree.nodes.get("Principled BSDF")
b.inputs["Base Color"].default_value = (*rgb, 1)
b.inputs["Roughness"].default_value = rough
b.inputs["Metallic"].default_value = metal
return m
def bounds(objs):
lo = Vector((1e9,) * 3); hi = Vector((-1e9,) * 3)
for o in objs:
if o.type != 'MESH':
continue
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
for k in range(3):
lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k])
return lo, hi
def main():
wipe()
scn = bpy.context.scene
top = BENCH[2] if BENCH else 0.0
# --- bench (the thing the props stand on — the R42 diagnosis needs it to be the real colour) ---
if BENCH:
w, d, h, hx = BENCH
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, h / 2))
bo = bpy.context.object
bo.scale = (w, d, h) # primitive_cube_add(size=1) is already 1 m across
bo.data.materials.append(flat("bench", hexcol(hx), 0.6))
# --- props -----------------------------------------------------------------------------------
placed = 0
for spec in files:
path, _, place = spec.partition("@")
if not path.lower().endswith(".glb"):
continue
x = y = z = ry = 0.0
if place:
p = [float(v) for v in place.split(",")]
x, y, z, ry = (p + [0, 0, 0, 0])[:4]
before = set(bpy.data.objects)
try:
bpy.ops.import_scene.gltf(filepath=path)
except Exception as e:
print(f"IMPORT FAIL {path}: {e}")
continue
new = [o for o in bpy.data.objects if o not in before]
roots = [o for o in new if o.parent is None]
meshes = [o for o in new if o.type == 'MESH']
if not meshes:
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
continue
for r in roots:
r.rotation_euler = (r.rotation_euler.x, r.rotation_euler.y, r.rotation_euler.z + math.radians(ry))
bpy.context.view_layer.update()
lo, hi = bounds(meshes)
cx, cy = (lo.x + hi.x) / 2, (lo.y + hi.y) / 2
for r in roots:
r.location = (r.location.x + x - cx, r.location.y + z - cy, r.location.z - lo.z + top + y)
placed += 1
bpy.context.view_layer.update()
allm = [o for o in bpy.data.objects if o.type == 'MESH']
lo, hi = bounds(allm)
# --- room shell -------------------------------------------------------------------------------
span = max(hi.x - lo.x, 3.0) * 3 + 4
bpy.ops.mesh.primitive_plane_add(size=span, location=(0, 0, -0.001))
bpy.context.object.data.materials.append(flat("floor", hexcol(FLOOR), 0.9))
wall_y = max(hi.y, 0.4) + 0.55
bpy.ops.mesh.primitive_plane_add(size=span, location=(0, wall_y, span / 2 - 0.4))
wl = bpy.context.object
wl.rotation_euler = (math.radians(90), 0, 0)
wl.data.materials.append(flat("wall", hexcol(WALL), 0.95))
# --- light: one warm ceiling fluoro + a soft fill, i.e. what an interior room actually has ----
scn.world.use_nodes = True
scn.world.node_tree.nodes["Background"].inputs[0].default_value = (0.42, 0.44, 0.48, 1)
scn.world.node_tree.nodes["Background"].inputs[1].default_value = 0.42
# The lamp is SIZED to the scene, not fixed: a 1.2 m softbox that flatters a 0.45 m turntable
# burns a 5 m pub corner to white, and a colour judgement rendered through a blow-out is not a
# colour judgement. Energy stays the caller's number; only the emitter area follows the set.
span_x = max(hi.x - lo.x, 0.6)
bpy.ops.object.light_add(type='AREA', location=(0, -0.45, 2.55))
lg = bpy.context.object
lg.data.energy = LAMP
lg.data.size = min(4.0, max(1.2, span_x * 0.55))
lg.data.color = (1.0, 0.96, 0.88)
bpy.ops.object.light_add(type='AREA', location=(-span_x * 0.4 - 0.8, -1.9, 1.9))
fl = bpy.context.object
fl.data.energy = LAMP * 0.30
fl.data.size = min(4.0, max(1.6, span_x * 0.5))
fl.rotation_euler = (math.radians(58), 0, math.radians(-38))
# --- camera: perspective, at eye height, looking at the gear ----------------------------------
dist, eye, yaw, aim = CAM
yr = math.radians(yaw)
cam_loc = Vector((math.sin(yr) * dist, -math.cos(yr) * dist, eye))
tgt = Vector((0, 0, aim))
bpy.ops.object.camera_add(location=cam_loc)
cam = bpy.context.object
cam.data.lens = 34
d = (tgt - cam_loc)
# A Blender camera looks down its LOCAL -Z with +Y up. Hand-rolling the two Euler angles put the
# first take of this tool at the sky and rendered a flat grey frame; `to_track_quat` is the only
# spelling that is right for every yaw.
cam.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
scn.camera = cam
scn.view_settings.view_transform = 'Standard'
scn.view_settings.look = 'None'
scn.view_settings.exposure = math.log2(EXPOSURE) if EXPOSURE > 0 else 0
for e in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'):
try:
scn.render.engine = e
break
except TypeError:
continue
scn.render.film_transparent = False
scn.render.resolution_x, scn.render.resolution_y = RES
scn.render.image_settings.file_format = 'PNG'
scn.render.filepath = os.path.abspath(OUT)
os.makedirs(os.path.dirname(os.path.abspath(OUT)), exist_ok=True)
bpy.ops.render.render(write_still=True)
print(f"ISOLATE {OUT} {placed}/{len(files)} props bench={BENCH}")
main()

112
pipeline/montage.py Normal file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Lay rendered PNGs out in a labelled grid — the before/after tool.
`contact_sheet.py` renders many GLBs in one Blender scene; it cannot pair a render made *now*
against one committed in a previous round, which is exactly what a look round is judged on. This
composites finished images: no Blender, no 3D, just PIL.
python3 pipeline/montage.py OUT.png --cols 2 --title "R41 -> R42" \\
before.png:"R41 as shipped" after.png:"R42 dressed"
Each input is `PATH[:CAPTION]`. Tiles are scaled to a common width, captions are drawn under them,
and a title band is drawn on top when `--title` is given. `--pair-gap` widens the gutter between
column pairs so an A/B row reads as pairs rather than as a strip.
"""
import os, sys
from PIL import Image, ImageDraw, ImageFont
BG = (250, 249, 246)
FG = (26, 26, 30)
SUB = (90, 92, 100)
def font(sz, bold=False):
for p in ("/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/Library/Fonts/Arial.ttf"):
try:
return ImageFont.truetype(p, sz)
except Exception:
continue
return ImageFont.load_default()
def main():
av = sys.argv[1:]
out = av[0]
cols = int(av[av.index("--cols") + 1]) if "--cols" in av else 2
title = av[av.index("--title") + 1] if "--title" in av else ""
width = int(av[av.index("--width") + 1]) if "--width" in av else 620
pair_gap = int(av[av.index("--pair-gap") + 1]) if "--pair-gap" in av else 0
items = []
skip = set()
for i, a in enumerate(av):
if a in ("--cols", "--title", "--width", "--pair-gap"):
skip.add(i); skip.add(i + 1)
for i, a in enumerate(av):
if i == 0 or i in skip or a.startswith("--"):
continue
path, _, cap = a.partition(":")
if not os.path.exists(path):
print(f"MISSING {path}")
continue
items.append((path, cap))
tiles = []
for path, cap in items:
im = Image.open(path).convert("RGB")
h = int(im.height * width / im.width)
tiles.append((im.resize((width, h), Image.LANCZOS), cap))
if not tiles:
print("nothing to montage")
return 1
# Captions WRAP to the tile width. The first take let a long caption run under its neighbour and
# the two collided in the middle of the sheet, which is the one thing a proof shot may not do.
f_cap = font(19)
def wrap(text, px):
words, lines, cur = text.split(), [], ""
for w in words:
t = (cur + " " + w).strip()
if dr0.textlength(t, font=f_cap) <= px or not cur:
cur = t
else:
lines.append(cur); cur = w
if cur:
lines.append(cur)
return lines
dr0 = ImageDraw.Draw(Image.new("RGB", (8, 8)))
tiles = [(im, wrap(cap, width - 6) if cap else []) for im, cap in tiles]
cap_lines = max((len(c) for _, c in tiles), default=0)
cap_h = (8 + 23 * cap_lines) if cap_lines else 0
rows = (len(tiles) + cols - 1) // cols
row_h = [max(t.height for t, _ in tiles[r * cols:(r + 1) * cols]) + cap_h for r in range(rows)]
pad, title_h = 16, (56 if title else 0)
W = pad + cols * (width + pad) + (pair_gap * (cols // 2 - 1) if cols > 2 else 0)
H = title_h + pad + sum(h + pad for h in row_h)
sheet = Image.new("RGB", (W, H), BG)
dr = ImageDraw.Draw(sheet)
if title:
dr.text((pad, 16), title, fill=FG, font=font(26, True))
y = title_h + pad
for r in range(rows):
x = pad
for c in range(cols):
k = r * cols + c
if k >= len(tiles):
break
im, cap = tiles[k]
sheet.paste(im, (x, y))
for li, line in enumerate(cap):
dr.text((x + 2, y + im.height + 8 + li * 23), line, fill=SUB, font=f_cap)
x += width + pad + (pair_gap if (pair_gap and c % 2 == 1) else 0)
y += row_h[r] + pad
os.makedirs(os.path.dirname(os.path.abspath(out)) or ".", exist_ok=True)
sheet.save(out)
print(f"MONTAGE {out} {len(tiles)} tiles {W}x{H}")
return 0
if __name__ == "__main__":
sys.exit(main())

348
pipeline/palette.py Normal file
View File

@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""Repaint a GLB's baked vertex colour (`COLOR_0`) into a named palette — zero draws, zero triangles.
WHY THIS EXISTS (R42 §42.1 + §42.2, "the pub props render as untextured white blobs")
--------------------------------------------------------------------------------------
R41's own note said "37 of 39 have no texture at all". Measuring the shipped files says something
different and more useful: **all 37 DO carry COLOR_0 and it does render** it is just the wrong
colour. Mean vertex colour across the set is a bleached near-grey (15 of 37 sit below 0.05
saturation; `beer_umbrella` is sRGB 238,238,238 and `jukebox` is 175,174,173), because the source
library is photogrammetry/generative output whose "colour" is really baked ambient occlusion. The
fault is therefore not a missing map it is a missing palette.
The cheapest correct fix is consequently NOT a texture. Lane C took the interior from 14,140 draws
to 117 by collapsing multi-material primitives into one `COLOR_0` primitive each (`merge_prims.py`);
adding per-asset baseColour maps would re-import the exact cost that bought. So this tool rewrites
the COLOR_0 buffer *in place*, preserving the accessor's component type and element count — same
byte length, same primitives, same triangles, **same draw count** and only touches the material
block to set metallic/roughness/emissive (material count does not affect draw count; three.js issues
one draw per glTF *primitive*, and `batch.js` never descends into a GLB instance because the loaded
root is a Group, not a Mesh).
Four routes, chosen per asset by what the source colour actually is:
ramp luminance -> a 2..5 stop palette gradient. For the bleached-grey majority: the greyscale
IS the baked shading, so mapping it through a dark->light palette keeps every AO gradient
and adds hue. Normalisation is per-asset between the source's own 1st/99th luminance
percentiles, so a flat-grey asset still spans the whole ramp.
lut exact source-colour -> target, ordered rules, first match wins, optional position
predicates (`cyl`, `y_min`, `y_max`). For the DJ gear, whose colours are a discrete
5..12 entry palette where each entry IS a part (chassis / platter / slipmat / arm).
tint keep the source hue relationship, force saturation and remap value. For the minority that
already carry meaningful colour (`pokie`'s lit artwork, `planter`'s foliage).
huesplit two ramps selected by source hue foliage vs pot, for the planted props.
factor no COLOR_0 at all (a real texture): tint via `baseColorFactor`, which multiplies the map.
python3 pipeline/palette.py --spec pipeline/r42_palette.json --src DIR --out DIR
python3 pipeline/palette.py --spec pipeline/r42_palette.json --src DIR --out DIR --only jukebox
python3 pipeline/palette.py --report DIR/*.glb # mean sRGB / saturation / prims / draws
RE-RUN SAFETY: `ramp` and `tint` normalise against the source file's own luminance spread, so a
second pass over an already-painted file is NOT a no-op. Every output is stamped
`asset.extras.procity_palette`, and a stamped input is skipped unless `--force`. The pristine
pre-R42 GLBs are kept at `pipeline/.props_orig/r42/` (git-ignored, like `_normalized/` itself),
which is the directory to point `--src` at when re-running.
"""
import json, os, struct, sys, colorsys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from merge_prims import read_glb, write_glb, COMPONENT, NCOMP # noqa: E402
# ── colour helpers ───────────────────────────────────────────────────────────────────────────────
def srgb_to_lin(c):
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def lin_to_srgb(c):
c = max(0.0, min(1.0, c))
return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
def hex_lin(h):
h = h.lstrip("#")
return tuple(srgb_to_lin(int(h[i:i + 2], 16) / 255.0) for i in (0, 2, 4))
def lum(rgb):
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
def lerp3(a, b, t):
return tuple(a[k] + (b[k] - a[k]) * t for k in range(3))
# ── glb attribute io ─────────────────────────────────────────────────────────────────────────────
def acc_layout(gltf, ai):
a = gltf["accessors"][ai]
fmt, sz = COMPONENT[a["componentType"]]
n = NCOMP[a["type"]]
bv = gltf["bufferViews"][a["bufferView"]]
base = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
stride = bv.get("byteStride") or sz * n
return a, fmt, sz, n, base, stride
def read_vec(gltf, binc, ai):
a, fmt, sz, n, base, stride = acc_layout(gltf, ai)
return [struct.unpack_from("<" + fmt * n, binc, base + i * stride) for i in range(a["count"])]
def write_colors(gltf, buf, ai, rows):
"""Write linear RGB back into an existing COLOR_0 accessor. Byte length never changes."""
a, fmt, sz, n, base, stride = acc_layout(gltf, ai)
ct = a["componentType"]
div = 65535.0 if ct == 5123 else (255.0 if ct == 5121 else 1.0)
for i, rgb in enumerate(rows):
old = struct.unpack_from("<" + fmt * n, buf, base + i * stride)
if div == 1.0:
vals = [float(c) for c in rgb]
else:
vals = [int(round(max(0.0, min(1.0, c)) * div)) for c in rgb]
if n == 4:
vals.append(old[3])
struct.pack_into("<" + fmt * n, buf, base + i * stride, *vals)
# ── the four routes ──────────────────────────────────────────────────────────────────────────────
def route_ramp(cols, spec):
stops = [(float(t), hex_lin(h)) for t, h in spec["stops"]]
stops.sort()
L = sorted(lum(c) for c in cols)
lo = L[max(0, int(len(L) * 0.01))]
hi = L[min(len(L) - 1, int(len(L) * 0.99))]
span = max(hi - lo, 1e-4)
gamma = float(spec.get("gamma", 1.0))
out = []
for c in cols:
t = min(1.0, max(0.0, (lum(c) - lo) / span)) ** gamma
for k in range(len(stops) - 1):
t0, c0 = stops[k]
t1, c1 = stops[k + 1]
if t <= t1 or k == len(stops) - 2:
f = 0.0 if t1 <= t0 else min(1.0, max(0.0, (t - t0) / (t1 - t0)))
out.append(lerp3(c0, c1, f))
break
else:
out.append(stops[-1][1])
return out
def route_tint(cols, spec):
hue = spec.get("hue") # degrees, or None to keep source hue
hue_rot = float(spec.get("hue_rot", 0.0)) / 360.0
sat_mul = float(spec.get("sat_mul", 1.0))
sat_set = spec.get("sat")
v0, v1 = spec.get("val", [0.0, 1.0])
S = sorted(max(lin_to_srgb(x) for x in c) for c in cols)
vlo = S[max(0, int(len(S) * 0.01))]
vhi = S[min(len(S) - 1, int(len(S) * 0.99))]
vspan = max(vhi - vlo, 1e-4)
out = []
for c in cols:
r, g, b = (lin_to_srgb(x) for x in c)
h, s, v = colorsys.rgb_to_hsv(r, g, b)
if hue is not None:
h = (float(hue) / 360.0)
h = (h + hue_rot) % 1.0
s = float(sat_set) if sat_set is not None else min(1.0, s * sat_mul)
v = v0 + (min(1.0, max(0.0, (v - vlo) / vspan)) * (v1 - v0))
r, g, b = colorsys.hsv_to_rgb(h, s, v)
out.append((srgb_to_lin(r), srgb_to_lin(g), srgb_to_lin(b)))
return out
def route_huesplit(cols, spec):
"""Two ramps chosen by SOURCE hue — 'a planter is a pot and a plant, and they are not one ramp'."""
lo_h, hi_h = spec.get("hue_band", [60, 190])
idx_a, idx_b = [], []
for i, c in enumerate(cols):
r, g, b = (lin_to_srgb(x) for x in c)
h, s, v = colorsys.rgb_to_hsv(r, g, b)
deg = h * 360.0
(idx_a if (lo_h <= deg <= hi_h and s >= spec.get("hue_min_sat", 0.05)) else idx_b).append(i)
out = [None] * len(cols)
for idxs, key in ((idx_a, "in_stops"), (idx_b, "out_stops")):
if not idxs:
continue
sub = route_ramp([cols[i] for i in idxs], {"stops": spec[key], "gamma": spec.get("gamma", 1.0)})
for j, i in enumerate(idxs):
out[i] = sub[j]
return [o if o is not None else c for o, c in zip(out, cols)]
def route_lut(cols, pos, spec):
rules = spec["rules"]
default = hex_lin(spec["default"]) if spec.get("default") else None
out = []
for c, p in zip(cols, pos):
hit = None
for rl in rules:
if "src" in rl and max(abs(c[k] - rl["src"][k]) for k in range(3)) > 2e-3:
continue
if "y_min" in rl and p[1] < rl["y_min"]:
continue
if "y_max" in rl and p[1] > rl["y_max"]:
continue
cyl = rl.get("cyl")
if cyl:
cx, cz = cyl["center"]
r = ((p[0] - cx) ** 2 + (p[2] - cz) ** 2) ** 0.5
if r > cyl.get("r_max", 1e9) or r < cyl.get("r_min", -1.0):
continue
hit = rl
break
out.append(hex_lin(hit["to"]) if hit else (default if default else c))
return out
ROUTES = {"ramp", "lut", "tint", "huesplit", "factor", "none"}
# ── per-asset application ────────────────────────────────────────────────────────────────────────
def mat_for(gltf, prim, want):
"""Return a material index carrying `want` (metallic/roughness/emissive), cloning if shared."""
mi = prim.get("material")
if mi is None:
return None
mats = gltf["materials"]
cur = mats[mi]
users = sum(1 for m in gltf.get("meshes", []) for p in m["primitives"] if p.get("material") == mi)
tgt = json.loads(json.dumps(cur))
pbr = tgt.setdefault("pbrMetallicRoughness", {})
if "metallic" in want:
pbr["metallicFactor"] = float(want["metallic"])
if "roughness" in want:
pbr["roughnessFactor"] = float(want["roughness"])
if "factor" in want:
pbr["baseColorFactor"] = list(hex_lin(want["factor"])) + [1.0]
if "emissive" in want:
tgt["emissiveFactor"] = list(hex_lin(want["emissive"]))
if tgt == cur:
return mi
if users == 1:
mats[mi] = tgt
return mi
mats.append(tgt)
return len(mats) - 1
STAMP = "procity_palette"
def stamped(gltf):
"""`ramp` and `tint` normalise against the SOURCE's own luminance spread, so running them twice
is not a no-op the second pass re-stretches an already-stretched file. The asset therefore
carries a stamp and a second run refuses unless --force. (`lut`/`factor` are idempotent; the
stamp is applied uniformly so one rule covers the whole spec.)"""
return (gltf.get("asset", {}).get("extras") or {}).get(STAMP)
def apply_asset(path, out_path, spec, force=False):
gltf, binc = read_glb(path)
was = stamped(gltf)
if was and not force:
return -1
buf = bytearray(binc)
touched = 0
seen_acc = set()
for mesh in gltf.get("meshes", []):
mname = mesh.get("name", "")
for prim in mesh["primitives"]:
sub = dict(spec)
sub.pop("meshes", None)
over = (spec.get("meshes") or {}).get(mname)
if over:
sub.update(over)
route = sub.get("route", "none")
if route not in ROUTES:
raise ValueError(f"{path}: unknown route {route!r}")
want = {k: sub[k] for k in ("metallic", "roughness", "emissive", "factor") if k in sub}
if want:
prim["material"] = mat_for(gltf, prim, want)
ai = prim["attributes"].get("COLOR_0")
if route in ("none", "factor") or ai is None:
continue
if ai in seen_acc:
print(f" ! {os.path.basename(path)}: COLOR_0 accessor {ai} shared across primitives; "
f"already written, skipping second pass")
continue
seen_acc.add(ai)
cols = [tuple(v[:3]) for v in read_vec(gltf, binc, ai)]
if route == "ramp":
new = route_ramp(cols, sub)
elif route == "tint":
new = route_tint(cols, sub)
elif route == "huesplit":
new = route_huesplit(cols, sub)
else:
new = route_lut(cols, read_vec(gltf, binc, prim["attributes"]["POSITION"]), sub)
write_colors(gltf, buf, ai, new)
touched += 1
gltf.setdefault("asset", {}).setdefault("extras", {})[STAMP] = spec.get("stamp", "r42")
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
write_glb(out_path, gltf, bytes(buf))
return touched
# ── reporting ────────────────────────────────────────────────────────────────────────────────────
def report(paths):
print(f"{'file':44} {'prims':>5} {'mats':>4} {'tris':>6} {'meanRGB':16} {'lum':>5} {'sat':>5}")
for p in paths:
gltf, binc = read_glb(p)
prims = tris = 0
tot = [0.0, 0.0, 0.0]
n = 0
for mesh in gltf.get("meshes", []):
for prim in mesh["primitives"]:
prims += 1
if "indices" in prim:
tris += gltf["accessors"][prim["indices"]]["count"] // 3
ai = prim["attributes"].get("COLOR_0")
if ai is None:
continue
a, fmt, sz, nc, base, stride = acc_layout(gltf, ai)
div = 65535.0 if a["componentType"] == 5123 else (255.0 if a["componentType"] == 5121 else 1.0)
for v in read_vec(gltf, binc, ai):
for k in range(3):
tot[k] += v[k] / div
n += 1
if n:
mean = [t / n for t in tot]
rgb = [round(lin_to_srgb(c) * 255) for c in mean]
sat = (max(mean) - min(mean)) / max(mean) if max(mean) > 0 else 0.0
s = f"{str(rgb):16} {lum(mean):5.3f} {sat:5.3f}"
else:
s = f"{'(textured)':16} {'-':>5} {'-':>5}"
print(f"{os.path.basename(p):44} {prims:5d} {len(gltf.get('materials', [])):4d} {tris:6d} {s}")
def main():
av = sys.argv[1:]
if "--report" in av:
report([a for a in av[av.index("--report") + 1:] if a.endswith(".glb")])
return 0
spec_path = av[av.index("--spec") + 1]
src = av[av.index("--src") + 1]
out = av[av.index("--out") + 1]
only = av[av.index("--only") + 1].split(",") if "--only" in av else None
force = "--force" in av
doc = json.load(open(spec_path))
n = skipped = 0
for aid, spec in doc["assets"].items():
if only and aid not in only:
continue
f = spec["file"]
touched = apply_asset(os.path.join(src, f), os.path.join(out, f), spec, force)
if touched < 0:
print(f" {aid:18} SKIP — already stamped {STAMP}; pass --force to repaint a repaint")
skipped += 1
continue
print(f" {aid:18} {spec.get('route', 'none'):9} prims_recoloured={touched} -> {f}")
n += 1
print(f"palette: {n} assets repainted, {skipped} skipped")
return 0
if __name__ == "__main__":
sys.exit(main())

114
pipeline/ped_from_fbx.py Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""PROCITY — Mixamo character FBX -> raw ped GLB (R42 §42.3, Lane E cast half).
The first stage of the ped contract pipeline. The shipped roster was made this way in R4/R7 but the
FBX->GLB step was never scripted in this repo (it lived in 90sDJsim), so recasting the town had no
reproducible front end. This is it. Stages 2 and 3 are unchanged and stay where they are:
1. THIS Blender: import Mixamo FBX -> export GLB (units + skeleton preserved)
2. decimate npx @gltf-transform/cli weld -> simplify --ratio R --error E (AUDIT.md R4)
3. merge pipeline/merge_ped.py -> 1 primitive / 1 material / 1 atlas (AUDIT.md R7)
WHAT MUST SURVIVE (measured off web/models/peds/man_casual_01.glb, the reference ped):
67 nodes = 65 `mixamorigN:<Bone>` joints + 1 mesh node + 1 `Armature`
1 skin, 65 joints, the canonical Mixamo bone-name set
`rigs.js _canon` rewrites `mixamorig\\d+` -> `mixamorig`, so the numeric infix may differ per file
(the shipped peds are `mixamorig12:`, walk.glb is `mixamorig4:`) but the COLON and the bone base
names may not: `_action()` filters an incoming clip's tracks to bones that exist on the target, so a
renamed bone does not error it silently drops the track and the citizen stands in bind pose.
That is the whole reason this stage is scripted instead of hand-run.
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/ped_from_fbx.py -- OUT_DIR NAME=SRC.fbx [NAME=SRC.fbx ...]
Import is DEFAULT-SCALE on purpose. Mixamo FBX are centimetre-unit, so the GLB comes out ~177 units
tall exactly like the shipped roster (man_casual_01: 178.01 x 175.98 x 34.75). `buildFigure()`
normalizes by the feet->crown bone span at runtime, so unit-matching the existing fleet matters more
than being metre-correct here, and rescaling would make the new peds the odd ones out.
"""
import bpy, sys, os
ARGV = sys.argv[sys.argv.index("--") + 1:]
OUT_DIR, pairs = ARGV[0], ARGV[1:]
os.makedirs(OUT_DIR, exist_ok=True)
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images,
bpy.data.armatures, bpy.data.actions):
for d in list(coll):
try:
coll.remove(d)
except Exception:
pass
ok = fail = 0
for pair in pairs:
name, _, src = pair.partition("=")
if not src:
print(f"BADARG {pair}"); fail += 1; continue
wipe()
try:
bpy.ops.import_scene.fbx(filepath=src, use_image_search=True,
automatic_bone_orientation=False)
except Exception as e:
print(f"IMPORT FAIL {name}: {e}"); fail += 1; continue
arms = [o for o in bpy.data.objects if o.type == 'ARMATURE']
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
if not arms or not meshes:
print(f"NO RIG/MESH {name}: arms={len(arms)} meshes={len(meshes)}"); fail += 1; continue
bones = [b.name for b in arms[0].data.bones]
mixa = sum(1 for b in bones if b.lower().startswith("mixamorig"))
# SCALE SHAPE — match the base roster, do not merely be normalizable.
# Blender's FBX importer lands a Mixamo character as metre-scale geometry under an Armature
# whose object scale is 0.01, so the exported GLB is a 1.76-unit mesh under a 0.01 root =
# 0.0176 world units tall. `buildFigure()` normalizes by the feet->crown BONE SPAN, so that
# animates correctly at any scale — the shipped DJ five are exactly this shape and have run
# for rounds. But the base 17 are the other shape (centimetre positions, no root scale:
# man_casual_01 POSITION spans -89..+89, root scale absent), and a replacement joining that
# pool should not be the one member with a 10^4 difference in vertex magnitude feeding the
# same impostor bake. Applying the armature scale here makes the output byte-shaped like the
# pool it joins and removes the variable rather than arguing about it.
for a in arms:
a.select_set(True)
bpy.context.view_layer.objects.active = a
bpy.ops.object.select_all(action='DESELECT')
for o in bpy.data.objects:
o.select_set(True)
bpy.context.view_layer.objects.active = arms[0]
try:
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
except Exception as e:
print(f"SCALE APPLY WARN {name}: {e}")
# Mixamo FBX carry a T-pose "mixamo.com" action; the ped is a MESH asset, the clips come from
# the shared bank, so drop every action before export. A ped GLB with an animation would make
# spawnRig's `rig.anims[0]` fallback prefer a T-pose over the fleet idle.
for o in bpy.data.objects:
if o.animation_data:
o.animation_data_clear()
for a in list(bpy.data.actions):
try:
bpy.data.actions.remove(a)
except Exception:
pass
out = os.path.join(OUT_DIR, f"{name}.glb")
try:
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB',
export_animations=False, export_skins=True,
export_morph=False, export_apply=False,
export_yup=True, export_image_format='AUTO')
except Exception as e:
print(f"EXPORT FAIL {name}: {e}"); fail += 1; continue
kb = os.path.getsize(out) // 1024
print(f"OK {name:26s} bones={len(bones):3d} mixamorig={mixa:3d} meshes={len(meshes)} "
f"{kb} KB -> {out}")
ok += 1
print(f"PED_FROM_FBX done ok={ok} fail={fail}")

184
pipeline/ped_sheet.py Normal file
View File

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""PROCITY ped contact sheet — the CAST tool (R42 §42.3, Lane E cast half).
Why this exists and `contact_sheet.py` was not enough: the ped GLBs are Mixamo exports in
CENTIMETRE units (bbox ~177 "metres" tall) and the game height-normalizes them at runtime in
`rigs.js buildFigure()`. `contact_sheet.py` is an ABSOLUTE-scale, top-down-ish tool whose frame
height is derived from the pre-scale bbox, so a 177-unit rig makes it emit a 50 000 px strip;
and even fitted, a 58 degrees-from-vertical camera shows you the tops of heads. A cast audit is a
question about FACES, CLOTHES and SILHOUETTES ("would this person look wrong on a suburban Sydney
street in 1996"), so this tool does what the game does — normalize every rig to the same standing
height and shoots it at eye level, front and 3/4, like a casting sheet.
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/ped_sheet.py -- OUT.png GLB [GLB ...]
--cols N tiles per row (default 6)
--tile N px per tile (default 320)
--title TXT caption
--height M normalized standing height (default 1.75, = rigs.js nominal)
Every tile is the SAME normalized height, so the sheet answers "who is this" and deliberately
does NOT answer "is the scale right" that is the no-giants gate's job, and it measures the
runtime figure, not the file.
"""
import bpy, sys, os, math
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
OUT, rest = ARGV[0], ARGV[1:]
COLS, TILE, TITLE, HEIGHT = 6, 320, "", 1.75
files = []
i = 0
while i < len(rest):
a = rest[i]
if a == "--cols": COLS = int(rest[i + 1]); i += 2
elif a == "--tile": TILE = int(rest[i + 1]); i += 2
elif a == "--title": TITLE = rest[i + 1]; i += 2
elif a == "--height": HEIGHT = float(rest[i + 1]); i += 2
else: files.append(a); i += 1
files = [f for f in files if f.lower().endswith(".glb")]
ROWS = int(math.ceil(len(files) / COLS))
PITCH_X = HEIGHT * 0.80 # tile centres across
PITCH_Y = HEIGHT * 1.45 # rows go BACK in world Y; the eye-level cam foreshortens little
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
def bounds(objs):
lo = Vector((1e9,) * 3); hi = Vector((-1e9,) * 3)
for o in objs:
if o.type != 'MESH':
continue
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
for k in range(3):
lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k])
return lo, hi
def label(text, x, y, z, size, colour=(0.10, 0.10, 0.12, 1)):
cu = bpy.data.curves.new(type="FONT", name="lbl")
cu.body = text
cu.align_x = 'CENTER'
cu.size = size
ob = bpy.data.objects.new("lbl", cu)
ob.location = (x, y, z)
ob.rotation_euler = (math.radians(90), 0, 0) # stand the text up, facing the camera
m = bpy.data.materials.new("lblmat")
m.use_nodes = True
b = m.node_tree.nodes.get("Principled BSDF")
b.inputs["Base Color"].default_value = colour
b.inputs["Roughness"].default_value = 1.0
ob.data.materials.append(m)
bpy.context.scene.collection.objects.link(ob)
def main():
wipe()
scn = bpy.context.scene
placed = 0
for n, path in enumerate(files):
r, c = divmod(n, COLS)
before = set(bpy.data.objects)
try:
bpy.ops.import_scene.gltf(filepath=path)
except Exception as e:
print(f"IMPORT FAIL {path}: {e}"); continue
new = [o for o in bpy.data.objects if o not in before]
roots = [o for o in new if o.parent is None]
meshes = [o for o in new if o.type == 'MESH']
if not meshes:
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
print(f"NO MESH {path}"); continue
lo, hi = bounds(meshes)
span = max(hi.z - lo.z, 1e-6)
s = HEIGHT / span # what rigs.js buildFigure does, by bbox
cx, cy = (lo.x + hi.x) / 2, (lo.y + hi.y) / 2
tx, ty = c * PITCH_X, -r * PITCH_Y
for o in roots:
o.scale = (o.scale.x * s, o.scale.y * s, o.scale.z * s)
o.location = (o.location.x * s + tx - cx * s,
o.location.y * s + ty - cy * s,
o.location.z * s - lo.z * s)
# Caption stands on the floor IN FRONT of the figure (camera sits at -Y), not under it —
# a label at negative Z is occluded by the floor plane and renders as nothing, which is how
# this tool's first three audit strips came back unlabelled.
label(os.path.basename(path)[:-4][:24], tx, ty - PITCH_X * 0.46, HEIGHT * 0.012,
HEIGHT * 0.055)
placed += 1
W = (COLS - 1) * PITCH_X
D = (ROWS - 1) * PITCH_Y
# floor + backdrop
bpy.ops.mesh.primitive_plane_add(size=max(W, D) * 6 + 40, location=(W / 2, -D / 2, -0.003))
for name, col in (("floor", (0.90, 0.90, 0.93, 1)),):
m = bpy.data.materials.new(name)
m.use_nodes = True
b = m.node_tree.nodes.get("Principled BSDF")
b.inputs["Base Color"].default_value = col
b.inputs["Roughness"].default_value = 1.0
bpy.context.object.data.materials.append(m)
scn.world.use_nodes = True
scn.world.node_tree.nodes["Background"].inputs[0].default_value = (0.93, 0.94, 0.97, 1)
scn.world.node_tree.nodes["Background"].inputs[1].default_value = 0.9
bpy.ops.object.light_add(type='SUN', location=(W / 2 + 4, 8, 10))
sun = bpy.context.object
sun.data.energy = 2.6
sun.rotation_euler = (math.radians(58), 0, math.radians(198))
bpy.ops.object.light_add(type='SUN', location=(W / 2 - 6, 6, 6))
fill = bpy.context.object
fill.data.energy = 1.1
fill.rotation_euler = (math.radians(66), 0, math.radians(150))
# Standard view transform, same reason contact_sheet.py gives: a film emulation would
# desaturate the clothing this sheet exists to judge.
scn.view_settings.view_transform = 'Standard'
scn.view_settings.look = 'None'
# ORTHO camera at chest height, looking down the +Y axis (rows recede away from it).
# glTF import puts the character's front along -Y in Blender, so this sees FACES.
tgt = Vector((W / 2, -D / 2, HEIGHT * 0.46))
dist = (max(W, D) + 10) * 2 + 12
bpy.ops.object.camera_add(location=(tgt.x, tgt.y - dist, tgt.z + dist * 0.16))
cam = bpy.context.object
cam.data.type = 'ORTHO'
cam.data.clip_end = dist * 5
cam.rotation_euler = (math.radians(81), 0, 0)
scn.camera = cam
need_w = COLS * PITCH_X
need_h = D * 0.36 + HEIGHT * 1.30 + (HEIGHT * 0.30 if TITLE else 0.0)
cam.data.ortho_scale = max(need_w, need_h * (need_w / max(need_h, 1e-6)) * 0) or need_w
cam.data.ortho_scale = need_w
res_x = int(COLS * TILE)
res_y = max(TILE // 2, int(res_x * need_h / need_w))
if TITLE:
label(TITLE, W / 2, -D - PITCH_Y * 0.30, HEIGHT * 1.16, HEIGHT * 0.085, (0.06, 0.06, 0.10, 1))
scn.render.resolution_x = res_x
scn.render.resolution_y = res_y
scn.render.resolution_percentage = 100
scn.render.image_settings.file_format = 'PNG'
scn.render.filepath = OUT
# Blender 5.x renamed the realtime engine back to BLENDER_EEVEE; 4.2-4.5 called it
# BLENDER_EEVEE_NEXT. Pick whichever this build actually offers rather than hard-coding one.
avail = {e.identifier for e in
bpy.types.RenderSettings.bl_rna.properties['engine'].enum_items}
for eng in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT', 'CYCLES'):
if eng in avail:
scn.render.engine = eng
break
try:
scn.eevee.taa_render_samples = 24
except Exception:
pass
bpy.ops.render.render(write_still=True)
print(f"PED SHEET {OUT} {placed}/{len(files)} rendered {COLS}x{ROWS} h={HEIGHT}m")
main()

1381
pipeline/r42_palette.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -162,6 +162,35 @@ def plansrc_landed(p):
# species and not gettable in a floor round. It is labelled EXCEPTION, never baseline, so a reader of # species and not gettable in a floor round. It is labelled EXCEPTION, never baseline, so a reader of
# this log can never mistake the two. "Should classic show the town selector at all?" is on the v8 debt # this log can never mistake the two. "Should classic show the town selector at all?" is on the v8 debt
# ledger for R38+. # ledger for R38+.
#
# ── [Lane F R42 §42.6] RULING 1 SUBSTITUTIONS — the covenant's own "ruling + re-pin" clause, used ──
# R42's period law (PROCITY is 1990s Australia) retired four bodies that ARE in the v2.1 baseline: a
# purple luchador, a cartoon child, a robed fantasy elder and a WW2 infantryman. Lane E sourced
# replacements and Lane D swapped them **at their own index** (§42.4), and four of the five sit in
# `PED_NAMES.normal`/`comical`, which `?classic=1` loads. So classic's wire changed and this gate went
# RED with exactly the right message: *"either the asset belongs in classic and the covenant needs a
# ruling + a re-pin, or it must be gated out of ?classic."*
#
# It is not gettable by gating: gating the replacements out of classic means classic keeps shipping the
# luchador, and ruling 1 is a repo-wide law, not a default-boot law. So: a ruling, and a re-pin — but a
# re-pin of exactly four named files, recorded as SUBSTITUTIONS rather than admitted as a class.
#
# THE EXCEPTION IS SELF-VERIFYING, which is what stops it from being a hole. A substitution only
# classifies OK if the file it replaces is itself present in the v2.1 tree — so this entry cannot be
# used to smuggle in an asset that was never baseline. And the arm below additionally asserts that
# **none of the retired four is still on the wire**: a substitution that became an addition would grow
# the pool, renumber `pickRig` and move the crowd, and it would pass this allow-list silently.
# `/models/peds/` is otherwise still enumerated by the v2.1 tree — same-origin is not a free pass.
R42_PERIOD_SUBSTITUTIONS = {
'/models/peds/man_casual_04.glb': '/models/peds/man_elder_01.glb',
'/models/peds/person_casual_01.glb': '/models/peds/man_soldier_ww2_01.glb',
'/models/peds/woman_smart_02.glb': '/models/peds/comical_luchador_01.glb',
'/models/peds/person_youth_01.glb': '/models/peds/comical_boy_01.glb',
}
# (`man_smart_01` replaces `dj_phrtt_01` in `PED_NAMES.djs`, which is gated OFF classic — it is
# deliberately absent from this map, and if it ever appears on classic's wire the gate goes red.)
R42_RETIRED = set(R42_PERIOD_SUBSTITUTIONS.values())
V2_TAG = 'v2.1' V2_TAG = 'v2.1'
DEPOT_HOSTS = ('digalot.fyi',) DEPOT_HOSTS = ('digalot.fyi',)
# (prefix, label) — carried, NOT baseline. Printed on every run, tagged EXCEPTION in the output. # (prefix, label) — carried, NOT baseline. Printed on every run, tagged EXCEPTION in the output.
@ -214,6 +243,18 @@ def _classify_fetch(url, host, paths, depot):
return 'ok', f'CARRIED EXCEPTION (not {V2_TAG} baseline) — {why}' return 'ok', f'CARRIED EXCEPTION (not {V2_TAG} baseline) — {why}'
if path in paths: if path in paths:
return 'ok', f'same-origin asset present in the {V2_TAG} tree' return 'ok', f'same-origin asset present in the {V2_TAG} tree'
old = R42_PERIOD_SUBSTITUTIONS.get(path)
# self-verifying: only OK if the body it replaces IS baseline (see the block above).
# `paths` holds URL-shaped entries ('/models/peds/x.glb') — `_v2_baseline` strips only the
# three chars of 'web' off 'web/models/…', keeping the leading slash — so `old` is compared
# as written. (The first cut re-derived the key and lost the slash: every substitution then
# failed its own self-verification and the gate reported four exceptions that "do not hold".)
if old and old in paths:
return 'ok', (f'RULING-1 SUBSTITUTION (R42 §42.3/§42.4) — replaces {old.split("/")[-1]}, '
f'which IS in the {V2_TAG} tree and was retired under the period law')
if old:
return 'violation', (f'claimed as a RULING-1 substitution for {old.split("/")[-1]}, but that '
f'file is NOT in the {V2_TAG} tree — the exception does not hold')
return 'violation', f'same-origin asset NOT in the {V2_TAG} tree' return 'violation', f'same-origin asset NOT in the {V2_TAG} tree'
h = url.split('/')[2] if '://' in url else '' h = url.split('/')[2] if '://' in url else ''
if any(d in h for d in DEPOT_HOSTS): if any(d in h for d in DEPOT_HOSTS):
@ -362,6 +403,22 @@ def classic_regression(p):
missing = [k for k in CLASSIC_REQUIRED if k not in core_seen] missing = [k for k in CLASSIC_REQUIRED if k not in core_seen]
FAIL(f"classic network: the REQUIRED v2 core was not observed ({missing}) — the observer " FAIL(f"classic network: the REQUIRED v2 core was not observed ({missing}) — the observer "
f"is not watching a live boot, so a clean allow-list verdict here would be vacuous") f"is not watching a live boot, so a clean allow-list verdict here would be vacuous")
# [Lane F R42 §42.6] A SUBSTITUTION MUST NOT BECOME AN ADDITION. The allow-list entry
# above says four named bodies may take four retired bodies' places; this says the
# retired ones actually left. If both were on the wire the pool would have grown, every
# `pickRig` index downstream would have moved and the crowd would no longer be the crowd
# the seed gives — and the allow-list alone would have passed it without a word.
still = sorted({u.split('/')[-1] for u in observed
if any(u.endswith(r.lstrip('/')) or r in u for r in R42_RETIRED)})
if still:
FAIL(f"RULING 1 — a retired body is STILL on classic's wire: {still}. The R42 swap is "
f"defined as in-place (Lane D §42.4); a substitution that became an addition "
f"renumbers every pickRig index and moves the seeded crowd.")
else:
subs = sorted({u.split('/')[-1] for u in observed
if any(u.endswith(s.lstrip('/')) for s in R42_PERIOD_SUBSTITUTIONS)})
OK(f"RULING 1 on classic's wire: {len(subs)} substitution(s) present ({subs}) and "
f"0 of the {len(R42_RETIRED)} retired bodies fetched — the swap is in place, not additive")
if vio: if vio:
FAIL(f"CLASSIC FETCH SURFACE WIDENED — {len(vio)} fetch(es) outside the {V2_TAG} baseline: " FAIL(f"CLASSIC FETCH SURFACE WIDENED — {len(vio)} fetch(es) outside the {V2_TAG} baseline: "
+ '; '.join(f"{u.split('/')[-1]} [{why}]" for u, why in vio[:6]) + '; '.join(f"{u.split('/')[-1]} [{why}]" for u, why in vio[:6])
@ -2245,6 +2302,12 @@ def smoke_djdance(p):
tAfter: after[i] ? after[i].t : null, tAfter: after[i] ? after[i].t : null,
clipAfter: after[i] ? after[i].name : null, clipAfter: after[i] ? after[i].name : null,
isDance: d.clipDur != null && durs.has(d.clipDur), isDance: d.clipDur != null && durs.has(d.clipDur),
// [Lane F R42 §42.6] or one of Lane E's 46 LIBRARY clips, asked of the bank
// itself rather than pattern-matched on the name. See the note at the
// assertion below: D's §41.3 widening installs venue clips into gig-crowd
// slots, so "a dancer is on one of the four Rokoko clips" stopped being true
// in R41 and only stayed green while the sampled slot happened to hold one.
inBank: !!(d.clipName && P.fleet.bank && P.fleet.bank.has(d.clipName)),
amp: +(maxY[i] - minY[i]).toFixed(4) })) }; amp: +(maxY[i] - minY[i]).toFixed(4) })) };
}""") }""")
if r['nClips'] == 4: OK('default boot: 4 dance clips loaded and canonicalised (party/medium/drink/sway)') if r['nClips'] == 4: OK('default boot: 4 dance clips loaded and canonicalised (party/medium/drink/sway)')
@ -2259,7 +2322,23 @@ def smoke_djdance(p):
if not dn: if not dn:
FAIL('gig night: no dancers in the crowd — the subject is absent') FAIL('gig night: no dancers in the crowd — the subject is absent')
else: else:
not_dance = [d for d in dn if not d['isDance'] or d['kind'] != 'rig'] # [Lane F R42 §42.6] THE PROPERTY IS "A CLIP MOVES THE BODY", NOT "IT IS ONE OF FOUR".
# This read `isDance` alone — the live clip's duration must be one of the four R13 Rokoko
# dance clips — and it went RED on a perfectly healthy dancer playing `venue_northern_soul`
# (16.1 s, advancing 12.77 -> 14.18, position.y flat). Nothing was broken: D's §41.3 gig
# widening deliberately installs Lane E's venue-library clips into some crowd slots, so the
# assertion stopped being true in R41 and only stayed green while the sampled slot happened
# to hold a Rokoko clip. R42's roster swap changed which slot got sampled and surfaced it.
# This is the same species as the R41 `smoke_djdance` bug: a gate that pinned a clip
# IDENTITY the game had stopped promising, instead of the behaviour it still does.
#
# `inBank` asks the ClipBank whether it holds that clip, so the widened set is admitted
# WITHOUT admitting a name the game never loaded. Nothing is weakened: that the four dance
# clips load and bind is asserted above (the census: every clip binds n-1 tracks, sole
# unbound `mixamorigReference`) and below (the positive control: the boot fetched all four),
# and that a CLIP and not the procedural fallback is moving the body is `stalled` +
# `bouncing`, both unchanged.
not_dance = [d for d in dn if not (d['isDance'] or d['inBank']) or d['kind'] != 'rig']
# [Lane F R41 §41.6] "advancing" is measured MODULO THE CLIP, and only for a dancer still # [Lane F R41 §41.6] "advancing" is measured MODULO THE CLIP, and only for a dancer still
# on the same clip. A 30.58 s loop sampled across its wrap point reads tAfter < t and is # on the same clip. A 30.58 s loop sampled across its wrap point reads tAfter < t and is
# advancing perfectly; a slot that legitimately swapped clip inside the 700 ms window is # advancing perfectly; a slot that legitimately swapped clip inside the 700 ms window is
@ -2272,12 +2351,18 @@ def smoke_djdance(p):
return ((d['tAfter'] - d['t']) % dur) > 1e-4 return ((d['tAfter'] - d['t']) % dur) > 1e-4
stalled = [d for d in dn if not _advanced(d)] stalled = [d for d in dn if not _advanced(d)]
bouncing = [d for d in dn if d['amp'] > 0.02] bouncing = [d for d in dn if d['amp'] > 0.02]
if not_dance: FAIL(f"gig night: dancer NOT driven by a dance clip: {not_dance[:2]}") if not_dance: FAIL(f"gig night: dancer driven by neither a R13 dance clip nor any clip the "
f"bank holds: {not_dance[:2]}")
elif stalled: FAIL(f"gig night: dancer mixer not advancing (page stalled?) — amplitude read is vacuous: {stalled[:2]}") elif stalled: FAIL(f"gig night: dancer mixer not advancing (page stalled?) — amplitude read is vacuous: {stalled[:2]}")
elif bouncing: FAIL(f"gig night: dancer position.y bouncing {bouncing[0]['amp']}m — the PROCEDURAL fallback is running despite loaded clips") elif bouncing: FAIL(f"gig night: dancer position.y bouncing {bouncing[0]['amp']}m — the PROCEDURAL fallback is running despite loaded clips")
else: else:
OK(f"gig night: {len(dn)} dancer(s) driven by real dance clips (durations {sorted(set(d['clipDur'] for d in dn))}s, " r13 = [d for d in dn if d['isDance']]
f"mixers advancing, position.y flat — the clip moves the body, not the fallback)") lib = [d for d in dn if not d['isDance'] and d['inBank']]
OK(f"gig night: {len(dn)} dancer(s) driven by real loaded clips — {len(r13)} on an R13 "
f"Rokoko dance clip, {len(lib)} on a §41.3 venue-library clip "
f"({sorted({d['clipName'] for d in lib}) or 'none in this sample'}); durations "
f"{sorted({d['clipDur'] for d in dn})}s, mixers advancing, position.y flat — the clip "
f"moves the body, not the fallback")
dance_fetches = [u for u in reqs if any(f in u for f in DANCE_FILES)] dance_fetches = [u for u in reqs if any(f in u for f in DANCE_FILES)]
if len(dance_fetches) == 4: OK('positive control: the default boot fetched all 4 dance clips (the detector can see the fetch)') if len(dance_fetches) == 4: OK('positive control: the default boot fetched all 4 dance clips (the detector can see the fetch)')
else: WARN(f"positive control: expected 4 dance-clip fetches on the default boot, saw {len(dance_fetches)}") else: WARN(f"positive control: expected 4 dance-clip fetches on the default boot, saw {len(dance_fetches)}")
@ -2500,23 +2585,45 @@ def _at_bin(pg, idx=0):
}""", idx) }""", idx)
def _dig_buy(pg): def _dig_buy(pg, expect_enabled=True):
"""One REAL buy: E at the aimed bin → riffle → pull the front sleeve → click BUY → WALK OUT. """One REAL buy: E at the aimed bin → riffle → pull the front sleeve → click BUY → WALK OUT.
Returns {cash0,n0,disabled,cash1,n1} read off the live wallet + collection.""" Returns {cash0,n0,disabled,cash1,n1,panel} read off the live wallet + collection.
[Lane F R42 §42.6] WAIT FOR THE CONTROL, DO NOT BET 350 ms ON IT. This read the panel a fixed
350 ms after the sleeve click and clicked whatever was there. On R42's third heavy `--strict` run
of the day it caught the BUY button still disabled on trip 1 with $191 in the wallet so not
affordability, just a panel that had not finished resolving its pull and the whole no-pump gate
went red. The identical gate was green on the two runs before it, with byte-identical deltas
[-28, -16, -13, -13, -16], which is the signature of a race and not a regression.
Widening the sleep would only move the race; polling for the precondition removes it. `panel` is
returned so a genuine disabled-button failure arrives with the panel's own text instead of a bare
`disabled: True`, which is what made this one take a run to diagnose.
`expect_enabled=False` for the BROKE-PLAYER negative control, where a disabled button is the
POINT waiting six seconds for it to enable would be waiting for the gate to fail."""
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))") pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
pg.wait_for_timeout(500) pg.wait_for_timeout(500)
pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement; pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement;
c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true })); c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true }));
c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""") c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""")
if expect_enabled:
try:
pg.wait_for_function("() => { const b = document.querySelector('.pcdg-panel button');"
" return !!b && !b.disabled; }", timeout=6000)
except Exception:
pass # fall through — the read below reports disabled WITH the panel's text
else:
pg.wait_for_timeout(350) pg.wait_for_timeout(350)
r = pg.evaluate("""() => { r = pg.evaluate("""() => {
const P = window.PROCITY, g = P.game, w = P.wallet; const P = window.PROCITY, g = P.game, w = P.wallet;
const btn = document.querySelector('.pcdg-panel button'); const btn = document.querySelector('.pcdg-panel button');
if (!btn) return { err: 'no pull panel (pull failed)' }; if (!btn) return { err: 'no pull panel (pull failed)' };
const panel = (document.querySelector('.pcdg-panel') || {}).innerText || '';
const cash0 = w.cash(), n0 = g.collection.length, disabled = btn.disabled; const cash0 = w.cash(), n0 = g.collection.length, disabled = btn.disabled;
btn.click(); btn.click();
const out = document.querySelector('.pcdg-out'); if (out) out.click(); const out = document.querySelector('.pcdg-out'); if (out) out.click();
return { cash0, n0, disabled, cash1: w.cash(), n1: g.collection.length }; return { cash0, n0, disabled, cash1: w.cash(), n1: g.collection.length,
panel: panel.replace(/\\s+/g, ' ').slice(0, 160) };
}""") }""")
pg.wait_for_timeout(250) pg.wait_for_timeout(250)
return r return r
@ -2811,9 +2918,11 @@ def smoke_nopump(p):
return { ok, cash: g.cash, n: g.collection.length }; }""") return { ok, cash: g.cash, n: g.collection.length }; }""")
if not (broke['ok'] and broke['cash'] == 0): if not (broke['ok'] and broke['cash'] == 0):
FAIL(f"no-pump: could not set up the broke player — {broke}"); return FAIL(f"no-pump: could not set up the broke player — {broke}"); return
# arm 1: the dig — pull panel's BUY must be disabled; clicking it moves nothing # arm 1: the dig — pull panel's BUY must be disabled; clicking it moves nothing.
# `expect_enabled=False`: here a disabled button IS the assertion, so the R42 wait-for-enable
# is skipped rather than spending its whole timeout confirming what this arm wants.
_at_bin(pg) _at_bin(pg)
rd = _dig_buy(pg) rd = _dig_buy(pg, expect_enabled=False)
if rd.get('err'): if rd.get('err'):
FAIL(f"no-pump/control: dig arm — {rd}") FAIL(f"no-pump/control: dig arm — {rd}")
elif rd['disabled'] and rd['n1'] == rd['n0'] and rd['cash1'] == 0: elif rd['disabled'] and rd['n1'] == rd['n0'] and rd['cash1'] == 0:

View File

@ -16,6 +16,8 @@
# 9. the R40 gates — tools/qa/door_footpath_check.mjs · r40_lane_b.py · r40_playtest.py # 9. the R40 gates — tools/qa/door_footpath_check.mjs · r40_lane_b.py · r40_playtest.py
# 10. the R41 (THE LIBRARY LANDS) gates — pipeline/clips_verify.py · tools/qa/r41_postures.mjs · # 10. the R41 (THE LIBRARY LANDS) gates — pipeline/clips_verify.py · tools/qa/r41_postures.mjs ·
# r41_denylist.mjs · r41_citizens.py · r41_shots.py · r41_integration.py # r41_denylist.mjs · r41_citizens.py · r41_shots.py · r41_integration.py
# 11. the R42 (PROVE THE LOOK) gates — tools/qa/r42_roster.mjs (period law · roster licence) ·
# r42_cast.py · r42_shots.py · r42_budget.py · r42_repro.py. --tour adds the proof tour.
# #
# Browser-driven gates (determinism-PNG, 10-min soak, budget-HUD, ?noassets run) need the running # 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. # game; they live in tools/soak.md + tools/shots.md and run once Lane B's index.html lands.
@ -236,17 +238,16 @@ if [ "$R41_SKIP" = 1 ]; then
elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
run_gate "R41 citizens (D §41.3: boot ledger · lazy · determinism under a 2s stall · bench binding + control · leak)" \ run_gate "R41 citizens (D §41.3: boot ledger · lazy · determinism under a 2s stall · bench binding + control · leak)" \
tools/.venv/bin/python tools/qa/r41_citizens.py tools/.venv/bin/python tools/qa/r41_citizens.py
# WARN-LEVEL, and the reason is measured, not a shrug (R6 policy for a gate that can go red for a # [Lane F R42 §42.6] PROMOTED FROM warn_gate TO run_gate. R41 wired this warn-level because its
# reason unrelated to the property it asserts). Its street arm is solid — 8 near-tier rigs, 3 distinct # BROWSE arm scanned up to 8 shops for browsers, kept the best, then RE-ENTERED the winner 16-25 s
# clips, every stature clean, reproducible run to run. Its BROWSE arm is not: it scans up to 8 shops # later — and a patronage occupant is free to walk out in the meantime, so the re-entry could spawn
# for browsers, keeps the best, then RE-ENTERS the winner — and a patronage occupant is free to walk # 0 browsers and the arm asserted on an empty room. F filed it as deterministically red; D measured
# out during the ~3 s per candidate that scan costs, so the re-entry spawns 0 browsers and the arm # the filing before fixing it (§42.4) and found it was FLAKY BY CONSTRUCTION, not always red — which
# fails on an empty room. Measured on this tree: 3 consecutive standalone runs all land on `T & R # is the property that cannot go strict. D's fix removes the window rather than widening it: the
# Books` with figures 1 (keeper only), byte-identical (34 draws / 36,751 tris), while the same gate # frame, the measurements and the JPEG are now taken INSIDE the winning room the moment a candidate
# passed with figures 2 earlier the same day under different machine load. Nothing in R41's shipped # beats the incumbent, and the harness never leaves. The residual failure ("no occupied shop has
# code moved between those runs. FILED TO D (F-progress §Round 41): re-check `k.browse` after the # browse points with browsers at all") is real, deterministic and worth failing on. So it is strict.
# re-entry and re-pick, or keep the winner entered instead of re-entering it. run_gate "R41 acceptance shots (D §41.3 + §42.4: measured camera · shot in place · R10 stature line per figure)" \
warn_gate "R41 acceptance shots (D §41.3: measured camera · occlusion raycast · R10 stature line per figure)" \
tools/.venv/bin/python tools/qa/r41_shots.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r41-shots}" tools/.venv/bin/python tools/qa/r41_shots.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r41-shots}"
run_gate "R41 integration (F §41.6: wardrobe both arms · interior ≤350 on F's sweep + phantom control · noassets · clip gates · deny-list live)" \ run_gate "R41 integration (F §41.6: wardrobe both arms · interior ≤350 on F's sweep + phantom control · noassets · clip gates · deny-list live)" \
tools/.venv/bin/python tools/qa/r41_integration.py tools/.venv/bin/python tools/qa/r41_integration.py
@ -254,6 +255,78 @@ else
soft_skip "R41 browser gates (citizens · acceptance shots · integration)" "Playwright venv absent" soft_skip "R41 browser gates (citizens · acceptance shots · integration)" "Playwright venv absent"
fi fi
# ── Gate 11 (Lane F, round 42): PROVE THE LOOK ───────────────────────────────
# [Lane F R42 §42.6] R42 is a LOOK round and its two new laws are about WHAT SHIPS IN A BODY, so the
# two cheapest gates are node/stdlib and always run. Each ships its control demonstrated.
#
# • F §42.6 / ruling 1 — r42_roster.mjs --gate period: NO BANNED-GENRE BODY SHIPS. PROCITY is 1990s
# Australia; the R42 audit retired a purple luchador, a cartoon child, a wuxia elder, a WW2
# infantryman and — the one nobody had looked at in six rounds — an UNCLOTHED body that was in the
# default crowd. The gate reads `PED_NAMES` in rigs.js (the only list the game loads from), not a
# directory listing: the five files are still on disk by design, because deleting a name mid-wave
# renumbers every pickRig index. Five arms: the roster, the five replacements at their own slots,
# the pool shape 17/5/2, no banned name on any LOADABLE surface, and a QUARANTINE arm — every ped
# GLB on disk must be shipped, a base clip, or explicitly recorded as withdrawn in credits.json,
# so a sixth un-vetted body cannot appear silently. CONTROL: the R41 roster is replayed through
# the same predicates and must trip all five.
# • F §42.6 / ruling 3 — r42_roster.mjs --gate licence: ROSTER LICENCE COMPLETENESS. Every shipped
# ped resolves to a credits.json row with a real licence; RED on any `unverified`. CONTROL: the
# same predicate on a synthetic doc with R41's amber row restored must turn 20 peds red.
# • D §42.4 — r42_cast.py: the recast, wired. Contract from the GLB bytes over all 54 clips, the
# identity signature byte-identical across the swap, draw-neutrality measured as the crowd's own
# difference (render, hide citizens.group, render), animation, and the impostor seam.
# • D §42.4 — r42_shots.py: the street + pub crowd frames, every figure resolved to its body.
# Scratch outdir like the R41 shot gate, so a routine run never rewrites the round's shots.
# • F §42.6 — r42_budget.py: the interior ≤350 law with THE INSTRUMENT PINNED. C proved in §42.2b
# that the 122-vs-123 argument was the CAMERA ASPECT (a fixed vertical fov inheriting the window's
# aspect), so this sweep sets `camera.aspect = 16/9` explicitly and ships two controls: the same
# sweep at 1.25 must read C's published 116, and the pinned sweep run in a 1280x1024 window must
# return the SAME number as in 1280x720 — which is the actual claim that the gate can no longer
# drift when someone edits an unrelated `new_page`.
# • F §42.6 — r42_repro.py: THE BYTE-HASH GATE, and the R41 ruling reversed on measurement. See
# F-progress §42.6 for the ruling itself. Pins one sha256 per bookmark across 3 fresh frozen
# boots, asserts `DBG.freeze().stillLive` is all-false and that the second freeze removes nothing,
# and carries an unfrozen CONTROL that must still be able to fail.
run_gate "R42 period law (F §42.6 / ruling 1: no banned-genre body ships · quarantine arm · R41-roster control)" \
node tools/qa/r42_roster.mjs --gate period
run_gate "R42 roster licence (F §42.6 / ruling 3: every shipped ped has a real credits row · unverified control)" \
node tools/qa/r42_roster.mjs --gate licence
R42_SKIP=0; for a in "$@"; do [ "$a" = "--no-r42" ] && R42_SKIP=1; done
if [ "$R42_SKIP" = 1 ]; then
soft_skip "R42 browser gates (cast · shots · interior budget · byte-hash pin)" "--no-r42"
elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
run_gate "R42 cast wired (D §42.4: contract from the bytes · identity byte-identical · crowd-difference draws · impostor seam)" \
tools/.venv/bin/python tools/qa/r42_cast.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r42-shots}"
run_gate "R42 cast shots (D §42.4: street + pub crowd, every figure resolved to its ped GLB)" \
tools/.venv/bin/python tools/qa/r42_shots.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r42-shots}"
run_gate "R42 interior budget (F §42.6: ≤350 with the aspect PINNED 16:9 + the 1.25 and viewport controls)" \
tools/.venv/bin/python tools/qa/r42_budget.py
run_gate "R42 byte-hash pin (F §42.6: 1 sha256 per bookmark over 3 frozen boots · stillLive all-false · unfrozen control)" \
tools/.venv/bin/python tools/qa/r42_repro.py
else
soft_skip "R42 browser gates (cast · shots · interior budget · byte-hash pin)" "Playwright venv absent"
fi
# ── Gate 11b (Lane F, round 42): THE PROOF TOUR — OPT-IN (--tour), NON-STRICT ─
# [Lane F R42 §42.6] The round's DELIVERABLE (this round is judged by eye), not a regression gate:
# six frames re-shot on the integrated tree and composited against the COMMITTED R41 originals, each
# humanoid frame carrying its R10 human-sized line and its CAST line. It is opt-in for two measured
# reasons: it costs ~8 minutes of browser time (the street arm HOLDS until the live crowd offers a
# frame with three posture states in it — that dwell is the method, not slack), and pointed at its
# real outdir it REWRITES the round's committed shots. `--tour` runs it to a scratch outdir;
# PROCITY_SHOT_OUT=docs/shots/laneF_r42 regenerates the deliverable deliberately.
TOUR_RUN=0; for a in "$@"; do [ "$a" = "--tour" ] && TOUR_RUN=1; done
if [ "$TOUR_RUN" = 1 ]; then
if [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then
warn_gate "R42 proof tour (F §42.6: the paired before/after deliverable · R10 + CAST lines)" \
tools/.venv/bin/python tools/qa/r42_tour.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r42-tour}"
else
soft_skip "R42 proof tour" "Playwright venv absent"
fi
fi
# ── Summary ────────────────────────────────────────────────────────────────── # ── Summary ──────────────────────────────────────────────────────────────────
hr hr
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \ printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \

View File

@ -43,7 +43,13 @@ SAMPLE_TYPES = ['opshop', 'toy', 'video', 'record', 'book', 'milkbar', 'clothes'
# sample above only ever draws pedIndex (i*7)%19, which can never reach fleet indices 1721. This leg # sample above only ever draws pedIndex (i*7)%19, which can never reach fleet indices 1721. This leg
# spawns each of the five BY NAME and asserts the spawned stature lands human — the number E flagged, # spawns each of the five BY NAME and asserts the spawned stature lands human — the number E flagged,
# proven at the spawn path, not inferred from the file. # proven at the spawn path, not inferred from the file.
DJ_BODIES = ['woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01'] # [R42 §42.4] `dj_phrtt_01` -> `man_smart_01`: ruling 1 retired the unclothed trellis body and Lane D's
# in-place swap put a Mixamo Ch23 in its `PED_NAMES.djs` slot, so fleet index 21 is still the subject of
# this leg — only the body changed. The list is deliberately BY NAME (not derived from PED_NAMES) so that
# a roster edit which drops a gated body shows up here as "NOT IN FLEET" rather than silently shrinking
# the sample. The three native bind scales this leg now spans are 0.95-0.99 u (trellis), ~1.76 u (the R42
# Mixamo conversions) and ~173-180 u (the legacy cm roster) — all absorbed by rigs.js's span-normalise.
DJ_BODIES = ['woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'man_smart_01']
fails = [] fails = []
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}") def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
@ -194,7 +200,7 @@ def main():
FAIL(f"DJ bodies (R36, {dj['shop']}): " + "; ".join(bad)) FAIL(f"DJ bodies (R36, {dj['shop']}): " + "; ".join(bad))
else: else:
det = ", ".join(f"{f['name']} {f['nativeSpan']}u→{f['stature']:.2f}m" for f in dj['figs']) det = ", ".join(f"{f['name']} {f['nativeSpan']}u→{f['stature']:.2f}m" for f in dj['figs'])
OK(f"DJ bodies (R36, {dj['shop']}): all 5 spawned as rigs, human-sized — rigs.js span-normalise absorbs the ~1-unit bind [{det}]") OK(f"gated `djs` tail (R36/R42, {dj['shop']}): all 5 spawned as rigs, human-sized — rigs.js span-normalise absorbs every native bind scale [{det}]")
b.close() b.close()
finally: finally:
if srv: srv.terminate() if srv: srv.terminate()

View File

@ -16,6 +16,10 @@ Two reproducible frames, both chosen by MEASUREMENT rather than by hand:
points, entered through the shell's own enterShop and framed from Lane C's room.spawn the view points, entered through the shell's own enterShop and framed from Lane C's room.spawn the view
the game gives you walking in. The browser rigs are the peds who ducked in off the street, now the game gives you walking in. The browser rigs are the peds who ducked in off the street, now
playing browse.glb instead of the shopkeeper's idle. playing browse.glb instead of the shopkeeper's idle.
[R42 §42.4] This arm used to scan the candidates, exit, and re-enter the winner ~25 s later,
by which time its patronage occupants had walked back out and the room rebuilt empty. It is now
shot IN PLACE, inside the winning room, the moment a candidate beats the incumbent; the occupancy
decay it used to lose to is printed in the sidecar so the old failure stays legible.
Every figure in each frame is measured for STATURE (feet->crown span off the posed skeleton) and Every figure in each frame is measured for STATURE (feet->crown span off the posed skeleton) and
reported as the R10 no-giants line, written into a sidecar .txt beside each shot. The check is reported as the R10 no-giants line, written into a sidecar .txt beside each shot. The check is
@ -184,37 +188,50 @@ MEASURE = r"""
} }
""" """
INTERIOR = r""" # ── the browse arm. [Lane D R42 §42.4] SHOOT THE WINNER WHILE IT IS STILL THE WINNER. ─────────────
async () => { # The R41 shape of this arm was scan-then-re-enter: walk up to 8 occupied shops at ~3.1 s each, keep
const P = window.PROCITY, D = window.DBG, C = P.citizens; # the id of the one with the most browsers, then `enterShop` it again and shoot. That is
# deterministically red, and the reason is measured below and printed with every run — the browsers
# are a function of LIVE patronage occupancy, and over the ~25 s the scan costs, the occupants of the
# shop picked first (the fullest one, which is also the one whose visitors are furthest through their
# dwell) have walked back out. The re-entry then builds the same room with nobody in it and the arm
# reports "no browsers" about a game that was working. F wired it warn-level with that reason at the
# call site; this is the fix.
#
# The fix is not a longer wait or a re-roll — it is to stop leaving. Python now drives the scan one
# candidate at a time and takes the frame + the measurements + the JPEG INSIDE the room, the moment a
# candidate beats the incumbent. Nothing is re-entered, so nothing can go stale between choosing and
# shooting, and the scan keeps its original "best of several, not first that works" property.
INT_CANDS = r"""
() => {
const P = window.PROCITY, C = P.citizens, D = window.DBG;
D.setSegment(2);
// Shops that currently hold patronage occupants occupancy is what puts BROWSERS on the floor (F // Shops that currently hold patronage occupants occupancy is what puts BROWSERS on the floor (F
// stands one per browse point per occupant). Not every room type HAS browse points (a market stall // stands one per browse point per occupant). Not every room type HAS browse points (a market stall
// has one, an op-shop three), so try several and keep the BEST rather than the first that works: // has one, an op-shop three), so several are tried and the BEST kept rather than the first that
// taking the first gave a one-browser stall on one run and a three-browser op-shop on the next. // works: taking the first gave a one-browser stall on one run and a three-browser op-shop on the next.
const cands = (P.plan.shops || []) return (P.plan.shops || [])
.map((s) => ({ id: s.id, name: s.name, type: s.type, count: C.occupancyOf(s.id).count })) .map((s) => ({ id: s.id, name: s.name, type: s.type, count: C.occupancyOf(s.id).count }))
.filter((s) => s.count > 0).sort((a, b) => b.count - a.count); .filter((s) => s.count > 0).sort((a, b) => b.count - a.count);
if (!cands.length) return { ok: false, why: 'no shop has occupants yet' };
D.setSegment(2);
let best = null;
for (const s of cands.slice(0, 8)) {
D.enterShop(s.id);
await new Promise(r => setTimeout(r, 2600)); // room build + browser spawn + lazy browse.glb
const room = P.interiorMode.current;
const n = room ? (room.browsePoints || []).length : 0;
const browsers = ((P.interiorMode.keepers || {}).keepers || []).filter((k) => k.browse).length;
if (room && n && browsers && (!best || browsers > best.browsers)) best = { shop: s, browsePoints: n, browsers };
if (best && best.browsers >= 3) break;
D.exitShop();
await new Promise(r => setTimeout(r, 500));
}
if (!best) return { ok: false, why: 'no occupied shop had browse points with browsers', cands: cands.slice(0, 6) };
D.enterShop(best.shop.id); // re-enter the winner
await new Promise(r => setTimeout(r, 2600));
return { ok: true, ...best, tried: cands.length };
} }
""" """
INT_TRY = r"""
async (id) => {
const P = window.PROCITY, D = window.DBG, C = P.citizens;
D.enterShop(id);
await new Promise(r => setTimeout(r, 2600)); // room build + browser spawn + lazy browse.glb
const room = P.interiorMode.current;
return { id, inside: !!room,
browsePoints: room ? (room.browsePoints || []).length : 0,
browsers: ((P.interiorMode.keepers || {}).keepers || []).filter((k) => k.browse).length,
occupants: C.occupancyOf(id).count };
}
"""
INT_LEAVE = "async () => { window.DBG.exitShop(); await new Promise(r => setTimeout(r, 500)); }"
OCC_OF = "(id) => window.PROCITY.citizens.occupancyOf(id).count"
INT_FRAME = r""" INT_FRAME = r"""
() => { () => {
const P = window.PROCITY, V3 = P.scene.position.constructor; const P = window.PROCITY, V3 = P.scene.position.constructor;
@ -372,31 +389,49 @@ def main():
print(cap) print(cap)
if not ok: rc = 1 if not ok: rc = 1
# ── shot 2: a browsing interior ─────────────────────────────────────────────────────── # ── shot 2: a browsing interior (shot from inside — see INT_CANDS) ────────────────────
info = pg.evaluate(INTERIOR) cands = pg.evaluate(INT_CANDS)
print('interior:', json.dumps(info)) print(f'interior: {len(cands)} occupied shops; scanning up to 8, shooting in place')
if not info.get('ok'): best, tried = None, []
print(' skipped interior shot'); rc = 1 for s in cands[:8]:
else: t = pg.evaluate(INT_TRY, s['id'])
tried.append({**s, **{k: t[k] for k in ('browsers', 'browsePoints', 'occupants')}})
print(f" {s['name'][:28]:<28} ({s['type']:<8}) occ {t['occupants']}/{s['count']} · "
f"{t['browsePoints']} browse points · {t['browsers']} browsers")
if t['inside'] and t['browsePoints'] and t['browsers'] and (not best or t['browsers'] > best['browsers']):
fr = pg.evaluate(INT_FRAME) fr = pg.evaluate(INT_FRAME)
print('frame:', json.dumps(fr))
if fr.get('ok'): if fr.get('ok'):
im = pg.evaluate(INT_MEASURE) im = pg.evaluate(INT_MEASURE)
pg.screenshot(path=str(OUTDIR / 'r41_browse_interior.jpg'), type='jpeg', quality=90)
best = {**t, 'shop': s, 'frame': fr, 'meas': im}
print(f" → shot in place: {fr['figures']} figures, {fr['inFrame']} in frame, "
f"{fr['draws']} draws")
if best and best['browsers'] >= 3: break
pg.evaluate(INT_LEAVE)
if not best:
print(f' no occupied shop had browse points with browsers; tried {tried[:6]}'); rc = 1
else:
# The R41 bug, measured rather than asserted: what the OLD scan-then-re-enter path would
# have walked back into. If this has decayed toward 0 the arm was red for exactly that
# reason, and shooting in place is what fixed it.
stale = pg.evaluate(OCC_OF, best['shop']['id'])
fr, im = best['frame'], best['meas']
browsers = [r for r in im['rows'] if r['kind'] == 'browser'] browsers = [r for r in im['rows'] if r['kind'] == 'browser']
line, ok = human_line(im['rows']) line, ok = human_line(im['rows'])
name = 'r41_browse_interior.jpg' name = 'r41_browse_interior.jpg'
pg.screenshot(path=str(OUTDIR / name), type='jpeg', quality=90)
cap = (f"PROCITY R41 §41.3 — {name}\n" cap = (f"PROCITY R41 §41.3 — {name}\n"
f"seed {SEED} · {info['shop']['name']} ({info['shop']['type']}) · " f"seed {SEED} · {best['shop']['name']} ({best['shop']['type']}) · "
f"{info['shop']['count']} patronage occupants · {info['browsePoints']} browse points\n" f"{best['occupants']} patronage occupants at entry · {best['browsePoints']} browse points · "
f"{len(browsers)} browsers\n"
f"shot IN PLACE (R42 §42.4): the frame is taken inside the winning room during the "
f"scan, never on a re-entry. Occupancy of that shop at entry {best['occupants']}"
f"{stale} by the end of the scan — the decay the old re-entry path walked into.\n"
f"figures: {json.dumps(im['rows'])}\n" f"figures: {json.dumps(im['rows'])}\n"
f"draws {fr['draws']} / interior budget 350 · tris {fr['tris']}\n" f"draws {fr['draws']} / interior budget 350 · tris {fr['tris']}\n"
f"{line}\n") f"{line}\n")
(OUTDIR / name.replace('.jpg', '.txt')).write_text(cap) (OUTDIR / name.replace('.jpg', '.txt')).write_text(cap)
print(cap) print(cap)
if not ok or not browsers: rc = 1 if not ok or not browsers: rc = 1
else:
rc = 1
if errs: if errs:
print('CONSOLE ERRORS:', errs[:6]); rc = 1 print('CONSOLE ERRORS:', errs[:6]); rc = 1
b.close() b.close()

198
tools/qa/r42_budget.py Normal file
View File

@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""PROCITY Lane F — R42 §42.6 · THE INTERIOR BUDGET, WITH THE INSTRUMENT PINNED (Lane C's §42 ask).
tools/.venv/bin/python tools/qa/r42_budget.py
WHY THIS FILE EXISTS. R41 closed with a two-round-old argument settled the wrong way round: C
published the worst interior as **122** draws and F measured **123** on two instruments, and F wrote
it off as "C's measurement environment". C found the actual cause in §42.2b and it is neither lane's
bookkeeping **it is the CAMERA ASPECT**. `renderer.info.render.calls` counts what survives frustum
culling; the sweep camera holds a fixed VERTICAL fov (70) and inherits its aspect from the window, so
a wider window puts more room in frame and issues more draws:
1280x1024 aspect 1.250 -> 116 1280x800 1.600 -> 122 1280x720 1.778 -> 123
Both numbers were honest readings of one tree through two windows. **123 is the law's number**,
because the law must be the worst a player can produce and a player's window is wide.
C's filing to F was exact: *"set `S.camera.aspect` explicitly inside SWEEP rather than inheriting the
viewport, so the gate cannot drift when someone changes `new_page`."* That is what this does.
`r41_integration.py` arm 2 happens to be right today only because its `new_page` is 1280x720, which
IS 16:9 a coincidence one line in an unrelated helper can end. Here the aspect is a constant in the
measurement, the viewport is irrelevant, and the sweep ships an ASPECT CONTROL that reproduces C's
1.25 reading on purpose, so "the instrument is pinned" is a measurement rather than a comment.
ARMS
1. GLB off / GLB on / GLB on + ?stock=real, aspect pinned 16:9 the law is worst 350.
2. CONTROL (aspect): the identical sweep at aspect 1.25 must read LOWER, and by C's own margin.
3. CONTROL (viewport): the 16:9 sweep run in a 1280x1024 window must return the SAME number as the
one run in a 1280x720 window which is the actual claim "the instrument no longer drifts".
"""
import sys, os, json, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = int(os.environ.get('PROCITY_R42_BUDGET_PORT', '8986'))
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
DRAW_LAW = 350
fails = []
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")
def note(m): print(f" \033[33m·\033[0m {m}")
def check(c, m): (OK if c else FAIL)(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()
'''
# F's own sweep — the same scene bookkeeping as R41 arm 2 (strip EVERY interior first, including the
# page's own `current`; that omission is what carried a phantom second room through R39/R40) — with
# ONE change: the camera's aspect is set from an argument and restored afterwards, so the number is
# a property of the tree and not of the window the harness happened to open.
SWEEP = r"""
async ({ glb, stock, aspect }) => {
const S = window.PROCITY_C, THREE = S.THREE;
const strip = () => { for (const o of S.scene.children.filter(o => o.userData && o.userData.kind === 'interior')) S.scene.remove(o); };
strip();
let manifest = null;
if (glb) manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null);
const inherited = S.camera.aspect;
const measure = async (type, arch) => {
const r = S.buildInterior({ id: type, type, seed: 1990, storeys: 1 }, THREE,
{ archetype: arch, useGLB: glb, manifest,
stock: stock ? 'real' : undefined,
stockBase: (stock && type === 'opshop') ? S.WARDROBE_BASE : undefined });
S.scene.add(r.group); if (glb) await r.glbReady;
S.camera.position.set(r.dims.W * 0.3, r.dims.H * 0.6, r.dims.D / 2 - 0.8);
S.camera.lookAt(0, 0.9, -r.dims.D * 0.15);
// THE PIN. A fixed vertical fov + an inherited aspect means the frustum is a function of the
// window; set it here and the sweep measures the tree instead of the browser.
S.camera.aspect = aspect; S.camera.updateProjectionMatrix();
S.renderer.info.reset(); S.renderer.render(S.scene, S.camera);
const d = S.renderer.info.render.calls;
S.scene.remove(r.group); r.dispose();
return d;
};
let worst = 0, worstAt = '', rooms = 0; const perType = {};
for (const t of S.SHOP_TYPES) {
let tw = 0;
for (const a of [undefined, ...S.ARCHETYPE_KEYS]) {
const d = await measure(t, a); rooms++;
if (d > tw) tw = d;
if (d > worst) { worst = d; worstAt = `${t}/${a || 'auto'}`; }
}
perType[t] = tw;
}
S.camera.aspect = inherited; S.camera.updateProjectionMatrix();
S.rebuild();
return { worst, worstAt, rooms, perType, inheritedAspect: +inherited.toFixed(3), pinnedAspect: aspect };
}
"""
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():
p = 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): return p
time.sleep(0.1)
p.terminate(); raise SystemExit(f'could not serve on :{PORT}')
def open_test(p, w, h):
b = p.chromium.launch()
pg = b.new_page(viewport={'width': w, 'height': h})
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)))
pg.goto(f'{HOST}/interior_test.html?seed={SEED}&stock=real')
pg.wait_for_function('window.PROCITY_C && window.PROCITY_C.SHOP_TYPES', timeout=45000)
pg.wait_for_timeout(1500) # the page's own stock preloads
return b, pg, errs
def main():
srv = serve()
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
head(f'1. THE INTERIOR LAW — F\'s own sweep, 12 types x 6 archetypes, aspect PINNED 16:9, law ≤{DRAW_LAW}')
b, pg, errs = open_test(p, 1280, 720)
res = {}
try:
for label, opts in (('GLB off', dict(glb=False, stock=False, aspect=16 / 9)),
('GLB on', dict(glb=True, stock=False, aspect=16 / 9)),
('GLB on + stock=real', dict(glb=True, stock=True, aspect=16 / 9))):
r = pg.evaluate(SWEEP, opts)
res[label] = r
note(f"{label}: worst {r['worst']} @ {r['worstAt']} over {r['rooms']} rooms "
f"(margin {DRAW_LAW - r['worst']}) · per type {json.dumps(r['perType'])}")
check(r['worst'] <= DRAW_LAW,
f'{label}: worst room {r["worst"]}{DRAW_LAW} — MARGIN {DRAW_LAW - r["worst"]}')
on = res['GLB on']
check(on['worstAt'].startswith('dept'),
f"the worst room is a dept room ({on['worstAt']}) — C's headline, on F's instrument")
head('2. CONTROL (aspect) — the identical sweep at 1.25, which must read LOWER')
narrow = pg.evaluate(SWEEP, dict(glb=True, stock=False, aspect=1.25))
note(f"aspect 1.250: worst {narrow['worst']} @ {narrow['worstAt']} · "
f"aspect 1.778: worst {on['worst']} @ {on['worstAt']}")
check(narrow['worst'] < on['worst'],
f"a narrower frustum draws less ({narrow['worst']} < {on['worst']}) — the sweep is "
f"aspect-sensitive, which is exactly why it has to be pinned. This IS the 122-vs-123 "
f"argument, reproduced on demand instead of relitigated.")
check(narrow['worst'] == 116,
f"…and it reproduces Lane C's published control value exactly: {narrow['worst']} = 116 "
f"@ aspect 1.25 (LANE_C_NOTES §42)")
check(not errs, f'0 console errors over {sum(r["rooms"] for r in res.values()) + narrow["rooms"]} room builds')
finally:
b.close()
head('3. CONTROL (viewport) — the SAME pinned sweep in a 1280x1024 window must agree')
b2, pg2, errs2 = open_test(p, 1280, 1024)
try:
other = pg2.evaluate(SWEEP, dict(glb=True, stock=False, aspect=16 / 9))
note(f"window 1280x1024 (inherited aspect {other['inheritedAspect']}): "
f"worst {other['worst']} @ {other['worstAt']}")
check(other['worst'] == res['GLB on']['worst'] and other['worstAt'] == res['GLB on']['worstAt'],
f"a window that would have read {other['inheritedAspect']} reads "
f"{other['worst']} @ {other['worstAt']} — identical to the 1280x720 run. The number is "
f"now a property of the TREE. Unpinned, this same window read 116 in C's table.")
check(not errs2, '0 console errors on the viewport-control run')
print('\n' + json.dumps({'law': DRAW_LAW, 'aspect': '16:9',
'glb_off': res['GLB off']['worst'],
'glb_on': res['GLB on']['worst'],
'glb_on_at': res['GLB on']['worstAt'],
'glb_on_stock_real': res['GLB on + stock=real']['worst'],
'margin': DRAW_LAW - res['GLB on']['worst'],
'control_aspect_125': narrow['worst'],
'control_viewport_1280x1024': other['worst']}, indent=1))
finally:
b2.close()
finally:
srv.terminate()
print('')
if fails:
print(f'\033[31m● FAIL\033[0m — {len(fails)} problem(s):')
for f in fails: print(f' · {f}')
return 1
print('\033[32m● PASS\033[0m — the interior law holds with the instrument pinned, and both controls fired')
return 0
if __name__ == '__main__':
sys.exit(main())

622
tools/qa/r42_cast.py Normal file
View File

@ -0,0 +1,622 @@
#!/usr/bin/env python3
"""PROCITY Lane D — R42 §42.4 gate: THE RECAST, WIRED.
tools/.venv/bin/python tools/qa/r42_cast.py [--seed N] [--outdir DIR] 0 green · 1 red
Ruling 1 (PROCITY is 1990s Australia) retired five ped bodies; Lane E sourced five replacements and
this gate is the proof that swapping them into `rigs.js` changed the CAST and nothing else. Six arms,
each with the control that makes it non-vacuous, every number measured on this tree.
1. CONTRACT the five replacement GLBs read off their own bytes nodes / skin joints /
primitives / materials / images / mime / extensions / baked animations / tris /
bone-name set after `_canon` and then the test that actually matters: replay
rigs.js's OWN track filter (canonicalise, keep `.quaternion`, drop
`Hips.quaternion`) over all 54 shipped clips and count bindable tracks against
the reference ped. CONTROL: `comical_boy_01` (an outgoing reject, still on disk)
must score STRICTLY LOWER otherwise the replay cannot tell a good ped from a
bad one. It scores 3 132/3 460, because six of its thumb bones lost the
`mixamorig:` prefix, so the instrument discriminates on a real defect.
2. ROSTER fresh default and `?classic=1` contexts: the live fleet's names BY INDEX, the
three pool lengths, and a NETWORK sweep of every GLB the boot fetched. None of
the five retired bodies may be in the fleet or on the wire. CONTROL: the five
replacements are present, at the exact indices the retired five held.
3. DETERMINISM two fresh contexts, same seed: byte-equal IDENTITY signature (which is where
`pedIndex` lives) and byte-equal POSTURE signature over the whole active crowd.
CONTROL: a different seed differs. This is the R2 fleet-order bug's gate — an
in-place swap must leave every index where it was.
4. DRAWS ruling 4 gives the street nine draws. Measured per bookmark, and measured so the
crowd's wall-clock drift cannot forge the answer: the citizens group is hidden
and the frame re-rendered, so the CITIZEN CONTRIBUTION is a difference, not a
total. Asserts one draw per near rig plus one for the entire mid crowd.
5. ANIMATION each replacement spawned BY NAME through the real `makeActor` path, playing walk
and then idle: bound-track count, the mixer advancing, the skeleton actually
moving, and the R16 flat-body trap checked as spine tilt (head above hips) and
stature held across the clip. CONTROL: the reference ped runs the same course.
6. IMPOSTOR the mid-tier billboard against its own near-tier rig at the swap boundary the
R2 failure (a 1.67x exposure mismatch from a double-tone-mapped impostor). Three
screenshots per subject (empty / rig / billboard) through the shell's real
composer; ped pixels are the ones that differ from empty. CONTROL: the ratio is
compared to a SHIPPED ped's ratio, so the claim is "as matched as it ever was"
rather than an invented tolerance.
Fresh headless contexts, own no-store server (this project's documented ES-module cache burn).
"""
import sys, os, re, io, json, time, math, struct, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = int(os.environ.get('PROCITY_R42_PORT', '8992'))
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
OUTDIR = ROOT / 'docs' / 'shots' / 'laneD'
if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1])
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
PEDS = ROOT / 'web' / 'models' / 'peds'
CLIPS = ROOT / 'web' / 'models' / 'clips'
REF = 'man_casual_01'
BASE_CLIPS = ['walk', 'idle', 'sit', 'look', 'dance_party', 'dance_medium', 'dance_drink', 'dance_sway']
# the swap, as wired in web/js/citizens/rigs.js — pool, index, out, in
SWAP = [('normal', 6, 'man_elder_01', 'man_casual_04'),
('normal', 9, 'man_soldier_ww2_01', 'person_casual_01'),
('djs', 4, 'dj_phrtt_01', 'man_smart_01'),
('comical', 0, 'comical_luchador_01', 'woman_smart_02'),
('comical', 1, 'comical_boy_01', 'person_youth_01')]
RETIRED = [o for _, _, o, _ in SWAP]
NEW = [n for _, _, _, n in SWAP]
BAD_CONTROL = 'comical_boy_01' # on disk, retired, and measurably non-contract (thumb bones)
POOL_LEN = {'normal': 17, 'djs': 5, 'comical': 2}
BOOKMARKS = ['street_noon', 'crossroads_busy', 'night_crowd', 'night_neon', 'district_posters',
'venue_night', 'market_square', 'patronage_door']
fails = []
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")
def note(m): print(f" \033[33m·\033[0m {m}")
def check(c, m): (OK if c else FAIL)(m); return c
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()
'''
# ══ ARM 1: the contract, read off the GLB bytes (no browser, no deps) ═══════════════════════════════
_canon = lambda s: re.sub(r'mixamorig\d+', 'mixamorig', s or '')
# glTF animation channel path -> the suffix three's GLTFLoader gives the track. rigs.js `_rotOnly`
# filters on `.quaternion`, which is three's name for glTF `rotation` — comparing against the raw
# glTF path finds nothing at all and silently "passes".
_PATH = {'rotation': 'quaternion', 'translation': 'position', 'scale': 'scale'}
def gltf(path):
d = path.read_bytes()
if d[:4] != b'glTF': raise ValueError(f'{path} is not a GLB')
n = struct.unpack('<I', d[12:16])[0]
return json.loads(d[20:20 + n]), len(d)
def ped_facts(name):
j, size = gltf(PEDS / (name + '.glb'))
names = [n.get('name', '') for n in j.get('nodes', [])]
joints = j['skins'][0]['joints'] if j.get('skins') else []
tris = sum(j['accessors'][p['indices']]['count'] // 3
for m in j.get('meshes', []) for p in m['primitives'] if 'indices' in p)
return dict(name=name, bytes=size, nodes=len(names), joints=len(joints),
jnames=sorted(_canon(names[i]) for i in joints),
prims=sum(len(m.get('primitives', [])) for m in j.get('meshes', [])),
mats=len(j.get('materials', [])), imgs=len(j.get('images', [])),
mime=[i.get('mimeType') for i in j.get('images', [])],
ext=j.get('extensionsUsed', []), anims=len(j.get('animations', [])), tris=tris,
nodeSet=set(_canon(x) for x in names))
def clip_corpus():
"""Every clip the fleet can bind: the 8 base ped clips + Lane E's 46-clip library."""
out = []
for c in BASE_CLIPS:
p = PEDS / (c + '.glb')
if p.exists(): out += [(c + ':' + nm, tr) for nm, tr in _tracks(p)]
for p in sorted(CLIPS.glob('*.glb')):
out += [(p.stem + ':' + nm, tr) for nm, tr in _tracks(p)]
return out
def _tracks(path):
j, _ = gltf(path)
names = [n.get('name', '') for n in j.get('nodes', [])]
res = []
for a in j.get('animations', []):
tr = [f"{_canon(names[c['target']['node']])}.{_PATH.get(c['target']['path'], c['target']['path'])}"
for c in a.get('channels', []) if 'node' in c.get('target', {})]
res.append((a.get('name', ''), tr))
return res
def bind_score(ped, corpus):
"""rigs.js's own filter, replayed: canon (done) -> keep .quaternion -> drop Hips.quaternion."""
tot = bound = 0; worst = None; unb = set()
for nm, tr in corpus:
keep = [t for t in tr if t.endswith('.quaternion') and not re.search(r'Hips\.quaternion$', t, re.I)]
b = sum(1 for t in keep if t.rsplit('.', 1)[0] in ped['nodeSet'])
unb |= {t.rsplit('.', 1)[0] for t in keep if t.rsplit('.', 1)[0] not in ped['nodeSet']}
tot += len(keep); bound += b
if worst is None or b < worst[1]: worst = (nm, b)
return tot, bound, worst, sorted(unb)
def arm_contract():
head('1. CONTRACT — the five replacements measured off their own GLB bytes')
ref = ped_facts(REF)
corpus = clip_corpus()
rt, rb, rw, ru = bind_score(ref, corpus)
note(f"reference {REF}: {ref['nodes']} nodes / {ref['joints']} joints / {ref['prims']} prim / "
f"{ref['mats']} mtl / {ref['imgs']} img {ref['mime']} / ext {ref['ext']} / {ref['anims']} anims / "
f"{ref['tris']} tris")
note(f"clip corpus: {len(corpus)} clips ({len(BASE_CLIPS)} base + {len(list(CLIPS.glob('*.glb')))} "
f"library GLBs) → reference binds {rb}/{rt}, worst single clip {rw[1]}, unbound {ru}")
rows = []
for n in NEW:
p = PEDS / (n + '.glb')
if not p.exists():
FAIL(f'{n}.glb is not on the tree'); continue
f = ped_facts(n)
t, b, w, u = bind_score(f, corpus)
rows.append((f, t, b, w))
same = f['jnames'] == ref['jnames']
shape = (f['nodes'] == ref['nodes'] and f['joints'] == ref['joints'] and f['prims'] == 1
and f['mats'] == 1 and f['imgs'] == 1 and f['mime'] == ref['mime']
and f['anims'] == 0 and 'KHR_draco_mesh_compression' not in f['ext'])
check(shape and same and (b, t) == (rb, rt) and w[1] == rw[1],
f"{n}: {f['nodes']}/{f['joints']} nodes/joints · {f['prims']}/{f['mats']}/{f['imgs']} "
f"prim/mtl/img {f['mime'][0] if f['mime'] else '-'} · {f['anims']} baked anims · "
f"{f['tris']} tris · boneset {'IDENTICAL' if same else 'DIFFERENT'} · binds {b}/{t} "
f"(worst clip {w[1]}) — {'contract-clean' if shape and same else 'DEVIATES'}")
# CONTROL: the instrument must be able to fail
if (PEDS / (BAD_CONTROL + '.glb')).exists():
bf = ped_facts(BAD_CONTROL)
bt, bb, bw, bu = bind_score(bf, corpus)
check(bb < rb, f"CONTROL: retired {BAD_CONTROL} scores {bb}/{bt} (worst clip {bw[1]}) — STRICTLY "
f"LOWER than the reference's {rb}/{rt}. Unbound: {bu[:3]}… so the replay "
f"discriminates; it was a real, silent contract break on a shipped ped.")
else:
note(f'{BAD_CONTROL}.glb not on disk — bind-score control unavailable')
if rows:
note('tri band ' + ' · '.join(f"{f['name']} {f['tris']}" for f, *_ in rows))
return {'ref': {k: ref[k] for k in ('nodes', 'joints', 'prims', 'mats', 'imgs', 'tris')},
'refBind': [rb, rt], 'rows': [{'name': f['name'], 'tris': f['tris'], 'bind': [b, t]}
for f, t, b, w in rows]}
# ══ browser plumbing ════════════════════════════════════════════════════════════════════════════════
def port_up(p):
with socket.socket() as s:
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', p)) == 0
def serve():
pr = 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): return pr
time.sleep(0.1)
pr.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, reqs = [], []
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
pg.on('pageerror', lambda e: errs.append(str(e)))
pg.on('request', lambda r: reqs.append(r.url))
return b, pg, errs, reqs
def boot(pg, q='', fleet=True):
pg.goto(f'{HOST}/index.html?seed={SEED}' + (('&' + q) if q else '') + '&dbg=1')
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
if fleet:
pg.wait_for_function('() => window.PROCITY.fleet && window.PROCITY.fleet.ready', timeout=25000)
ROSTER = """() => { const f = window.PROCITY.fleet;
return { normal: f.normal.map(r => r.pedName), comical: f.comical.map(r => r.pedName),
all: f.all.map(r => r.pedName) }; }"""
# max draws over n natural frames, then the same frame with the citizens hidden: the difference IS the
# crowd's draw cost, and it is immune to the wall-clock drift that makes raw totals wobble +-3.
DRAW_SPLIT = r"""
async (n) => {
const P = window.PROCITY, C = P.citizens;
let mx = 0, mt = 0, near = 0, mid = 0;
for (let i = 0; i < n; i++) {
await new Promise(r => requestAnimationFrame(() => r()));
const r = P.renderer.info.render;
if (r.calls > mx) { mx = r.calls; mt = r.triangles; near = C.stats.rigged; mid = C.stats.mid; }
}
// measure the same pose twice more: crowd in, crowd out. Nothing else changes between them.
const frame = async () => { await new Promise(r => requestAnimationFrame(() => r()));
return P.renderer.info.render.calls; };
const withCrowd = await frame();
const nearNow = C.stats.rigged, midNow = C.stats.mid;
C.group.visible = false;
const without = await frame();
C.group.visible = true;
await frame();
return { max: mx, tris: mt, nearAtMax: near, midAtMax: mid,
withCrowd, without, near: nearNow, mid: midNow,
budget: (P.budget || {}).draws || 300 };
}
"""
# each replacement spawned by name through the real makeActor path and actually animated
ANIM = r"""
async (names) => {
const P = window.PROCITY, THREE = P.THREE, fleet = P.fleet;
const rigs = await import('./js/citizens/rigs.js');
const out = [];
for (const name of names) {
const rig = fleet.all.find(r => r.pedName === name);
if (!rig) { out.push({ name, missing: true }); continue; }
const a = rigs.makeActor(rig, { walkClip: fleet.walkClip, idleClip: fleet.idleClip, nominalHeight: 1.75 });
if (!a) { out.push({ name, noActor: true }); continue; }
const stage = new THREE.Group(); stage.add(a.fig); P.scene.add(stage);
const bones = []; a.inner.traverse(o => { if (o.isBone) bones.push(o); });
const v = new THREE.Vector3();
const sample = () => { a.fig.updateWorldMatrix(true, true);
let crown = 0, foot = Infinity, hips = null, head = null, pts = [];
for (const o of bones) { o.getWorldPosition(v);
if (/head/i.test(o.name)) crown = Math.max(crown, v.y);
if (/Hips$/i.test(o.name)) hips = v.y;
if (/Head$/i.test(o.name) && head === null) head = v.y;
foot = Math.min(foot, v.y); pts.push(v.x, v.y, v.z); }
return { crown, foot, hips, head, stature: crown - foot, pts };
};
const legs = [];
for (const [mode, moving] of [['walk', true], ['idle', false]]) {
a.setMoving(moving, 0);
a.mixer.update(0.001);
const s0 = sample();
let travel = 0, minStat = s0.stature, maxStat = s0.stature, minTilt = 1e9;
const act = (a.mixer._actions || []).filter(x => x.getEffectiveWeight() > 0.5)[0] || null;
const t0 = act ? act.time : null;
let prev = s0.pts;
for (let k = 0; k < 24; k++) {
a.mixer.update(1 / 30);
const s = sample();
let d = 0; for (let i = 0; i < prev.length; i++) d += Math.abs(s.pts[i] - prev[i]);
travel += d; prev = s.pts;
minStat = Math.min(minStat, s.stature); maxStat = Math.max(maxStat, s.stature);
if (s.hips != null && s.head != null) minTilt = Math.min(minTilt, s.head - s.hips);
}
const tEnd = act ? act.time : null;
legs.push({ mode, clip: act ? act.getClip().name : null,
tracks: act ? act.getClip().tracks.length : 0,
advanced: (t0 != null && tEnd != null) ? +(tEnd - t0).toFixed(3) : null,
travel: +travel.toFixed(3), minStature: +minStat.toFixed(3),
maxStature: +maxStat.toFixed(3), minHeadAboveHips: +minTilt.toFixed(3) });
}
out.push({ name, height: a.height || 1.75, legs });
P.scene.remove(stage); a.dispose && a.dispose();
}
return out;
}
"""
# stage one subject at a fixed spot in front of the camera, as a near RIG or as a mid BILLBOARD, using
# the LIVE atlas the sim baked. Rendered by the shell's own composer, so the tone-map path is the real one.
IMPOSTOR_STAGE = r"""
async ({ name, kind }) => {
const P = window.PROCITY, THREE = P.THREE, C = P.citizens, fleet = P.fleet;
const rigs = await import('./js/citizens/rigs.js');
const impM = await import('./js/citizens/impostor.js');
if (window.__d42) { P.scene.remove(window.__d42.stage);
if (window.__d42.layer) { window.__d42.layer.mesh.geometry.dispose(); window.__d42.layer.material.dispose(); }
if (window.__d42.actor && window.__d42.actor.dispose) window.__d42.actor.dispose();
window.__d42 = null; }
C.group.visible = (kind === 'crowd');
if (kind === 'empty' || kind === 'crowd') return { ok: true, kind };
const idx = fleet.all.findIndex(r => r.pedName === name);
if (idx < 0) return { ok: false, why: 'not in fleet' };
const cam = P.camera; cam.updateMatrixWorld(true);
const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(cam.quaternion); fwd.y = 0; fwd.normalize();
const D = 7.0, H = 1.75;
const x = cam.position.x + fwd.x * D, z = cam.position.z + fwd.z * D;
const groundY = cam.position.y - 1.62; // DBG's eye height above the deck
const facing = Math.atan2(-(cam.position.x - x), -(cam.position.z - z)); // front (-Z) toward the lens
const stage = new THREE.Group(); P.scene.add(stage);
const rec = { stage, layer: null, actor: null };
if (kind === 'rig') {
const a = rigs.makeActor(fleet.all[idx], { walkClip: fleet.walkClip, idleClip: fleet.idleClip, nominalHeight: H });
a.setMoving(true, 0);
a.mixer.update(0.35 + idx * 0.017); // the EXACT pose sim.js bakes into the atlas
a.fig.position.set(x, groundY, z); a.fig.rotation.y = facing;
stage.add(a.fig); rec.actor = a;
} else {
const layer = new impM.ImpostorLayer(C.impostor.atlas, { maxInstances: 2 });
layer.mesh.position.copy(C.group.position); // the live layer rides the citizens group
stage.add(layer.mesh);
layer.update([{ x, z, groundY, height: H, subject: idx, facing }], cam);
rec.layer = layer;
}
window.__d42 = rec;
// The measurement BOX: the billboard's own quad (h*1.14 square, feet at -0.07h — impostor.js's own
// framing constants) projected to screen. Cropping to it is what keeps a chunk streaming in at the
// far end of the street out of a luminance number about one ped; an unbounded full-frame difference
// reported up to 68 000 "ped" pixels when a distant block popped between two shots.
const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), fwd).normalize();
const half = H * 1.14 * 0.5, W = window.innerWidth, Hp = window.innerHeight;
let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
for (const sx of [-1, 1]) for (const sy of [0, 1]) {
const w = new THREE.Vector3(x + right.x * half * sx, groundY - H * 0.07 + H * 1.14 * sy,
z + right.z * half * sx).project(cam);
const px = (w.x * 0.5 + 0.5) * W, py = (1 - (w.y * 0.5 + 0.5)) * Hp;
x0 = Math.min(x0, px); x1 = Math.max(x1, px); y0 = Math.min(y0, py); y1 = Math.max(y1, py);
}
const M = 10;
return { ok: true, kind, idx, box: [Math.max(0, Math.floor(x0 - M)), Math.max(0, Math.floor(y0 - M)),
Math.min(W, Math.ceil(x1 + M)), Math.min(Hp, Math.ceil(y1 + M))] };
}
"""
def crop_stats(png_a, png_b, box):
"""mean luminance of the pixels in `box` where b differs from a — i.e. the staged subject only.
Differencing against an EMPTY frame is what makes this a measurement of the ped rather than of
the street behind it: the background is identical in both shots, so every surviving pixel belongs
to the thing that was staged."""
from PIL import Image, ImageChops, ImageStat
a = Image.open(io.BytesIO(png_a)).convert('RGB').crop(box)
b = Image.open(io.BytesIO(png_b)).convert('RGB').crop(box)
d = ImageChops.difference(a, b).convert('L').point(lambda v: 255 if v > 8 else 0)
st = ImageStat.Stat(b.convert('L'), d)
n = st.count[0]
return (st.mean[0] if n else 0.0), n, b, d
def main():
OUTDIR.mkdir(parents=True, exist_ok=True)
from playwright.sync_api import sync_playwright
report = {'seed': SEED}
report['contract'] = arm_contract()
srv = serve()
try:
with sync_playwright() as p:
# ── 2. roster + period law ────────────────────────────────────────────────────────────
head('2. ROSTER — the swap is wired, in place, and nothing retired is on the wire')
b, pg, errs, reqs = new_page(p)
boot(pg)
pg.wait_for_timeout(2500)
r = pg.evaluate(ROSTER)
fetched = sorted({u.rsplit('/', 1)[-1].split('?')[0] for u in reqs
if '/models/peds/' in u and u.endswith('.glb')})
report['roster'] = {'default': r, 'fetched': fetched}
live = set(r['all'])
check(not (live & set(RETIRED)),
f"default boot fleet ({len(r['all'])} bodies) holds NONE of the five retired: "
f"{', '.join(RETIRED)}")
onwire = sorted(set(RETIRED) & {f[:-4] for f in fetched})
check(not onwire,
f'default boot fetched {len(fetched)} ped GLBs and none of them is a retired body '
f'(dj_phrtt_01 on the wire: {"YES" if "dj_phrtt_01.glb" in fetched else "no"})')
check(all(n in live for n in NEW), f"all five replacements present: {', '.join(NEW)}")
# `fleet.normal` is PED_NAMES.normal ++ PED_NAMES.djs (R36 w2.5 appends the gated tail),
# and `fleet.all` is that ++ comical — so the three source pools are slices of the live one.
pools = {'normal': r['normal'][:POOL_LEN['normal']],
'djs': r['normal'][POOL_LEN['normal']:],
'comical': r['comical']}
for pool, i, out, inn in SWAP:
lst = pools[pool]
got = lst[i] if len(lst) > i else None
allIdx = r['all'].index(got) if got in r['all'] else None
check(got == inn, f"{pool}[{i}] (fleet.all[{allIdx}]): {out}{got} (expected {inn})")
check(len(r['normal']) == POOL_LEN['normal'] + POOL_LEN['djs'] and
len(r['comical']) == POOL_LEN['comical'] and len(r['all']) == 24,
f"pool lengths unmoved: normal+djs {len(r['normal'])} (17+5) · comical "
f"{len(r['comical'])} · fleet.all {len(r['all'])} — every pickRig index is where it was")
check(not errs, f'default boot: 0 console errors ({len(reqs)} requests swept)')
b.close()
b, pg, errs, reqs = new_page(p)
boot(pg, 'classic=1')
pg.wait_for_timeout(2500)
rc = pg.evaluate(ROSTER)
cf = sorted({u.rsplit('/', 1)[-1].split('?')[0] for u in reqs
if '/models/peds/' in u and u.endswith('.glb')})
report['roster']['classic'] = {'roster': rc, 'fetched': cf}
check(len(rc['normal']) == POOL_LEN['normal'] and not (set(rc['all']) & set(RETIRED)),
f"?classic=1: covenanted {len(rc['normal'])}-body pool, no retired body "
f"({len(rc['all'])} in fleet.all)")
check('dj_phrtt_01.glb' not in cf and not (set(RETIRED) & {f[:-4] for f in cf}),
f'?classic=1 fetched {len(cf)} ped GLBs, none retired')
check(not errs, '?classic=1: 0 console errors')
b.close()
# ── 3. determinism ────────────────────────────────────────────────────────────────────
head('3. DETERMINISM — same seed → same crowd, across fresh contexts (the R2 gate)')
sigs = []
for _ in range(2):
b, pg, errs, reqs = new_page(p)
boot(pg)
pg.evaluate("() => window.DBG.shot('street_noon')")
pg.wait_for_timeout(3000)
sigs.append((pg.evaluate("() => window.PROCITY.citizens.identitySignature().join('\\n')"),
pg.evaluate("() => window.PROCITY.citizens.postureSignature().join('\\n')")))
b.close()
n = len(sigs[0][0].splitlines())
check(sigs[0][0] == sigs[1][0] and n > 0,
f'{n} active citizens, two fresh contexts → byte-equal IDENTITY signature '
f'(id:pedIndex:pvar:height:speed:edge:forward)')
check(sigs[0][1] == sigs[1][1], 'and byte-equal POSTURE signature')
report['determinism'] = {'n': n, 'idsig_equal': sigs[0][0] == sigs[1][0],
'psig_equal': sigs[0][1] == sigs[1][1]}
b, pg, errs, reqs = new_page(p)
pg.goto(f'{HOST}/index.html?seed={SEED + 1}&dbg=1')
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
pg.evaluate("() => window.DBG.shot('street_noon')")
pg.wait_for_timeout(3000)
other = pg.evaluate("() => window.PROCITY.citizens.identitySignature().join('\\n')")
check(other != sigs[0][0], f'CONTROL: seed {SEED + 1} gives a DIFFERENT identity signature '
f'({len(other.splitlines())} citizens) — byte-equal is not constant')
b.close()
# ── 4. draws ──────────────────────────────────────────────────────────────────────────
head('4. DRAWS — the crowd\'s cost measured as a difference, not a total (ruling 4)')
b, pg, errs, reqs = new_page(p)
boot(pg)
pg.wait_for_timeout(6000)
print(f" {'bookmark':<20} {'max':>5} {'budget':>7} {'crowd in':>9} {'crowd out':>10} "
f"{'Δ':>4} {'near':>5} {'mid':>4} {'expected':>9}")
dtab = {}
for bm in BOOKMARKS:
pg.evaluate('(n) => window.DBG.shot(n)', bm)
pg.wait_for_timeout(2500)
pg.evaluate('(n) => window.DBG.shot(n)', bm)
d = pg.evaluate(DRAW_SPLIT, 24)
dtab[bm] = d
exp = d['near'] + (1 if d['mid'] else 0)
got = d['withCrowd'] - d['without']
print(f" {bm:<20} {d['max']:>5} {d['budget']:>7} {d['withCrowd']:>9} {d['without']:>10} "
f"{got:>4} {d['near']:>5} {d['mid']:>4} {exp:>9}")
check(d['max'] <= d['budget'], f'{bm}: {d["max"]} draws ≤ budget {d["budget"]}')
check(got == exp,
f'{bm}: the whole crowd costs {got} draws = {d["near"]} near rigs × 1 + '
f'{1 if d["mid"] else 0} instanced billboard layer for {d["mid"]} mid citizens')
report['draws'] = dtab
note('one primitive / one material per ped (arm 1) ⇒ a one-for-one swap cannot move this '
'number; the totals wobble only with which citizens the wall clock has walked into frame')
b.close()
# ── 5. animation ──────────────────────────────────────────────────────────────────────
head('5. ANIMATION — every new body plays walk and idle, and neither folds it')
b, pg, errs, reqs = new_page(p)
boot(pg)
pg.wait_for_timeout(2500)
anim = pg.evaluate(ANIM, NEW + [REF])
report['anim'] = anim
for a in anim:
if a.get('missing') or a.get('noActor'):
FAIL(f"{a['name']}: could not be spawned ({a})"); continue
bad = []
for leg in a['legs']:
if leg['tracks'] != 64: bad.append(f"{leg['mode']} bound {leg['tracks']} tracks (want 64)")
if not leg['advanced']: bad.append(f"{leg['mode']} mixer did not advance")
if leg['travel'] < 1.0: bad.append(f"{leg['mode']} skeleton barely moved ({leg['travel']})")
if leg['minStature'] < 0.85 * a['height']: bad.append(
f"{leg['mode']} FOLDED — stature fell to {leg['minStature']}m of {a['height']}m")
if leg['minHeadAboveHips'] < 0.35: bad.append(
f"{leg['mode']} FLAT BODY — head only {leg['minHeadAboveHips']}m above hips")
det = ' · '.join(f"{l['mode']} {l['tracks']}tr Δt{l['advanced']}s travel {l['travel']} "
f"stature {l['minStature']}{l['maxStature']}m head+{l['minHeadAboveHips']}m"
for l in a['legs'])
check(not bad, f"{a['name']} ({a['height']}m): {det}" + ('' + '; '.join(bad) if bad else ''))
b.close()
# ── 6. impostor vs rig at the swap boundary ───────────────────────────────────────────
head('6. IMPOSTOR — the mid billboard against its own near rig (the R2 exposure trap)')
b, pg, errs, reqs = new_page(p)
boot(pg)
# settle hard before measuring: the bookmark pauses the day cycle, but the sky/PMREM and
# the exposure ramp reach steady state on their own clock, and ACES makes a global exposure
# change a NON-linear shift in any ratio measured off the canvas. Every subject in this arm
# is therefore measured inside ONE run, and the claim is a within-run comparison against the
# bodies that did not change — absolute ratios move a little run to run; that comparison does not.
pg.evaluate("() => window.DBG.shot('street_noon')")
pg.wait_for_timeout(4000)
pg.evaluate("() => window.DBG.shot('street_noon')")
pg.wait_for_timeout(8000)
# Measure EVERY body in the fleet, not just the new five. The bake lights the ped with its
# own key+hemi+env and the street lights it with the sun, so a billboard/rig ratio of
# exactly 1.000 was never the contract — what the R2 fix bought is that the ratio is the
# SAME KIND of number for every ped on the same code path. So the 19 unchanged bodies are
# the control population, and the question this arm answers is the only one that matters
# at a swap boundary: do the new five sit inside the spread the town already ships?
roster = pg.evaluate(ROSTER)['all']
imp = {}
strips = []
for name in roster:
pg.evaluate(IMPOSTOR_STAGE, {'name': name, 'kind': 'empty'})
pg.wait_for_timeout(320)
empty = pg.screenshot(type='png')
st = pg.evaluate(IMPOSTOR_STAGE, {'name': name, 'kind': 'rig'})
if not st.get('ok'):
FAIL(f"{name}: could not stage a rig ({st.get('why')})"); continue
BOX = tuple(st['box'])
pg.wait_for_timeout(320)
rig_png = pg.screenshot(type='png')
pg.evaluate(IMPOSTOR_STAGE, {'name': name, 'kind': 'billboard'})
pg.wait_for_timeout(320)
bill_png = pg.screenshot(type='png')
lr, nr, rig_img, _ = crop_stats(empty, rig_png, BOX)
lb, nb, bill_img, _ = crop_stats(empty, bill_png, BOX)
imp[name] = {'rigLum': round(lr, 2), 'rigPx': nr, 'billLum': round(lb, 2), 'billPx': nb,
'ratio': round(lb / lr, 4) if lr else None, 'new': name in NEW}
if name in NEW or name == REF: strips.append((name, rig_img, bill_img))
note(f"{name:<20}{'NEW ' if name in NEW else ' '} rig {lr:6.2f} ({nr:5d} px) · "
f"billboard {lb:6.2f} ({nb:5d} px) · ratio {imp[name]['ratio']}")
pg.evaluate(IMPOSTOR_STAGE, {'name': REF, 'kind': 'crowd'})
report['impostor'] = imp
ctrl = sorted(v['ratio'] for k, v in imp.items() if not v['new'] and v['ratio'])
newr = sorted(imp[n]['ratio'] for n in NEW if imp.get(n) and imp[n]['ratio'])
if len(ctrl) >= 10 and len(newr) == len(NEW):
lo, hi, med = ctrl[0], ctrl[-1], ctrl[len(ctrl) // 2]
nmed = newr[len(newr) // 2]
note(f'CONTROL POPULATION — the {len(ctrl)} bodies this round did NOT touch span '
f'{lo:.3f}{hi:.3f} (median {med:.3f}); the bake is lit by its own key+hemi+env and '
f'the street by the sun, so a per-albedo spread is the normal state of this seam and '
f'1.000 was never the contract')
# (a) no SYSTEMATIC shift — that is what R2's 1.67x actually was: every ped at once
check(0.85 <= nmed / med <= 1.18,
f'no systematic shift: the five new bodies\' median ratio {nmed:.3f} vs the '
f'untouched roster\'s {med:.3f} = {nmed / med:.2f}x (R2\'s break was 1.67x on '
f'the whole fleet)')
# (b) and no individual outlier beyond what the town already ships
for name in NEW:
v = imp[name]
check(lo <= v['ratio'] <= hi,
f"{name}: billboard/rig luminance {v['ratio']:.3f} sits inside the untouched "
f"roster's own {lo:.3f}{hi:.3f} — the swap does not widen the near↔mid seam")
worst = max(imp.items(), key=lambda kv: abs(math.log(kv[1]['ratio'] or 1)))
note(f"worst-matched body in the whole roster: {worst[0]} at {worst[1]['ratio']:.3f} "
f"({'NEW' if worst[1]['new'] else 'shipped before this round'})")
else:
FAIL('control population too small — arm 6 is vacuous')
if strips:
from PIL import Image, ImageDraw
CW, CH = 240, 268 # per-subject crops differ by a pixel or two
sheet = Image.new('RGB', (CW * 2, (CH + 16) * len(strips)), (16, 16, 18))
dr = ImageDraw.Draw(sheet)
for i, (nm, ri, bi) in enumerate(strips):
y = i * (CH + 16) + 16
sheet.paste(ri.resize((CW, CH)), (0, y)); sheet.paste(bi.resize((CW, CH)), (CW, y))
dr.text((6, y - 13), f"{nm} LEFT near rig | RIGHT mid billboard "
f"billboard/rig luminance {imp[nm]['ratio']}", fill=(255, 230, 120))
sheet.save(OUTDIR / 'r42_impostor_boundary.jpg', quality=88)
note(f"contact strip → {OUTDIR / 'r42_impostor_boundary.jpg'}")
check(not errs, f'impostor arm: 0 console errors')
b.close()
finally:
srv.terminate()
(OUTDIR / 'r42_cast.json').write_text(json.dumps(report, indent=1))
print()
if fails:
print(f"\033[31m● RED\033[0m — {len(fails)} failure(s)")
for f in fails: print(' ' + f)
return 1
print("\033[32m● PASS\033[0m — contract, roster, determinism, draws, animation and the impostor "
"boundary all green")
return 0
if __name__ == '__main__':
sys.exit(main())

322
tools/qa/r42_repro.py Normal file
View File

@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""PROCITY Lane F — R42 §42.6 · THE BYTE-HASH GATE. The R41 ruling, reversed on measurement.
tools/.venv/bin/python tools/qa/r42_repro.py [--boots 3] [--goldens PATH] [--write-goldens]
R41 §41.6 ruled: **no gate may pin a screenshot byte-hash**, because 4 of 5 classic bookmarks gave 3
distinct hashes over 3 boots. F blamed the sign-atlas font race. **F was wrong about the cause.**
Lane B proved it in §42.5-C: the atlas canvases are byte-identical across boots, `document.fonts` is
an EMPTY set in this game (all text is canvas) and `fonts.ready` resolves ~55 ms before the first
`fillText` on 6 of 6 boots. What moves the frame is **the CROWD** the citizen sim advances on
wall-clock time, so two boots settled for the same 2.5 s are two different streets (983 changed px,
all of them in the pedestrian band, rows 361-418).
So the condition for a byte-stable frame is not "the font landed". It is **"nothing in frame runs on
wall-clock time"**, and that is a thing a harness can ASSERT rather than hope for:
?classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0 + DBG.freeze() after the pose
and DBG.freeze().stillLive must come back ALL FALSE.
`?pop=0` is NOT a substitute and never was (`index.html` read `|| 140`, and 0 is falsy fixed this
round in F's own file, and arm 0 below is the regression test for that fix).
TWO MORE THINGS HAD TO BE FOUND BEFORE THIS RAN 5/5, and both are load-bearing lines below:
· **settle until `DBG.info()` stops changing**, never for a fixed number. The CHUNK STREAMER is the
wall-clock process nobody had named; `crossroads_busy` read 2-of-3 inside a full gate run and
3-of-3 byte-identical standalone, and the only difference was machine load.
· **pin the RENDER, not the instrument.** With everything else frozen, the settled scene tuple
identical on every boot and the camera equal to nine decimals, two bookmarks still split by
53-62 px at (1252,82), which is `#pc-fps` printing `fps 20` vs `fps 21`. An element screenshot
does not exclude a DOM overlay, so `#pc-dbg` is hidden for the capture. The whole-page hash is
still taken and reported so "the render moved" and "the instrument moved" stay distinguishable.
WHAT THIS GATE PINS, and why it is not a committed golden. It boots each bookmark N times in FRESH
contexts and requires ONE distinct sha256 across them. That is the property the ruling is about
"a screenshot of this frame is a deterministic function of the tree" and it cannot rot when Lane E
repaints a couch. A committed cross-round golden VALUE is available (`--goldens PATH`) but is not
wired into qa.sh: R42 alone moved 46 prop colours, five bodies, a bench yaw and the booth
composition, and a committed value would have gone red on all four while telling us nothing.
ARMS
0. `?pop=0` means zero F's fix to index.html:454; the CONTROL is the same boot
without the flag, which must still hold a full roster.
1. freeze reports stillLive false the assertion the ruling now requires of any hash-pinning gate.
2. N boots, one hash per bookmark the pin itself, five bookmarks.
3. CONTROL: no freeze the same bookmarks, same boots, WITHOUT the freeze, which must
produce more than one hash somewhere. A pin that is green with
and without its precondition is not testing the precondition.
"""
import sys, os, json, time, socket, hashlib, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = int(os.environ.get('PROCITY_R42_REPRO_PORT', '8987'))
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
BOOTS = int(sys.argv[sys.argv.index('--boots') + 1]) if '--boots' in sys.argv else 3
GOLDENS = pathlib.Path(sys.argv[sys.argv.index('--goldens') + 1]) if '--goldens' in sys.argv else None
WRITE = '--write-goldens' in sys.argv
# NB there is deliberately no fixed settle constant here — see settle() below. The 2.5 s every lane
# has been using is a bet on how fast this box streams chunks today, and it is the bet that made the
# first cut of this gate report a bookmark 2-of-3 that was 3-of-3 standalone.
# The five classic bookmarks R41 measured. `?classic=1` because that is the arm whose whole point is
# byte-stability (the zero-fetch-delta covenant), and because it is the arm every previous round's
# hash table used — so today's numbers are comparable to R41's and B's.
BOOKMARKS = ['street_noon', 'shopfront_detail', 'crossroads_busy', 'market_square', 'night_neon']
FLAGS = 'classic=1&dbg=1&weather=0&tram=0&magpie=0&washing=0'
fails = []
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")
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():
p = 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): return p
time.sleep(0.1)
p.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}&{query}')
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
KEYS = ('drawCalls', 'tris', 'chunks', 'geometries', 'textures')
def settle(pg, cap_ms=12000, step=250):
"""WAIT UNTIL THE SCENE STOPS CHANGING, don't wait a number someone picked.
A fixed 2.5 s settle is a bet on how fast this box streams chunks today, and it is the bet that
made the first cut of this gate report `crossroads_busy` as 2-of-3: the same three boots run
standalone were 3-of-3 byte-identical, and the difference was six browser launches of machine
load in front of them. The chunk streamer is the wall-clock process nobody had named the crowd
is only the one you can see. So poll `DBG.info()` and require the whole tuple (draws · tris ·
chunks · geometries · textures) to repeat before capturing, and report the tuple so a hash
mismatch arrives WITH its diagnosis instead of as a bare inequality."""
prev, waited = None, 0
while waited < cap_ms:
i = pg.evaluate("() => window.DBG.info()")
cur = tuple(i.get(k) for k in KEYS)
if cur == prev: return cur, waited
prev = cur
pg.wait_for_timeout(step); waited += step
return prev, waited
def shot_hash(pg, bookmark, freeze):
"""One capture. `DBG.shot` poses the camera and pins the clock; `DBG.freeze` takes the crowd out.
ORDER MATTERS: shot() first (it teleports and warms chunks, which SPAWNS citizens), freeze()
second. Freezing before the pose leaves the newly streamed chunk's crowd in the picture."""
pg.evaluate(f"() => window.DBG.shot('{bookmark}')")
tup, waited = settle(pg)
fz = pg.evaluate("() => window.DBG.freeze()") if freeze else None
pg.wait_for_timeout(400)
fz2 = None
if freeze:
# re-assert: the sim keeps promoting citizens to near-tier rigs behind the hidden group
# (measured: rigged 6 → 9 across a 2.5 s settle), and a chunk that lands after the first
# freeze arrives with its own crowd. The second call must report a ZERO draw delta — that
# is the proof nothing came back, and it is what makes the pin an assertion.
fz2 = pg.evaluate("() => window.DBG.freeze()")
# PIN THE RENDER, NOT THE INSTRUMENT. This is the last thing standing between PROCITY and a
# byte-stable frame, and it took a pixel diff to find: with the crowd frozen, the clock paused,
# the settled scene tuple IDENTICAL on every boot and the camera transform equal to nine decimal
# places, `crossroads_busy` and `night_neon` still split 2/3. The whole difference was **53-62
# pixels in a 7x10 box at (1252,82)** — `#pc-fps` in the `?dbg=1` readout printing `fps 20` on
# one boot and `fps 21` on the next. Nothing in the game moved. The frame rate is not the frame.
#
# NB an element screenshot does NOT exclude an overlay: the canvas is full-viewport and the HUD
# is a DOM layer composited on top of it, so `locator('#canvas').screenshot()` still captures the
# HUD's pixels — measured, the bbox was identical before and after switching to the locator.
# `#pc-dbg` is therefore hidden for the capture and restored after. `visibility` rather than
# `display` so nothing reflows. The whole-page hash is still taken and reported so the
# distinction between "the render moved" and "the instrument moved" is in the log every run.
sel = pg.evaluate("() => { const c = window.PROCITY.renderer.domElement; "
"if (!c.id) c.id = 'pc-gl-shot'; "
"const d = document.getElementById('pc-dbg'); if (d) d.style.visibility = 'hidden'; "
"return '#' + c.id; }")
gl = pg.locator(sel).screenshot(type='png')
pg.evaluate("() => { const d = document.getElementById('pc-dbg'); if (d) d.style.visibility = ''; }")
page = pg.screenshot(type='png')
return (hashlib.sha256(gl).hexdigest(), hashlib.sha256(page).hexdigest()), tup, waited, fz, fz2
def main():
srv = serve()
rc = 0
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
# ── arm 0: ?pop=0 actually means zero (F's index.html fix) ───────────────────────────
head('0. `?pop=0` MEANS ZERO — the regression test for F\'s §42.6 fix to index.html:454')
b, pg, errs = new_page(p)
try:
boot(pg, f'{FLAGS}&roster=v1&pop=0')
pg.wait_for_timeout(1200)
st = pg.evaluate("() => ({ ...window.PROCITY.citizens.stats, target: window.PROCITY.citizens.target })")
note(f"?roster=v1&pop=0 → {json.dumps(st)}")
if (st.get('active') or 0) != 0:
FAIL(f"?pop=0 still boots {st.get('active')} active citizens — the `|| 140` fix did not land")
else:
OK('?roster=v1&pop=0 → 0 active citizens (before the fix this was 140 / 96 active — B §42.5-C)')
if errs: FAIL(f'pop=0 arm: console error(s) {errs[:2]}')
finally:
b.close()
b, pg, errs = new_page(p)
try: # CONTROL: the same boot WITHOUT the flag must still hold a full roster
boot(pg, f'{FLAGS}&roster=v1')
pg.wait_for_timeout(1200)
st = pg.evaluate("() => ({ ...window.PROCITY.citizens.stats, target: window.PROCITY.citizens.target })")
if (st.get('active') or 0) == 0:
FAIL('CONTROL VACUOUS: ?roster=v1 with no pop flag also boots 0 citizens — '
'the arm above proves nothing')
else:
OK(f"CONTROL — the same boot with no `pop` flag holds {st['active']} active "
f"(target {st.get('target')}): the default is untouched, only 0 changed meaning")
finally:
b.close()
# ── arms 1-2: the pin ────────────────────────────────────────────────────────────────
head(f'1-2. THE PIN — {BOOTS} fresh contexts per bookmark, ?{FLAGS} + DBG.freeze()')
table, goldens = {}, {}
for bm in BOOKMARKS:
hs, pgs, live, rows, tups = [], [], [], [], []
for i in range(BOOTS):
b, pg, errs = new_page(p)
try:
boot(pg, FLAGS)
(h, ph), tup, waited, fz, fz2 = shot_hash(pg, bm, freeze=True)
hs.append(h); pgs.append(ph); tups.append(tup)
live.append(fz2['stillLive'])
rows.append(dict(crowd=fz['draws']['before'] - fz['draws']['after'],
after=fz['draws']['after'], active=fz['peds']['active'],
back=fz2['draws']['before'] - fz2['draws']['after'], settle=waited))
if errs: FAIL(f'{bm} boot {i}: console error(s) {errs[:2]}')
finally:
b.close()
n, np_ = len(set(hs)), len(set(pgs))
table[bm] = dict(distinct=n, page=np_, hashes=hs, rows=rows, settled=[list(t) for t in tups])
goldens[bm] = hs[0]
# arm 1 — the assertion the reversed ruling REQUIRES of a hash-pinning gate
bad = [k for st in live for k, v in st.items() if v]
if bad:
FAIL(f'{bm}: DBG.freeze().stillLive is not all-false — {sorted(set(bad))} still run on '
f'wall-clock time. A hash pinned on this frame is a coin toss.')
came_back = [r['back'] for r in rows if r['back']]
if came_back:
FAIL(f'{bm}: the second freeze() still removed {came_back} draw(s) — something re-entered '
f'the frame after the first freeze, so the capture raced the streamer')
# arm 2 — the pin, and its diagnosis when it fails
if n != 1:
diag = ('the settled scene tuple differs between boots: '
+ ' vs '.join(str(t) for t in sorted(set(tups)))) if len(set(tups)) > 1 else \
(f'the settled scene tuple is IDENTICAL on all {BOOTS} boots {tups[0]}'
f'so the difference is in the render, not in what was loaded')
FAIL(f'{bm}: {n} distinct hashes over {BOOTS} frozen boots — not byte-reproducible. {diag}')
else:
OK(f'{bm}: canvas 1 of {BOOTS}{hs[0][:16]}… · the crowd was {rows[0]["crowd"]} of '
f'{rows[0]["crowd"] + rows[0]["after"]} draws ({rows[0]["active"]} active citizens) · '
f'0 came back · settled in {max(r["settle"] for r in rows)} ms · stillLive all-false')
if np_ != 1 and n == 1:
note(f'{bm}: the whole-PAGE hash is {np_} of {BOOTS} while the RENDER is 1 of {BOOTS}'
f"that difference is `#pc-fps` in the ?dbg=1 readout (measured: 53-62 px at "
f'(1252,82), "fps 20" vs "fps 21"). Pin the render; the frame rate is not the frame.')
# ── arm 3: THE CONTROL — the same pin with the freeze taken away ─────────────────────
head('3. CONTROL — the identical loop WITHOUT DBG.freeze(): the pin must be able to FAIL')
unstable = []
for bm in BOOKMARKS:
hs = []
for i in range(BOOTS):
b, pg, errs = new_page(p)
try:
boot(pg, FLAGS)
(h, _ph), _t, _w, _f, _f2 = shot_hash(pg, bm, freeze=False)
hs.append(h)
finally:
b.close()
n = len(set(hs))
table[bm]['nofreeze'] = n
if n > 1: unstable.append(f'{bm} {n}/{BOOTS}')
note(f'{bm}: {n} distinct hash(es) over {BOOTS} unfrozen boots')
if not unstable:
FAIL(f'CONTROL VACUOUS: every bookmark was byte-stable WITHOUT the freeze too, so this '
f'run does not show the freeze is what buys the pin. Re-run: the crowd has to be '
f'in at least one of these frames for the control to mean anything.')
else:
OK(f'the freeze is load-bearing — unfrozen, {len(unstable)} of {len(BOOKMARKS)} bookmarks '
f'give more than one hash ({", ".join(unstable)}); frozen, all give one')
# ── optional: the committed-golden mode, deliberately NOT wired into qa.sh ───────────
if GOLDENS:
if WRITE:
GOLDENS.parent.mkdir(parents=True, exist_ok=True)
GOLDENS.write_text(json.dumps({'schema': 'procity-shot-goldens/2', 'seed': SEED,
'viewport': [1280, 720], 'flags': FLAGS,
'recipe': 'DBG.shot(bookmark) -> settle until DBG.info() repeats (draws·tris·'
'chunks·geometries·textures) -> DBG.freeze() -> 400 ms -> freeze() '
'again (must remove 0 draws, stillLive all-false) -> hide #pc-dbg -> '
'sha256 of a PNG of the RENDERER CANVAS (never the whole page: the '
'HUD fps digit is the last non-determinism in a frozen frame)',
'settle': 'adaptive, cap 12000 ms, step 250 ms',
'boots_agreeing': BOOTS, 'regenerate':
'tools/.venv/bin/python tools/qa/r42_repro.py --goldens <this file> --write-goldens',
'recheck': 'the same command without --write-goldens',
'hashes': goldens}, indent=1) + '\n')
OK(f"wrote {len(goldens)} goldens → {GOLDENS}")
else:
old = json.loads(GOLDENS.read_text())['hashes']
for bm, h in goldens.items():
if old.get(bm) != h: FAIL(f'{bm}: golden {str(old.get(bm))[:16]}… ≠ measured {h[:16]}')
OK(f'{len(goldens)} committed goldens re-measured')
print('\n' + json.dumps({k: {'frozen_canvas': v['distinct'], 'frozen_page': v['page'],
'unfrozen_canvas': v.get('nofreeze')}
for k, v in table.items()}, indent=1))
finally:
srv.terminate()
print('')
if fails:
print(f'\033[31m● FAIL\033[0m — {len(fails)} problem(s):')
for f in fails: print(f' · {f}')
rc = 1
else:
print(f'\033[32m● PASS\033[0m — every bookmark pins to one sha256 over {BOOTS} frozen boots, '
f'stillLive all-false, and the unfrozen control can still fail')
return rc
if __name__ == '__main__':
sys.exit(main())

BIN
tools/qa/r42_roster.mjs Normal file

Binary file not shown.

379
tools/qa/r42_shots.py Normal file
View File

@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""PROCITY Lane D — R42 §42.4 acceptance shots: THE NEW ROSTER, ON THE STREET AND IN THE PUB.
tools/.venv/bin/python tools/qa/r42_shots.py [--seed N] [--outdir DIR] 0 green · 1 red
Two frames, and both of them name the bodies in shot because "the luchador is gone" is a claim
about the CAST, and a JPEG on its own cannot carry it. Every figure is resolved back to the ped GLB
it is wearing (`fleet.all[pedIndex].pedName`), so the sidecar is a cast list, not a caption.
1. r42_street_crowd.jpg the retail heart at midday. Camera pose chosen the R41 way the bench
station with the most patronage doors around it, stood off on the carriageway and
occlusion-raycast before it is accepted then held until the crowd offers the frame.
2. r42_pub_crowd.jpg THE BEFORE/AFTER. Shot on the same recipe Lane F used for
docs/shots/laneF_r41/pub_furnished.jpg (?gigs=1, segment 5, the best pub, Lane C's own
room.spawn line, back 5.2 m, eye 1.62 m) so the pair is comparable frame for frame. That R41
frame is the one with the purple EL CHUPACABRA luchador in the gig crowd; this is the same room
after the recast.
Human-sized line (R10) on both, measured off the posed skeleton, written into the sidecar .txt.
Fresh headless contexts, own no-store server.
"""
import sys, os, json, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = int(os.environ.get('PROCITY_R42_SHOT_PORT', '8993'))
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
OUTDIR = ROOT / 'docs' / 'shots' / 'laneD'
if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1])
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
LO, HI = 1.4, 2.0
RETIRED = ['comical_luchador_01', 'comical_boy_01', 'man_elder_01', 'man_soldier_ww2_01', 'dj_phrtt_01']
rc = 0
def FAIL(m):
global rc; rc = 1; print(f" \033[31m✗ {m}\033[0m")
def OK(m): print(f" \033[32m✓\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()
'''
# ── street: the R41 §41.3 place/frame logic, unchanged (it is already measurement-driven) ──────────
PLACE = r"""
async () => {
const P = window.PROCITY, C = P.citizens;
const THREE = await import('three');
const mod = await import('./js/citizens/sim.js');
const V3 = P.scene.position.constructor;
const ray = new THREE.Raycaster(); ray.far = 40;
const doors = [];
if (C.shopsByChunk) for (const list of C.shopsByChunk.values()) for (const d of list) doors.push(d);
const clear = (px, pz, tx, tz) => {
const d = Math.hypot(tx - px, tz - pz);
ray.set(new V3(px, 1.6, pz), new V3((tx - px) / d, 0, (tz - pz) / d));
let h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh && !x.object.isSprite);
if (h.length && h[0].distance < d - 0.8) return false;
for (const [ax, az] of [[1,0],[-1,0],[0,1],[0,-1]]) {
ray.set(new V3(px, 1.6, pz), new V3(ax, 0, az));
h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh);
if (h.length && h[0].distance < 1.3) return false;
}
return true;
};
const out = [];
for (let i = 0; i < C.edges.length; i++) {
const e = C.edges[i];
if ((e.width || 4) < 10) continue;
const ux = e.ux, uz = e.uz;
for (const st of mod.benchStationsFor(e)) {
let nd = 0;
for (const d of doors) if (Math.hypot(d.x - st.x, d.z - st.z) < 14) nd++;
if (!nd) continue;
const bd = Math.hypot(st.x - (e.A.x + ux * st.s), st.z - (e.A.z + uz * st.s)) || 1;
const nx = (st.x - (e.A.x + ux * st.s)) / bd, nz = (st.z - (e.A.z + uz * st.s)) / bd;
const back = Math.min(bd * 0.55, 9);
for (const sgn of [1, -1]) {
const px = st.x - nx * back - ux * 5 * sgn, pz = st.z - nz * back - uz * 5 * sgn;
out.push({ nd, px, pz, yaw: Math.atan2(-(st.x - px), -(st.z - pz)), bx: st.x, bz: st.z });
}
}
}
out.sort((p, q) => q.nd - p.nd);
const keep = [];
for (const c of out) { if (keep.length >= 6) break; if (clear(c.px, c.pz, c.bx, c.bz)) keep.push(c); }
return { ok: keep.length > 0, cands: keep, stations: out.length / 2 | 0 };
}
"""
# Same visibility discipline as R41 ("in the frustum" is not "in the picture") plus the R42 addition:
# every figure is resolved to the ped GLB it is wearing.
STREET_MEASURE = r"""
async () => {
const P = window.PROCITY, C = P.citizens, V3 = P.scene.position.constructor;
const THREE = await import('three');
const cam = P.camera; cam.updateMatrixWorld(true);
const ray = new THREE.Raycaster(); ray.far = 30;
const names = (P.fleet.all || []).map(r => r.pedName);
const visible = (c) => {
const dx = c.x - cam.position.x, dz = c.z - cam.position.z, d = Math.hypot(dx, dz);
ray.set(new V3(cam.position.x, 1.35, cam.position.z), new V3(dx / d, 0, dz / d));
const h = ray.intersectObject(P.scene, true)
.filter(x => !x.object.isSkinnedMesh && !x.object.isSprite && !x.object.isPoints);
return !(h.length && h[0].distance < d - 0.5);
};
const v = new V3(); const rows = []; const mid = [];
for (const c of C.activeCitizens()) {
const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z);
v.set(c.x, 1.0, c.z).project(cam);
const inShot = !(v.z > 1 || Math.abs(v.x) > 0.62 || Math.abs(v.y) > 0.55);
if (c.tier === 'mid' && inShot && d <= 70) mid.push(names[c.pedIndex] || '?');
if (!c.actor || c.actorKind !== 'rig') continue;
if (d < 3.0 || d > 22 || !inShot || !visible(c)) continue;
let lo = 1e9, hi = -1e9;
c.actor.inner.updateWorldMatrix(true, true);
c.actor.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w);
if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } });
const posedNow = c.loiter > 0;
rows.push({ id: c.id, d: +d.toFixed(1), ped: names[c.pedIndex] || '?',
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'),
clip: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look'
: (posedNow ? c.posture.idle : c.posture.walk),
h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3), footY: +lo.toFixed(3) });
}
rows.sort((a, b) => a.d - b.d);
const clips = new Set(rows.map(r => r.clip)), states = new Set(rows.map(r => r.state));
// [R42] the R41 score plus a CAST term: this round's frame is about who is in it, so a frame
// showing five different bodies beats one showing the same body five times.
const bodies = new Set(rows.map(r => r.ped));
return { rows, midPeds: mid, clips: clips.size, states: [...states], bodies: bodies.size,
posed: rows.filter(r => r.state === 'bench-sit' || r.state === 'lean').length,
score: clips.size * 100 + rows.length * 25 + bodies.size * 120
+ rows.filter(r => r.state === 'bench-sit' || r.state === 'lean').length * 350,
info: window.DBG.info() };
}
"""
# ── pub: Lane F's own §41.6 hero recipe, so the pair is comparable ─────────────────────────────────
ENTER_PUB = r"""
async () => {
const P = window.PROCITY, D = window.DBG;
const pubs = (P.plan.shops || []).filter(s => s.type === 'pub');
if (!pubs.length) return { ok: false, why: 'no pub in this town' };
let best = null;
for (const s of pubs.slice(0, 6)) {
D.enterShop(s.id);
await new Promise(r => setTimeout(r, 2600));
const room = P.interiorMode.current;
const crew = P.interiorMode.crew;
const n = (room ? 1 : 0) && ((crew ? crew.members.length : 0)
+ (((P.interiorMode.keepers || {}).keepers || []).length));
if (room && (!best || n > best.figures)) { best = { shop: { id: s.id, name: s.name, type: s.type }, figures: n };
if (n >= 6) break; }
D.exitShop(); await new Promise(r => setTimeout(r, 400));
}
if (!best) return { ok: false, why: 'no pub interior would build' };
D.enterShop(best.shop.id);
await new Promise(r => setTimeout(r, 2800));
return { ok: true, ...best, gig: P.gigs ? P.gigs.stateOf(best.shop.id) : null };
}
"""
PUB_FRAME = r"""
async ({ back, eye }) => {
const P = window.PROCITY, V3 = P.scene.position.constructor;
const room = P.interiorMode.current;
if (!room) return { ok: false, why: 'no room open' };
const sp = room.spawn || { x: 0, z: 0 };
const tx = 0, tz = -room.dims.D * 0.12, ty = 1.05; // F's 'centre' target, verbatim
let dx = sp.x - tx, dz = sp.z - tz; const L = Math.hypot(dx, dz) || 1; dx /= L; dz /= L;
const d = Math.min(Math.max(back, 1.9), Math.max(L, back));
const px = tx + dx * d, pz = tz + dz * d;
P.camera.position.set(px, eye, pz);
P.camera.lookAt(new V3(tx, ty, tz));
P.camera.updateMatrixWorld(true);
P.renderer.info.reset();
P.renderer.render(P.interiorMode.scene, P.camera);
return { ok: true, cam: [+px.toFixed(2), +pz.toFixed(2)], target: [+tx.toFixed(2), +tz.toFixed(2)],
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles, dims: room.dims };
}
"""
PUB_MEASURE = r"""
() => {
const P = window.PROCITY, V3 = P.scene.position.constructor;
const names = (P.fleet.all || []).map(r => r.pedName);
const cam = P.camera; cam.updateMatrixWorld(true);
const v = new V3();
const rows = [];
const add = (a, kind, role, pedIndex) => {
if (!a || !a.inner) { rows.push({ state: kind, role, ped: 'placeholder', h: 0, stature: 0 }); return; }
let lo = 1e9, hi = -1e9;
a.inner.updateWorldMatrix(true, true);
a.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w);
if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } });
v.set(a.fig.position.x, 1.0, a.fig.position.z).project(cam);
rows.push({ state: kind, role, ped: (pedIndex != null && names[pedIndex]) || '?',
h: +(a.height || a.nominalHeight || 0).toFixed(3), stature: +(hi - lo).toFixed(3),
clip: ((a.mixer && a.mixer._actions) || []).filter(x => x.getEffectiveWeight() > 0.5)
.map(x => x.getClip().name)[0] || null,
inFrame: !(v.z > 1 || Math.abs(v.x) > 0.92 || Math.abs(v.y) > 0.92) });
};
const crew = P.interiorMode.crew;
if (crew) for (const m of crew.members) add(m.actor, m.part === 'band' ? 'band' : 'crowd', m.role, m.pedIndex);
for (const k of ((P.interiorMode.keepers || {}).keepers || []))
add(k.actor, k.browse ? 'browser' : 'keeper', k.type, k.pedIndex);
return { rows, info: window.DBG.info() };
}
"""
def port_up(p):
with socket.socket() as s:
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', p)) == 0
def serve():
pr = 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): return pr
time.sleep(0.1)
pr.terminate(); raise SystemExit(f'could not serve on :{PORT}')
def human_line(rows):
"""The R10 no-giants line, height-RELATIVE (D's R41 finding: a 1.32 m wall-leaner is not a defect,
a 2.4 m anything is, and a body folded to hip height is the R16 failure this exists to catch)."""
out, bad = [], []
for r in rows:
if r.get('ped') == 'placeholder': continue
h = r.get('h') or 0
ratio = (r['stature'] / h) if h else None
who = r.get('id', r.get('state'))
if r['stature'] > HI: bad.append(f'{who} GIANT {r["stature"]}m')
elif h and r['stature'] < 0.55 * h: bad.append(f'{who} FOLDED {r["stature"]}m of {h}m')
elif h and not (LO <= h <= HI): bad.append(f'{who} nominal {h}m out of [{LO},{HI}]')
out.append(f"{r['state']}" + (f"[{r['ped']}]" if r.get('ped') else '')
+ f" {r['stature']}m" + (f" ({ratio:.0%} of its {h}m nominal)" if ratio else ''))
verdict = ('ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal)' if not bad
else 'FAIL: ' + ', '.join(bad))
return (f"HUMAN-SIZED LINE (R10) — {len(out)} figure(s), stature (feet→crown) measured off the "
f"posed skeleton: {' · '.join(out)}. {verdict}."), not bad
def cast_line(peds, label):
seen = {}
for p in peds: seen[p] = seen.get(p, 0) + 1
banned = sorted(set(peds) & set(RETIRED))
return (f"CAST ({label}) — {len(peds)} figures wearing {len(seen)} distinct bodies: "
+ ', '.join(f'{k}×{v}' for k, v in sorted(seen.items())) + '. '
+ ('PERIOD LAW: none of the five retired bodies is in this frame.'
if not banned else f'RETIRED BODY IN FRAME: {banned}')), not banned
def boot(pg, q=''):
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1' + (('&' + q) if q else ''))
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
pg.wait_for_function('() => window.PROCITY.fleet && window.PROCITY.fleet.ready', timeout=25000)
def main():
global rc
OUTDIR.mkdir(parents=True, exist_ok=True)
from playwright.sync_api import sync_playwright
srv = serve()
try:
with sync_playwright() as p:
# ── 1. the street ─────────────────────────────────────────────────────────────────────
print('\033[1m1. r42_street_crowd — the recast town, on the footpath\033[0m')
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)))
boot(pg, 'weather=0&magpie=0&washing=0')
pg.evaluate("() => { window.DBG.setSegment(2); window.DBG.shot('crossroads_busy'); }")
pg.wait_for_timeout(45000) # let the crowd reach its posture steady state
place = pg.evaluate(PLACE)
print(f" place: {place.get('stations', 0)} bench stations near shop doors, "
f"{len(place.get('cands', []))} clear camera poses")
best = None
for cand in place.get("cands", [])[:6]:
pg.evaluate('([x,z,y]) => window.DBG.teleport(x,z,y)', [cand['px'], cand['pz'], cand['yaw']])
pg.wait_for_timeout(2500)
for _ in range(14):
m = pg.evaluate(STREET_MEASURE)
if m['rows'] and (not best or m['score'] > best['score']):
pg.screenshot(path=str(OUTDIR / 'r42_street_crowd.jpg'), type='jpeg', quality=92)
best = dict(m, **{k: cand[k] for k in ('px', 'pz', 'yaw')})
print(f" ({cand['px']:.0f},{cand['pz']:.0f}) score {m['score']} "
f"n={len(m['rows'])} clips={m['clips']} bodies={m['bodies']} "
f"posed={m['posed']}")
# good enough to stop hunting: two posed states, four clips, four distinct bodies
if best and best['posed'] >= 2 and best['clips'] >= 4 and best['bodies'] >= 4: break
pg.wait_for_timeout(2500)
pg.evaluate('([x,z,y]) => window.DBG.teleport(x,z,y)', [cand['px'], cand['pz'], cand['yaw']])
if best and best['posed'] >= 2 and best['clips'] >= 4 and best['bodies'] >= 4: break
if not best:
FAIL('street: never framed anybody')
else:
line, ok = human_line(best['rows'])
cast, cok = cast_line([r['ped'] for r in best['rows']] + best['midPeds'],
'near rigs + mid billboards in frame')
states = {}
for r in best['rows']: states[r['state']] = states.get(r['state'], 0) + 1
cap = (f"PROCITY R42 §42.4 — r42_street_crowd.jpg (THE RECAST ROSTER, ON THE STREET)\n"
f"seed {SEED} · synthetic · MIDDAY · camera ({best['px']:.1f}, {best['pz']:.1f}) "
f"yaw {best['yaw']:.3f}\n"
f"{len(best['rows'])} near-tier rigs in frame · states {states} · "
f"{best['clips']} distinct clips: {', '.join(sorted(set(r['clip'] for r in best['rows'])))}\n"
f"draws {best['info']['drawCalls']} / budget {best['info']['budget']['draws']} · "
f"tris {best['info']['tris']}\n\n{cast}\n\n{line}\n")
(OUTDIR / 'r42_street_crowd.txt').write_text(cap)
print(cap)
if not ok: FAIL('street: human-sized line')
if not cok: FAIL('street: a retired body is in frame')
OK('r42_street_crowd.jpg + .txt')
if errs: FAIL(f'street: {len(errs)} console error(s): {errs[:2]}')
b.close()
# ── 2. the pub (the before/after against laneF_r41/pub_furnished.jpg) ─────────────────
print('\n\033[1m2. r42_pub_crowd — the same room as R41\'s pub_furnished, recast\033[0m')
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)))
boot(pg, 'gigs=1&weather=0&magpie=0&washing=0')
pg.evaluate('() => window.DBG.setSegment(5)')
pg.wait_for_timeout(1500)
r = pg.evaluate(ENTER_PUB)
if not r.get('ok'):
FAIL(f"pub: {r.get('why')}")
else:
fr = pg.evaluate(PUB_FRAME, {'back': 5.2, 'eye': 1.62})
if not fr.get('ok'):
FAIL(f"pub frame: {fr.get('why')}")
else:
m = pg.evaluate(PUB_MEASURE)
pg.screenshot(path=str(OUTDIR / 'r42_pub_crowd.jpg'), type='jpeg', quality=92)
inframe = [x for x in m['rows'] if x.get('inFrame')]
line, ok = human_line(inframe)
cast, cok = cast_line([x['ped'] for x in m['rows'] if x['ped'] != 'placeholder'],
'every figure in the room')
cap = (f"PROCITY R42 §42.4 — r42_pub_crowd.jpg (THE BEFORE/AFTER)\n"
f"BEFORE: docs/shots/laneF_r41/pub_furnished.jpg — same seed, same recipe, and the\n"
f"purple EL CHUPACABRA luchador (comical_luchador_01) in the gig crowd.\n"
f"seed {SEED} · {r['shop']['name']} (pub) · segment 5 · ?gigs=1 · gig state "
f"{r.get('gig')}\n"
f"camera {fr['cam']}{fr['target']} (Lane C's room.spawn line, back 5.2 m, eye "
f"1.62 m — Lane F's §41.6 hero recipe verbatim, so the pair is comparable)\n"
f"{len(m['rows'])} figures in the room, {len(inframe)} in frame · "
f"{fr['draws']} draws of the ≤350 interior law · {fr['tris']} tris · "
f"room {fr['dims']['W']}x{fr['dims']['D']} m\n\n{cast}\n\n{line}\n")
(OUTDIR / 'r42_pub_crowd.txt').write_text(cap)
print(cap)
if not ok: FAIL('pub: human-sized line')
if not cok: FAIL('pub: a retired body is in frame')
OK('r42_pub_crowd.jpg + .txt')
if errs: FAIL(f'pub: {len(errs)} console error(s): {errs[:2]}')
b.close()
finally:
srv.terminate()
print('\n\033[32m● shots written\033[0m' if rc == 0 else '\n\033[31m● problems — see above\033[0m')
return rc
if __name__ == '__main__':
sys.exit(main())

838
tools/qa/r42_tour.py Normal file
View File

@ -0,0 +1,838 @@
#!/usr/bin/env python3
"""PROCITY Lane F — R42 §42.6 THE PROOF SHOTS. This round is judged by eye, so these ARE the round.
tools/.venv/bin/python tools/qa/r42_tour.py [--only NAME[,NAME]] [--outdir DIR]
R42 is a LOOK round: three faults were visible in R41's own committed proof shots, and the deliverable
is the same frames taken again on the integrated tree, **paired against those committed originals**.
The R41 files in `docs/shots/laneF_r41/` are the BEFORE they are in git, nobody can re-shoot them,
and that is exactly what makes them evidence.
pub_furnished R41's hero. The before holds TWO purple `EL CHUPACABRA` luchadors and an
UNCLOTHED body at the stage edge, in a room of white plastic-looking props.
Same recipe, same seed, same room, same segment. pub_furnished_ab.jpg
record_dj_booth R41's booth: "a plain black box". Same recipe. → record_dj_booth_ab.jpg
record_dj_booth_door and the frame R41 never took: the booth from where a player actually stands,
at Lane C's own `room.spawn` — the door. A composition claim is a claim about
the entry view, so it has to be shot from the entry.
street_postures R41's street, from R41's OWN published camera parsed out of its committed
sidecar, because re-running R41's live-crowd cluster search lands somewhere
different every time. street_postures_ab.jpg
street_bench the bench claim on its own, shot SIDE-ON (a person facing the camera looks the
same whichever way the bench under them is turned), with the yaw MEASURED in
the frame from the plan's road geometry and the scene's instance matrices
not read back off B's placement rule.
credits_panel the F2 surface with the ped rows filled: ruling 3 on screen, no `unverified`.
TWO LINES ON EVERY HUMANOID FRAME
· the R10 human-sized line (stature feetcrown off the POSED skeleton, as a ratio of that figure's
own nominal height a giant >2.0 m or a fold <55% fails).
· the CAST line (Lane D's §42.4 idea, adopted): every figure resolved back to the ped GLB it is
wearing via `fleet.all[pedIndex].pedName`, with an explicit assertion that none of the five
retired bodies is in the picture. "The luchador is gone" is a claim about the cast, and a JPEG
cannot carry it alone.
Fresh headless context per frame, own no-store server, seed 20261990, 1280x720.
Exit 0 = every frame captured, every human-sized line clean, and no retired body in any cast.
"""
import sys, os, re, json, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = int(os.environ.get('PROCITY_R42_TOUR_PORT', '8984'))
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
OUTDIR = ROOT / 'docs' / 'shots' / 'laneF_r42'
BEFORE = ROOT / 'docs' / 'shots' / 'laneF_r41' # the committed R41 originals — the BEFORE arm
LO, HI = 1.4, 2.0
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
ONLY = None
if '--only' in sys.argv: ONLY = set(sys.argv[sys.argv.index('--only') + 1].split(','))
# Lane E §42.3 / Lane D §42.4 — the five bodies that must never be in a shipped frame again.
RETIRED = ['comical_luchador_01', 'comical_boy_01', 'man_elder_01', 'man_soldier_ww2_01', 'dj_phrtt_01']
fails = []
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")
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()
'''
# ── the street: R41's own cluster search, plus the CAST term ──────────────────────────────────────
# Unchanged in method from r41_tour.py on purpose — a before/after pair whose AFTER was framed by a
# different algorithm is not a pair. The one addition is `ped`: the frame this round is about WHO is
# in it, so the scorer also rewards distinct BODIES, and the sidecar can name them.
STREET_LOOK = r"""
() => {
const P = window.PROCITY, C = P.citizens, V3 = P.scene.position.constructor;
const cam = P.camera;
const names = (P.fleet.all || []).map(r => r.pedName);
const people = [];
for (const c of C.activeCitizens()) {
if (!c.actor || c.actorKind !== 'rig') continue;
const posedNow = c.loiter > 0;
people.push({ id: c.id, x: c.x, z: c.z, ped: names[c.pedIndex] || '?',
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk') });
}
const anchors = people.filter(p => p.state !== 'walk');
if (!anchors.length) return { ok: false, why: 'no non-walking rig on the street yet', n: people.length };
const targets = anchors.map(a => ({ x: a.x, z: a.z, state: a.state }));
for (let i = 0; i < anchors.length; i++)
for (let j = i + 1; j < anchors.length; j++)
if (anchors[i].state !== anchors[j].state && Math.hypot(anchors[i].x - anchors[j].x, anchors[i].z - anchors[j].z) < 34)
targets.push({ x: (anchors[i].x + anchors[j].x) / 2, z: (anchors[i].z + anchors[j].z) / 2,
state: anchors[i].state + '+' + anchors[j].state });
let best = null;
for (const a of targets) {
for (let i = 0; i < 12; i++) {
const th = i * Math.PI / 6;
for (const r of [7, 9.5, 12, 15, 18]) {
const px = a.x + Math.cos(th) * r, pz = a.z + Math.sin(th) * r;
cam.position.set(px, 1.62, pz);
cam.lookAt(new V3(a.x, 1.25, a.z));
cam.updateMatrixWorld(true);
const v = new V3(); const seen = [];
for (const p of people) {
const d = Math.hypot(p.x - px, p.z - pz);
if (d < 2.4 || d > 26) continue;
v.set(p.x, 1.0, p.z).project(cam);
if (v.z > 1 || Math.abs(v.x) > 0.9 || Math.abs(v.y) > 0.9) continue;
seen.push(p);
}
const states = new Set(seen.map(s => s.state));
const bodies = new Set(seen.map(s => s.ped));
const score = states.size * 100 + bodies.size * 40 + seen.length * 10 - r * 4;
if (!best || score > best.score) best = { score, px, pz, ax: a.x, az: a.z,
states: [...states], bodies: [...bodies], inFrame: seen.length, anchor: a.state };
}
}
}
cam.position.set(best.px, 1.62, best.pz);
cam.lookAt(new V3(best.ax, 1.25, best.az));
cam.updateMatrixWorld(true);
// MEASURE IN THE SAME BEAT searching in one evaluate and measuring in the next lets the dwell
// clock run between them, and a bench somebody has just got up from is the frame you shoot.
const v2 = new V3(); const rows = [];
for (const c of C.activeCitizens()) {
if (!c.actor || c.actorKind !== 'rig') continue;
const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z);
if (d < 2.2 || d > 26) continue;
v2.set(c.x, 1.0, c.z).project(cam);
if (v2.z > 1 || Math.abs(v2.x) > 0.92 || Math.abs(v2.y) > 0.92) continue;
let lo = 1e9, hi = -1e9;
c.actor.inner.updateWorldMatrix(true, true);
c.actor.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } });
const posedNow = c.loiter > 0;
// the clip is read off the MIXER, not off `c.posture` posture is the ASSIGNMENT and exists on a
// ?clips=0 boot that has fetched nothing (R41's lesson, kept).
const playing = ((c.actor.mixer && c.actor.mixer._actions) || [])
.filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name);
rows.push({ id: c.id, d: +d.toFixed(1), ped: names[c.pedIndex] || '?',
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'),
clip: playing[0] || null, h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3) });
}
rows.sort((a, b) => a.d - b.d);
const states = [...new Set(rows.map(r => r.state))].sort();
const clips = [...new Set(rows.map(r => r.clip).filter(Boolean))].sort();
const bodies = [...new Set(rows.map(r => r.ped))].sort();
// and a BENCH-SIT BONUS, stated rather than hidden: R41's committed `street_postures.jpg` is a
// bench-sit frame, and a pair whose halves show different states is not a pair. The bonus biases
// the AFTER toward the comparable frame; it does not manufacture one, because a bench-sit anchor
// only exists if a citizen is actually sitting on a Lane B bench in this town at this moment.
const benchBonus = rows.some(r => r.state === 'bench-sit') ? 150 : 0;
return { ok: true, ...best, rows, states, clips, bodies, inFrame: rows.length, benchBonus,
score2: states.length * 100 + bodies.length * 50 + clips.length * 40 + rows.length * 10 + benchBonus,
info: window.DBG.info(), clipStats: C.clipStats() };
}
"""
# ── THE BENCH, measured in the frame and NOT from Lane B's rule ───────────────────────────────────
# `benchStops(plan)` is used for POSITIONS ONLY — to say "this instance is a bench" — which is the
# same non-circularity B used. The yaw verdict is then computed from two things B's placer never
# touches: the PLAN's road centreline (Lane A's graph) and the instance's own world matrix.
BENCH_MEASURE = r"""
async () => {
const P = window.PROCITY, V3 = P.scene.position.constructor;
const THREE = await import('three');
const F = await import('./js/world/furniture.js');
const stops = F.benchStops(P.plan);
// road geometry, straight from Lane A's graph — not from the furniture module
const nodes = new Map(P.plan.streets.nodes.map(n => [n.id, n]));
const edges = P.plan.streets.edges.map(e => { const a = nodes.get(e.a), b = nodes.get(e.b);
return { id: e.id, ax: a.x, az: a.z, bx: b.x, bz: b.z, kind: e.kind, width: e.width }; });
const byId = new Map(edges.map(e => [e.id, e]));
const foot = (e, x, z) => {
const dx = e.bx - e.ax, dz = e.bz - e.az, L2 = dx * dx + dz * dz || 1, L = Math.sqrt(L2);
let t = ((x - e.ax) * dx + (z - e.az) * dz) / L2; t = Math.max(0, Math.min(1, t));
const cx = e.ax + dx * t, cz = e.az + dz * t;
return { d: Math.hypot(x - cx, z - cz), cx, cz, ux: dx / L, uz: dz / L, e };
};
// THE EDGE A BENCH BELONGS TO IS THE ONE ITS STATION NAMES, not the geometrically nearest one.
// Measured the hard way: nearest-edge attribution reported one bench at 174.4 degrees off "the
// road" — a bench standing near a corner is often closer to the CROSS street's centreline than to
// its own, so it correctly faces its own street and the instrument calls that a 180-degree error.
// `edgeId` is metadata of the station this instance was matched to BY POSITION; the yaw verdict is
// still computed from Lane A's road geometry and the instance's own matrix, so it stays
// non-circular. Both readings are returned so the disagreement is visible rather than tuned away.
const nearestEdgePoint = (x, z) => {
let best = null;
for (const e of edges) { const f = foot(e, x, z); if (!best || f.d < best.d) best = f; }
return best;
};
const cam = P.camera; cam.updateMatrixWorld(true);
const m = new THREE.Matrix4(), pos = new V3(), q = new THREE.Quaternion(), sc = new V3();
const fwd = new V3(), v = new V3();
const rows = [];
P.scene.traverse(o => {
if (!o.isInstancedMesh) return;
o.updateWorldMatrix(true, false);
for (let i = 0; i < o.count; i++) {
o.getMatrixAt(i, m); m.premultiply(o.matrixWorld); m.decompose(pos, q, sc);
// POSITION-ONLY identification: is there a bench station under this instance?
let hit = null;
for (const s of stops) { const d = Math.hypot(s.x - pos.x, s.z - pos.z); if (d < 0.02) { hit = s; break; } }
if (!hit) continue;
fwd.set(0, 0, 1).applyQuaternion(q).normalize(); // the bench FRONT in world space
const own = byId.has(hit.edgeId) ? foot(byId.get(hit.edgeId), pos.x, pos.z) : nearestEdgePoint(pos.x, pos.z);
const ne = nearestEdgePoint(pos.x, pos.z);
const ang = (f) => {
const toRoad = new V3(f.cx - pos.x, 0, f.cz - pos.z).normalize();
return Math.acos(Math.max(-1, Math.min(1, fwd.dot(toRoad)))) * 180 / Math.PI;
};
const along = new V3(own.ux, 0, own.uz);
const degTo = ang(own), degNear = ang(ne);
const degAlong = Math.acos(Math.max(-1, Math.min(1, Math.abs(fwd.dot(along))))) * 180 / Math.PI;
v.copy(pos); v.y = 1.0; v.project(cam);
rows.push({ x: +pos.x.toFixed(2), z: +pos.z.toFixed(2), edgeId: hit.edgeId, side: hit.side,
offset: +own.d.toFixed(2), nearOffset: +ne.d.toFixed(2), nearIsOwn: ne.e.id === hit.edgeId,
toRoadDeg: +degTo.toFixed(1), nearDeg: +degNear.toFixed(1), alongDeg: +degAlong.toFixed(1),
camDist: +Math.hypot(pos.x - cam.position.x, pos.z - cam.position.z).toFixed(1),
inFrame: !(v.z > 1 || Math.abs(v.x) > 0.95 || Math.abs(v.y) > 0.95) });
}
});
// THE CONTROL, in the same evaluate: shift every station 3 m and re-identify. "coincides" has to
// be a measurement, or the 2 cm match above is just "there was an instanced mesh somewhere".
let ctrl = 0;
P.scene.traverse(o => {
if (!o.isInstancedMesh) return;
o.updateWorldMatrix(true, false);
for (let i = 0; i < o.count; i++) {
o.getMatrixAt(i, m); m.premultiply(o.matrixWorld); m.decompose(pos, q, sc);
for (const s of stops) if (Math.hypot(s.x + 3 - pos.x, s.z + 3 - pos.z) < 0.02) { ctrl++; break; }
}
});
// and choose the camera for the DEDICATED bench frame while the geometry is still in hand.
// A SIDE-ON view is the one that reads. Standing on the bench's own front line (which is what the
// street cluster search happens to pick) shows a person facing the camera, and a person facing the
// camera looks identical whichever way the bench underneath them is turned. In profile, with the
// road on one side and the shopfront on the other, "the sitter faces the road" is visible.
let pick = null;
for (const c of P.citizens.activeCitizens()) {
if (!c.bench || !c.actor || c.actorKind !== 'rig') continue;
for (const r of rows) {
const d = Math.hypot(r.x - c.x, r.z - c.z);
if (d < 1.2 && (!pick || d < pick.d)) pick = { d, r };
}
}
let shot = null;
if (pick) {
const e = byId.get(pick.r.edgeId);
const f = e ? foot(e, pick.r.x, pick.r.z) : null;
const toRoad = f ? new V3(f.cx - pick.r.x, 0, f.cz - pick.r.z).normalize() : new V3(0, 0, 1);
const side = new V3(-toRoad.z, 0, toRoad.x); // 90 deg to the bench front
shot = { px: +(pick.r.x + side.x * 5.0 + toRoad.x * 2.4).toFixed(3),
pz: +(pick.r.z + side.z * 5.0 + toRoad.z * 2.4).toFixed(3),
ax: +(pick.r.x + toRoad.x * 0.3).toFixed(3),
az: +(pick.r.z + toRoad.z * 0.3).toFixed(3),
bench: pick.r, sitterDist: +pick.d.toFixed(2) };
}
return { stations: stops.length, matched: rows.length, control: ctrl, rows, shot };
}
"""
# Place the camera the BENCH_MEASURE pick chose, then measure the figures that land in it — in the
# same beat, for the same reason the street search does: a bench somebody has just got up from is the
# frame you actually shoot otherwise.
POSE = r"""
({ px, pz, ax, az, eye, ly }) => {
const P = window.PROCITY, V3 = P.scene.position.constructor;
P.player.teleport(px, pz, 0);
P.camera.position.set(px, eye == null ? 1.55 : eye, pz);
P.camera.lookAt(new V3(ax, ly == null ? 1.05 : ly, az));
P.chunks.warmup(P.camera.position);
P.camera.updateMatrixWorld(true);
const names = (P.fleet.all || []).map(r => r.pedName);
const cam = P.camera, v = new V3(), rows = [];
for (const c of P.citizens.activeCitizens()) {
if (!c.actor || c.actorKind !== 'rig') continue;
const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z);
if (d < 1.0 || d > 26) continue;
v.set(c.x, 1.0, c.z).project(cam);
if (v.z > 1 || Math.abs(v.x) > 0.92 || Math.abs(v.y) > 0.92) continue;
let lo = 1e9, hi = -1e9;
c.actor.inner.updateWorldMatrix(true, true);
c.actor.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } });
const posedNow = c.loiter > 0;
rows.push({ id: c.id, d: +d.toFixed(1), ped: names[c.pedIndex] || '?',
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow ? 'idle' : 'walk'),
clip: ((c.actor.mixer && c.actor.mixer._actions) || []).filter(x => x.getEffectiveWeight() > 0.5)
.map(x => x.getClip().name)[0] || null,
h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3) });
}
rows.sort((a, b) => a.d - b.d);
return { rows, info: window.DBG.info(), sitters: rows.filter(r => r.state === 'bench-sit').length };
}
"""
# ── interiors ────────────────────────────────────────────────────────────────────────────────────
# Same as R41's INT_FRAME, plus a `from: 'door'` arm: stand at Lane C's own `room.spawn` — the point
# the player is put down on when the door opens — and look at the target from there. R41 shot the
# booth from 2.5 m at eye 1.45 (a product photo); the fault John reported was about the READ from
# where you walk in, so the after has to answer that question in the frame that asks it.
INT_FRAME = r"""
async ({ target, back, eye, from }) => {
const P = window.PROCITY, V3 = P.scene.position.constructor;
const room = P.interiorMode.current;
if (!room) return { ok: false, why: 'no room open' };
const sp = room.spawn || { x: 0, z: 0 };
let tx, tz, ty = 1.15;
if (target === 'people') {
const figs = ((P.interiorMode.keepers || {}).keepers || []).map(k => k.actor.fig);
if (!figs.length) return { ok: false, why: 'no figures in the room' };
tx = 0; tz = 0; for (const f of figs) { tx += f.position.x; tz += f.position.z; }
tx /= figs.length; tz /= figs.length;
} else if (target === 'centre') {
tx = 0; tz = -room.dims.D * 0.12; ty = 1.05;
} else {
let hit = null;
room.group.traverse(o => {
if (hit) return;
const u = o.userData || {};
if (target === 'djBooth' && u.djBooth) hit = o;
});
if (!hit) return { ok: false, why: `no ${target} in this room` };
const THREE = await import('three');
const c = new THREE.Box3().setFromObject(hit).getCenter(new V3());
tx = c.x; tz = c.z; ty = Math.max(0.95, c.y);
}
let px, pz;
if (from === 'door') { px = sp.x; pz = sp.z; } // the player's real view
else {
let dx = sp.x - tx, dz = sp.z - tz; const L = Math.hypot(dx, dz) || 1;
dx /= L; dz /= L;
const d = Math.min(Math.max(back, 1.9), Math.max(L, back));
px = tx + dx * d; pz = tz + dz * d;
}
P.camera.position.set(px, eye, pz);
P.camera.lookAt(new V3(tx, ty, tz));
P.camera.updateMatrixWorld(true);
P.renderer.info.reset();
P.renderer.render(P.interiorMode.scene, P.camera);
return { ok: true, cam: [+px.toFixed(2), +pz.toFixed(2)], target: [+tx.toFixed(2), +tz.toFixed(2)],
spawn: [+sp.x.toFixed(2), +sp.z.toFixed(2)],
standoff: +Math.hypot(px - tx, pz - tz).toFixed(2),
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles,
shop: P.interiorMode.shop, dims: room.dims };
}
"""
# Lane D's §42.4 measure, adopted whole: the gig crew AND the keepers, each resolved to its body.
ROOM_MEASURE = r"""
() => {
const P = window.PROCITY, V3 = P.scene.position.constructor;
const names = (P.fleet.all || []).map(r => r.pedName);
const cam = P.camera; cam.updateMatrixWorld(true);
const v = new V3(); const rows = [];
const add = (a, kind, role, pedIndex) => {
if (!a || !a.inner) { rows.push({ state: kind, role, ped: 'placeholder', h: 0, stature: 0, inFrame: false }); return; }
let lo = 1e9, hi = -1e9;
a.inner.updateWorldMatrix(true, true);
a.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w);
if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } });
v.set(a.fig.position.x, 1.0, a.fig.position.z).project(cam);
rows.push({ state: kind, role, ped: (pedIndex != null && names[pedIndex]) || '?',
h: +(a.height || a.nominalHeight || 0).toFixed(3), stature: +(hi - lo).toFixed(3),
clip: ((a.mixer && a.mixer._actions) || []).filter(x => x.getEffectiveWeight() > 0.5)
.map(x => x.getClip().name)[0] || null,
inFrame: !(v.z > 1 || Math.abs(v.x) > 0.92 || Math.abs(v.y) > 0.92) });
};
const crew = P.interiorMode.crew;
if (crew) for (const m of crew.members) add(m.actor, m.part === 'band' ? 'band' : 'crowd', m.role, m.pedIndex);
for (const k of ((P.interiorMode.keepers || {}).keepers || []))
add(k.actor, k.browse ? 'browser' : 'keeper', k.type, k.pedIndex);
return { rows, info: window.DBG.info() };
}
"""
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():
p = 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): return p
time.sleep(0.1)
p.terminate(); raise SystemExit(f'could not serve on :{PORT}')
def human_line(rows):
"""The R10 line — height-RELATIVE (D's R41 finding: a 1.32 m wall-leaner is not a defect, a 2.4 m
anything is, and a body folded to hip height is the R16 failure this exists to catch)."""
out, bad = [], []
for r in rows:
if r.get('ped') == 'placeholder': continue
h = r.get('h') or 0
ratio = (r['stature'] / h) if h else None
who = r.get('id', r.get('state'))
if r['stature'] > HI: bad.append(f'{who} GIANT {r["stature"]}m')
elif h and r['stature'] < 0.55 * h: bad.append(f'{who} FOLDED {r["stature"]}m of {h}m')
elif h and not (LO <= h <= HI): bad.append(f'{who} nominal {h}m out of [{LO},{HI}]')
out.append(f"{r['state']}" + (f"[{r.get('clip') or r.get('ped')}]" if (r.get('clip') or r.get('ped')) else '')
+ f" {r['stature']}m" + (f" ({ratio:.0%} of its {h}m nominal)" if ratio else ''))
verdict = ('ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal)' if not bad
else 'FAIL: ' + ', '.join(bad))
return (f"HUMAN-SIZED LINE (R10) — {len(out)} figure(s), stature (feet→crown) measured off the "
f"posed skeleton: {' · '.join(out)}. {verdict}."), not bad
def cast_line(rows, label):
"""Lane D's §42.4 idea, adopted. A JPEG cannot say 'the luchador is gone'; this can."""
peds = [r['ped'] for r in rows if r.get('ped') and r['ped'] != 'placeholder']
seen = {}
for p in peds: seen[p] = seen.get(p, 0) + 1
banned = sorted(set(peds) & set(RETIRED))
return (f"CAST ({label}) — {len(peds)} figure(s) wearing {len(seen)} distinct bodies: "
+ ', '.join(f'{k}x{v}' for k, v in sorted(seen.items())) + '. '
+ ('PERIOD LAW (ruling 1): none of the five retired bodies '
f'({", ".join(RETIRED)}) is in this frame.'
if not banned else f'*** RETIRED BODY IN FRAME: {banned} ***')), not banned
def write_shot(pg, name, caption, rows=None, cast_label=None, capture=True):
OUTDIR.mkdir(parents=True, exist_ok=True)
if capture:
pg.screenshot(path=str(OUTDIR / f'{name}.jpg'), type='jpeg', quality=92)
txt = caption
if rows is not None:
line, ok = human_line(rows)
txt += '\n\n' + line
if not ok: FAIL(f'{name}: {line}')
cl, cok = cast_line(rows, cast_label or name)
txt += '\n\n' + cl
if not cok: FAIL(f'{name}: {cl}')
(OUTDIR / f'{name}.txt').write_text(txt + '\n')
OK(f'{name}.jpg + .txt')
def pair(name, before_name, title, before_cap, after_cap):
"""Composite the committed R41 original beside today's frame. `pipeline/montage.py` is Lane E's
tool and this is exactly what it was written for (§42 notes) no second copy of it here."""
b = BEFORE / f'{before_name}.jpg'
a = OUTDIR / f'{name}.jpg'
if not b.exists(): FAIL(f'{name}_ab: the committed R41 before is missing at {b}'); return
if not a.exists(): FAIL(f'{name}_ab: no after frame to pair'); return
out = OUTDIR / f'{name}_ab.jpg'
r = subprocess.run([sys.executable, str(ROOT / 'pipeline' / 'montage.py'), str(out),
'--cols', '2', '--width', '640', '--title', title,
f'{b}:{before_cap}', f'{a}:{after_cap}'], capture_output=True, text=True)
if r.returncode or not out.exists():
FAIL(f'{name}_ab: montage failed — {r.stdout.strip()} {r.stderr.strip()[:200]}')
else:
OK(f'{name}_ab.jpg (R41 committed original | R42 same recipe)')
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=45000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
pg.wait_for_function('() => window.PROCITY.fleet && window.PROCITY.fleet.ready', timeout=25000)
def enter_best(pg, want_type=None, need=None, tries=10):
js = r"""
async ({ want, need, tries }) => {
const P = window.PROCITY, D = window.DBG, C = P.citizens;
const PREF = ['record', 'opshop', 'book', 'video', 'stall', 'pawn'];
const shops = (P.plan.shops || []).filter(s => !want || s.type === want)
.map(s => ({ id: s.id, name: s.name, type: s.type, occ: (C.occupancyOf(s.id) || {}).count || 0 }))
.sort((a, b) => (b.occ - a.occ) || ((PREF.indexOf(a.type) + 9) % 9) - ((PREF.indexOf(b.type) + 9) % 9));
const has = (room) => {
if (!room) return false;
if (need === 'djBooth') { let f = false; room.group.traverse(o => { if ((o.userData || {}).djBooth) f = true; }); return f; }
return true;
};
let best = null;
for (const s of shops.slice(0, tries)) {
D.enterShop(s.id);
await new Promise(r => setTimeout(r, 2600));
const room = P.interiorMode.current;
const ks = ((P.interiorMode.keepers || {}).keepers) || [];
if (room && has(room)) {
best = { ok: true, shop: s, tried: shops.length, keepers: ks.length,
fittings: (room.placement || []).length };
return best;
}
D.exitShop(); await new Promise(r => setTimeout(r, 350));
}
return { ok: false, why: `no ${want || 'shop'} satisfied "${need}"`, tried: shops.length };
}
"""
return pg.evaluate(js, dict(want=want_type, need=need, tries=tries))
# ── 1. THE PUB ────────────────────────────────────────────────────────────────────────────────────
def shot_pub(p):
head('1. pub_furnished — R41\'s hero, re-shot: dressed props, and a cast with no luchador and no nude')
b, pg, errs = new_page(p)
try:
boot(pg, 'gigs=1&weather=0&magpie=0&washing=0')
pg.evaluate("() => window.DBG.setSegment(5)")
pg.wait_for_timeout(1500)
r = enter_best(pg, 'pub', None)
if not r.get('ok'):
FAIL(f"pub_furnished: {r.get('why')}"); return
pg.wait_for_timeout(1800) # let the gig crew resolve its bodies before the cast is read
fr = pg.evaluate(INT_FRAME, {'target': 'centre', 'back': 5.2, 'eye': 1.62, 'from': 'standoff'})
if not fr.get('ok'): FAIL(f"pub_furnished: {fr.get('why')}"); return
m = pg.evaluate(ROOM_MEASURE)
inframe = [x for x in m['rows'] if x.get('inFrame')]
note(f"{r['shop']['name']} · {r['fittings']} fittings · {fr['draws']} draws / {fr['tris']} tris · "
f"{len(m['rows'])} figures ({len(inframe)} in frame)")
cap = (f"PROCITY R42 §42.6 — pub_furnished (AFTER)\n"
f"seed {SEED}, {r['shop']['name']} (pub), segment 5, ?gigs=1 — the SAME recipe as "
f"docs/shots/laneF_r41/pub_furnished.jpg (Lane C's room.spawn line, back 5.2 m, eye 1.62 m), "
f"so the pair differs only by what landed this round.\n"
f"WHAT CHANGED: Lane E §42.1 repainted 46 props in place via a COLOR_0 rewrite (draw delta "
f"+0, tri delta +0, every POSITION/NORMAL/index accessor sha1-identical to R41) — the white "
f"blobs are brown vinyl and beer-carpet red because they always carried vertex colour and it "
f"was bleached to 0.02 saturation, not missing. Lane E §42.3 + Lane D §42.4 swapped five "
f"bodies at their own index: the purple EL CHUPACABRA luchador and the UNCLOTHED dj_phrtt_01 "
f"that stood at the stage edge in the before are both gone from the fleet.\n"
f"{r['fittings']} fittings · {fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris) "
f"· camera {fr['cam']}{fr['target']}.")
write_shot(pg, 'pub_furnished', cap, m['rows'], cast_label='the whole room')
if errs: FAIL(f'pub_furnished: {len(errs)} console error(s): {errs[:2]}')
finally:
b.close()
pair('pub_furnished', 'pub_furnished',
'PROCITY R42 §42.6 — THE PUB: R41 as committed, and the same frame today',
'BEFORE (R41, committed): white plastic-looking props; TWO purple EL CHUPACABRA luchadors; '
'an unclothed body at the stage edge.',
'AFTER (R42): the props are painted (E §42.1, +0 draws), and every body in the room is a '
'person who could stand on a 1996 Sydney street (E §42.3 + D §42.4).')
# ── 2. THE BOOTH ──────────────────────────────────────────────────────────────────────────────────
def shot_booth(p):
head('2. record_dj_booth — R41\'s recipe re-shot, AND the frame R41 never took (from the door)')
b, pg, errs = new_page(p)
try:
boot(pg, 'weather=0&magpie=0&washing=0')
pg.evaluate("() => window.DBG.setSegment(2)")
pg.wait_for_timeout(1200)
r = enter_best(pg, 'record', 'djBooth')
if not r.get('ok'):
FAIL(f"record_dj_booth: {r.get('why')}"); return
# a) the PAIR arm — R41's exact camera recipe, so the before/after is a pair
fr = pg.evaluate(INT_FRAME, {'target': 'djBooth', 'back': 2.5, 'eye': 1.45, 'from': 'standoff'})
if not fr.get('ok'): FAIL(f"record_dj_booth: {fr.get('why')}"); return
m = pg.evaluate(ROOM_MEASURE)
note(f"pair arm: {r['shop']['name']} · {fr['draws']} draws / {fr['tris']} tris · "
f"camera {fr['cam']}{fr['target']} (standoff {fr['standoff']} m)")
write_shot(pg, 'record_dj_booth',
f"PROCITY R42 §42.6 — record_dj_booth (AFTER, R41's own camera recipe)\n"
f"seed {SEED}, {r['shop']['name']} (record), midday. Camera derived exactly as in R41: "
f"2.5 m off the booth group's bounding-box centre along Lane C's room.spawn line, "
f"eye 1.45 m — so this is a PAIR with docs/shots/laneF_r41/record_dj_booth.jpg.\n"
f"WHAT CHANGED: Lane E §42.2a repainted the deck chassis Technics silver (#b9bdc2) — "
f"contrast against the booth bench went 1.21:1 → 6.74:1 — and Lane C §42.2b composed the "
f"booth (bench 3 → 7 boxes, a record bay, headphones, and the booth turned to face the "
f"door: entry-line alignment 49% → 92%). Near-black pixels inside the booth's own screen "
f"rect: 57.5% → 26.7%.\n"
f"{fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris) · camera {fr['cam']} "
f"{fr['target']}.", m['rows'] if m['rows'] else None, cast_label='the record shop')
# b) the DOOR arm — the frame the fault was actually reported about
fr2 = pg.evaluate(INT_FRAME, {'target': 'djBooth', 'back': 0, 'eye': 1.62, 'from': 'door'})
if not fr2.get('ok'): FAIL(f"record_dj_booth_door: {fr2.get('why')}"); return
m2 = pg.evaluate(ROOM_MEASURE)
note(f"door arm: spawn {fr2['spawn']} → booth {fr2['target']} ({fr2['standoff']} m) · "
f"{fr2['draws']} draws / {fr2['tris']} tris")
write_shot(pg, 'record_dj_booth_door',
f"PROCITY R42 §42.6 — record_dj_booth_door (THE PLAYER'S REAL VIEW)\n"
f"seed {SEED}, {r['shop']['name']} (record), midday. The camera stands at Lane C's own "
f"`room.spawn` — the exact spot the player is put down on when the door opens — at eye "
f"height 1.62 m, looking at the booth: {fr2['standoff']} m away, spawn {fr2['spawn']}"
f"booth {fr2['target']}.\n"
f"R41 shot this booth from 2.5 m at eye 1.45 m, which is a product photograph. The fault "
f"John reported — 'it reads as a plain black box' — is a claim about the ENTRY view, so "
f"this is the frame that answers it. C §42.2b moved the booth to face this line "
f"(alignment 49% → 92%).\n"
f"{fr2['draws']} draws of the ≤350 interior law ({fr2['tris']} tris).",
m2['rows'] if m2['rows'] else None, cast_label='the record shop')
if errs: FAIL(f'record_dj_booth: {len(errs)} console error(s): {errs[:2]}')
finally:
b.close()
pair('record_dj_booth', 'record_dj_booth',
'PROCITY R42 §42.6 — THE DJ BOOTH: R41 as committed, and the same camera today',
'BEFORE (R41, committed): the rigged 1200 and the TTM-54i are in there, and at normal '
'viewing distance you cannot tell it is a turntable setup.',
'AFTER (R42): silver chassis (E §42.2a, 1.21:1 → 6.74:1) in a composed booth (C §42.2b) — '
'near-black pixels in the booth crop 57.5% → 26.7%.')
# ── 3. THE STREET ─────────────────────────────────────────────────────────────────────────────────
def shot_street(p):
head('3. street_postures — the recast crowd, and Lane B\'s benches now facing the road')
b, pg, errs = new_page(p)
try:
boot(pg, 'weather=0&magpie=0&washing=0')
pg.evaluate("() => window.DBG.setSegment(2)")
pg.evaluate("() => window.DBG.shot('street_noon')")
# ── THE PAIR FRAME: stand on the BEFORE's own published camera ──────────────────────────────
# The first cut re-ran R41's cluster search and got a different spot each time, because the
# search scores a LIVE crowd — one run landed within 40 cm of R41's camera and the next landed
# 30 m away behind a wall. Re-rolling until it looks right is not a method. The pose is parsed
# out of the committed R41 sidecar (a magic number I typed would be a different claim), so the
# AFTER stands exactly where the BEFORE stood and the only variable in the pair is the tree.
m41 = re.search(r'camera \(([-\d.]+), ([-\d.]+)\) looking at \(([-\d.]+), ([-\d.]+)\)',
(BEFORE / 'street_postures.txt').read_text())
if not m41:
FAIL('street_postures: could not parse the R41 camera out of its committed sidecar'); return
px, pz, ax, az = (float(g) for g in m41.groups())
note(f'R41 published camera, parsed from its sidecar: ({px}, {pz}) → ({ax}, {az})')
sp = pg.evaluate(POSE, dict(px=px, pz=pz, ax=ax, az=az, eye=1.62, ly=1.25))
pg.wait_for_timeout(1500)
sp = pg.evaluate(POSE, dict(px=px, pz=pz, ax=ax, az=az, eye=1.62, ly=1.25))
OUTDIR.mkdir(parents=True, exist_ok=True)
pg.screenshot(path=str(OUTDIR / 'street_postures.jpg'), type='jpeg', quality=92)
states = sorted({r['state'] for r in sp['rows']})
clips = sorted({r['clip'] for r in sp['rows'] if r['clip']})
bodies = sorted({r['ped'] for r in sp['rows']})
cs = pg.evaluate("() => window.PROCITY.citizens.clipStats()")
note(f"{len(sp['rows'])} rigs in frame · states {states} · clips {clips} · bodies {bodies} · "
f"{sp['info']['drawCalls']} draws / {sp['info']['tris']} tris")
write_shot(pg, 'street_postures',
f"PROCITY R42 §42.6 — street_postures (AFTER)\n"
f"seed {SEED}, midday (seg 2). Camera ({px}, {pz}) → ({ax}, {az}) — **R41's own published "
f"pose**, parsed out of docs/shots/laneF_r41/street_postures.txt rather than re-derived. "
f"R41 chose it with a live-crowd cluster search; re-running that search on a live crowd "
f"lands somewhere different every time, so the AFTER stands where the BEFORE stood and the "
f"only variable in the pair is the tree.\n"
f"{len(sp['rows'])} rigs in frame · posture states {states} · {len(clips)} distinct clips "
f"({', '.join(clips)}) · {len(bodies)} distinct bodies · {sp['info']['drawCalls']} draws of "
f"the ≤300 street law ({sp['info']['tris']} tris) · clip bank {cs}.\n"
f"R41 read 173 draws / 43,562 tris at this pose. The recast is draw-neutral by "
f"construction (one primitive, one material per ped — Lane D §42.4); the 802-station pin "
f"reads 0/0 and a single bookmark moves ±1 run to run.\n"
f"NB the rows below count NEAR-TIER RIGS only (R41's convention, kept). A citizen far "
f"enough out to be drawn as a mid-tier billboard has no skeleton and no posture to read, "
f"so a seated figure visible in the picture can be absent from the list — that is what "
f"street_bench.jpg is for.",
sp['rows'], cast_label='the street', capture=False)
# ── THE BENCH FRAME: found by polling, shot in the same beat ────────────────────────────────
STOPS = ['crossroads_busy', 'street_noon', 'crossroads_busy', 'patronage_door', 'night_crowd']
best, bench_shot = None, None
for i in range(72):
if i % 12 == 0:
pg.evaluate(f"() => window.DBG.shot('{STOPS[(i // 12) % len(STOPS)]}')")
pg.evaluate("() => window.DBG.setSegment(2)")
pg.wait_for_timeout(900)
r = pg.evaluate(STREET_LOOK)
if not r.get('ok'):
pg.wait_for_timeout(600); continue
if not best or r['score2'] > best['score2']: best = r
# THE BENCH FRAME, TAKEN IN THE SAME BEAT IT IS FOUND. The first cut computed the pose
# here and shot it after the loop, and came back with 0 figures in frame: the dwell clock
# runs, the sitter gets up, and you photograph an empty bench with a caption about a
# sitter. Measure, pose, capture, re-measure — then move on. (This is the R41 tour's own
# lesson applied one level down; it cost that harness two frames before it was learned.)
if not bench_shot and r['anchor'].startswith('bench-sit'):
bm = pg.evaluate(BENCH_MEASURE)
if bm.get('shot'):
bp = pg.evaluate(POSE, dict(bm['shot'], eye=1.55, ly=1.05))
pg.wait_for_timeout(350)
if bp['sitters']:
pg.screenshot(path=str(OUTDIR / 'street_bench.jpg'), type='jpeg', quality=92)
bench_shot = dict(bm=bm, bp=bp, sh=bm['shot'])
# whether or not it worked, the pose moved the player — put the search back where
# it was so the next poll is not measuring a different street.
pg.evaluate(f"() => window.DBG.shot('{STOPS[(i // 12) % len(STOPS)]}')")
pg.evaluate("() => window.DBG.setSegment(2)")
pg.wait_for_timeout(700)
if len(r['states']) >= 3 and len(r['clips']) >= 3 and r['inFrame'] >= 4 and bench_shot: break
pg.wait_for_timeout(450)
if not bench_shot:
FAIL('street_bench: no citizen sat on a real Lane B bench in 72 polls — no bench frame')
else:
bm, bp, sh = bench_shot['bm'], bench_shot['bp'], bench_shot['sh']
inf = [x for x in bm['rows'] if x['inFrame']]
deg = sorted(x['toRoadDeg'] for x in bm['rows'])
along = sorted(x['alongDeg'] for x in bm['rows'])
if not bm['rows']:
FAIL('street_bench: no bench instance matched a benchStops() station within 2 cm')
elif bm['control'] != 0:
FAIL(f"street_bench: the 3 m-offset control matched {bm['control']} instance(s) — "
f"the 2 cm identification is not a measurement")
else:
mid = deg[len(deg) // 2]
corners = [x for x in bm['rows'] if not x['nearIsOwn']]
note(f"bench yaw: {len(bm['rows'])} of {bm['stations']} stations matched · front-vs-OWN-road "
f"min {deg[0]}° / med {mid}° / max {deg[-1]}° · front-vs-street med "
f"{along[len(along) // 2]}° · control 0 · {len(corners)} bench(es) sit nearer a CROSS "
f"street than their own (max nearest-edge reading {max((x['nearDeg'] for x in corners), default=0)}°)")
if deg[-1] > 5.0:
FAIL(f'street_bench: a bench front is {deg[-1]}° off its own road — B\'s §42.5-A fix is '
f'not what this scene is drawing')
note(f"bench frame: camera ({sh['px']}, {sh['pz']}) → ({sh['ax']}, {sh['az']}) — side-on, "
f"{bp['sitters']} sitter(s) and {len(bp['rows'])} figure(s) in frame · "
f"{bp['info']['drawCalls']} draws")
write_shot(pg, 'street_bench',
f"PROCITY R42 §42.6 — street_bench (Lane B §42.5-A, measured in this frame)\n"
f"seed {SEED}, midday. Camera ({sh['px']}, {sh['pz']}) → ({sh['ax']}, {sh['az']}) — "
f"5 m to the SIDE of the bench and 2.4 m out into the carriageway, deliberately NOT the "
f"cluster search's pose: that one stands on the bench's own front line, and a person "
f"facing the camera looks identical whichever way the bench under them is turned. In "
f"profile, 'the sitter faces the road' is something you can see.\n"
f"THE MEASUREMENT, and it is not B's arithmetic read back: bench instances are "
f"identified by POSITION ONLY (a `benchStops(plan)` station within 2 cm — CONTROL: the "
f"same stations offset 3 m match {bm['control']} of {bm['matched']}), and then the "
f"angle is computed between the instance's own world +Z and the foot of the "
f"perpendicular onto Lane A's road centreline for the edge that station names. "
f"Neither input is the placement rule.\n"
f"{bm['matched']} of {bm['stations']} town-wide stations are standing in the streamed "
f"window ({len(inf)} in this frame). Bench FRONT vs the direction to the road: "
f"min {deg[0]}° · median {deg[len(deg) // 2]}° · max {deg[-1]}°. Front vs the street's "
f"own direction: median {along[len(along) // 2]}°. In R41 those two were 90° and 0°.\n"
f"ONE INSTRUMENT NOTE, recorded rather than tuned away: {len([x for x in bm['rows'] if not x['nearIsOwn']])} "
f"of {bm['matched']} benches stand nearer a CROSS street's centreline than their own, "
f"because a station 14 m from a node is close to the corner. Attributing those to the "
f"geometrically nearest edge reads up to "
f"{max((x['nearDeg'] for x in bm['rows'] if not x['nearIsOwn']), default=0)}° and is "
f"WRONG — the bench correctly faces the street it belongs to. This is the same trap "
f"Lane B named in §42.5-A when they matched instances by expected perpendicular offset.\n"
f"IN THIS FRAME: {bp['sitters']} citizen(s) on a bench, {len(bp['rows'])} figure(s) "
f"total, {bp['info']['drawCalls']} draws of the ≤300 street law ({bp['info']['tris']} tris).",
bp['rows'], cast_label='the bench frame', capture=False)
if errs: FAIL(f'street: {len(errs)} console error(s): {errs[:2]}')
finally:
b.close()
pair('street_postures', 'street_postures',
"PROCITY R42 §42.6 — THE STREET: R41 as committed, and its OWN camera today",
'BEFORE (R41, committed): the R41 roster, and benches standing side-on to the road.',
"AFTER (R42): the same pose, parsed out of the R41 sidecar. Five bodies recast (E §42.3 / "
"D §42.4), bench + streetlight yaw fixed (B §42.5-A, 60/60). See street_bench.jpg for the "
"yaw in profile.")
# ── 4. THE CREDITS ────────────────────────────────────────────────────────────────────────────────
def shot_credits(p):
head('4. credits_panel — ruling 3 on screen: the ped rows filled, no `unverified` left on a body')
b, pg, errs = new_page(p)
try:
boot(pg, 'plansrc=osm&town=katoomba&weather=0&magpie=0&washing=0')
pg.evaluate("() => window.DBG.shot('street_noon')")
pg.wait_for_timeout(600)
link = pg.evaluate("() => { const el = document.getElementById('pc-creditlink'); return el ? el.textContent.trim() : null; }")
pg.keyboard.press('F2')
pg.wait_for_timeout(1200)
# scroll to the ped rows — the panel is long and the rows this round filled are the point
pg.evaluate(r"""() => {
const el = document.getElementById('pc-credits'); if (!el) return;
const box = el.querySelector('*') && el;
const walk = (n) => { for (const c of n.querySelectorAll('*'))
if (/character roster/i.test(c.textContent || '') && c.children.length < 6) return c; return null; };
const t = walk(el); if (t) t.scrollIntoView({ block: 'center' });
}""")
pg.wait_for_timeout(400)
st = pg.evaluate(r"""() => {
const el = document.getElementById('pc-credits');
const t = el ? el.innerText : '';
return { open: !!(el && getComputedStyle(el).display !== 'none'),
entries: (window.PROCITY.credits && window.PROCITY.credits.count) || null,
chars: t.length,
osm: /OpenStreetMap contributors/.test(t), odbl: /ODbL/.test(t),
roster: /character roster/i.test(t),
withdrawn: /withdrawn from the roster/i.test(t),
chIds: /Ch01|Ch49|Ch23|Ch31/.test(t),
unverifiedCount: (t.match(/unverified/gi) || []).length,
pedUnverified: /(character roster|DJ five)[^]{0,600}?unverified/i.test(t) }; }""")
note(f"HUD link {link!r} · panel open {st['open']} · roster row {st['roster']} · withdrawn row "
f"{st['withdrawn']} · Mixamo Ch ids {st['chIds']} · 'unverified' appears {st['unverifiedCount']}× "
f"· {st['chars']} chars")
cap = (f"PROCITY R42 §42.6 — credits_panel (RULING 3, on screen)\n"
f"seed {SEED}, ?plansrc=osm&town=katoomba — an OSM boot, so the ODbL obligation is live and "
f"the HUD link carries it: {link!r}. F2 opens Lane B's §41.5 surface.\n"
f"WHAT CHANGED THIS ROUND: R41 shipped the ped roster as amber `unverified` — B could not "
f"establish the bodies' provenance from anything in this repo. Lane E §42.3-A answered it "
f"from INSIDE the assets: all 24 source FBX carry Original|ApplicationVendor = 'Mixamo, Inc.' "
f"in their own headers, and the four unmerged peds kept Mixamo's numbered character ids in "
f"their material names (Ch43_Body = the luchador), which recovers the id for the other 20. "
f"So the row now reads a real licence with per-asset evidence, `required: false` is correct, "
f"and no attribution is owed. A second row records the four bodies WITHDRAWN on ruling 1 — "
f"'why is that gone' is a question a credits panel should be able to answer.\n"
f"On screen: the roster row ({st['roster']}), the withdrawn row ({st['withdrawn']}), the "
f"Mixamo Ch ids ({st['chIds']}), the © OpenStreetMap contributors credit ({st['osm']}) and "
f"ODbL 1.0 ({st['odbl']}). 'unverified' still appears {st['unverifiedCount']}x in the panel — "
f"on the PROPS row (props-house-library, 18 house props), which is a different, still-open "
f"debt and is deliberately not hidden. NO ped row is unverified: {not st['pedUnverified']}.\n"
f"Zero draws by construction (DOM); credits.json is fetched on first open, not at boot.")
write_shot(pg, 'credits_panel', cap)
if not st['open']: FAIL('credits_panel: the panel did not open on F2')
if not st['roster']: FAIL('credits_panel: the character-roster row is not on screen')
if not st['withdrawn']: FAIL('credits_panel: the withdrawn-bodies row is not on screen')
if st['pedUnverified']: FAIL('credits_panel: a ped row still reads `unverified` — ruling 3 is not met')
if errs: FAIL(f'credits_panel: {len(errs)} console error(s): {errs[:2]}')
finally:
b.close()
def main():
srv = serve()
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
if not ONLY or 'pub' in ONLY: shot_pub(p)
if not ONLY or 'booth' in ONLY: shot_booth(p)
if not ONLY or 'street' in ONLY: shot_street(p)
if not ONLY or 'credits' in ONLY: shot_credits(p)
finally:
srv.terminate()
print('')
if fails:
print(f'\033[31m● FAIL\033[0m — {len(fails)} problem(s):')
for f in fails: print(f' · {f}')
return 1
print(f'\033[32m● PASS\033[0m — the tour is in {OUTDIR}, every humanoid frame carrying its R10 line '
f'and its CAST line, every pair composited against the committed R41 original')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@ -63,17 +63,16 @@
}, },
{ {
"id": "ped-bodies-base", "id": "ped-bodies-base",
"name": "Citizen bodies — the base roster (17 normal + 2 comical)", "name": "Citizen bodies — the character roster",
"kind": "mesh", "kind": "mesh",
"licence": "unverified", "licence": "Adobe Mixamo royalty-free licence (use inside a game is permitted; redistributing the characters as assets is not)",
"attribution": null, "attribution": null,
"required": false, "required": false,
"source": "Copied byte-identical from the house game 90sDJsim (web/world/models/peds/); the canonical originals live outside this repo.", "source": "Adobe Mixamo numbered character pack (Ch01Ch49), downloaded as FBX to John's sorted library and converted by pipeline/ped_from_fbx.py → gltf-transform weld/simplify → pipeline/merge_ped.py. Resolved per asset in R42 §42.3: every source FBX carries Original|ApplicationVendor = 'Mixamo, Inc.' and Original|ApplicationName = 'mixamo.com' in its own FBX header, and each ped's material names carry its Mixamo character id (man_casual_01 = Ch01_Shirt, comical_luchador_01 = Ch43_Body, and so on for all 24).",
"used_for": "Every walking citizen you meet on the street and every shopkeeper behind a counter.", "used_for": "Every walking citizen you meet on the street and every shopkeeper behind a counter.",
"count": 19, "count": 24,
"files": ["models/peds/man_*.glb", "models/peds/woman_*.glb", "models/peds/comical_*.glb"], "files": ["models/peds/man_*.glb", "models/peds/woman_*.glb", "models/peds/person_*.glb", "models/peds/comical_*.glb"],
"evidence": "unverified", "evidence": "E-progress.md §42.3 — the per-asset audit table (file → Mixamo Ch id → in-file vendor string) · docs/LANES/LANE_E_NOTES.md §42.3",
"flag": "NO PER-ASSET LICENCE RECORD EXISTS IN THIS REPO. docs/CITY_SPEC.md ('NPC contract') declares the roster's zone as 'web-ok only (Mixamo, CC0/CC-BY, CGTrader RF)' — and CC-BY and CGTrader RF can both carry an attribution obligation, so if any one of these 19 is CC-BY the credit is owed and unrecorded. Lane E: resolve per asset against the 90sDJsim/character_kit sources before any public release.",
"lane": "E" "lane": "E"
}, },
{ {
@ -88,6 +87,22 @@
"count": 5, "count": 5,
"files": ["models/peds/dj_*.glb", "models/peds/woman_dj_*.glb"], "files": ["models/peds/dj_*.glb", "models/peds/woman_dj_*.glb"],
"evidence": "docs/LANES/LANE_E_NOTES.md — provenance table, row 'Ped bodies ⟨v7.0⟩' · pipeline/AUDIT.md 'Round 36 wave 1'", "evidence": "docs/LANES/LANE_E_NOTES.md — provenance table, row 'Ped bodies ⟨v7.0⟩' · pipeline/AUDIT.md 'Round 36 wave 1'",
"flag": "LICENCE IS FINE; ONE ASSET IS NOT SHIPPABLE. R42 §42.3 rendered all five and found dj_phrtt_01.glb is an unclothed body — its single 1024² texture is a bare-skin atlas, there is no garment on the asset at all. It is admitted to the crowd on every non-?classic boot (loadPedFleet opts.djs defaults to `dance`) and it is live on the shared depot. Lane E has staged its replacement (man_smart_01.glb, Mixamo Ch23, same contract); Lane D removes it from PED_NAMES.djs in §42.4. Withdraw the depot copy at the same time.",
"lane": "E"
},
{
"id": "ped-bodies-withdrawn",
"name": "Citizen bodies withdrawn from the roster (R42 period audit)",
"kind": "mesh",
"licence": "Adobe Mixamo royalty-free licence (same as the roster they came from)",
"attribution": null,
"required": false,
"source": "Adobe Mixamo Ch39 / Ch49 / Ch09 / Ch43. Recorded here rather than deleted quietly, because 'why is that gone' is a question a credits panel should be able to answer.",
"used_for": "Nothing, by ruling. They failed R42's period law (PROCITY is 1990s Australia): a robed fantasy elder, a WW2 infantryman, a cartoon child at three-heads-tall, and a masked luchador. Their replacements are in the roster row above.",
"count": 4,
"files": ["models/peds/man_elder_01.glb", "models/peds/man_soldier_ww2_01.glb", "models/peds/comical_boy_01.glb", "models/peds/comical_luchador_01.glb"],
"evidence": "E-progress.md §42.3 audit table · docs/shots/laneE/r42_roster_rejects.jpg",
"flag": "STILL PRESENT IN THE TREE AND STILL IN PED_NAMES. Lane E deliberately did not delete them: rigs.js references them by literal name, and removing the files mid-round would shorten the fleet, renumber pickRig and move Lane F's crowd goldens while §42.4 (the wiring wave) has not run. Lane D removes them from PED_NAMES in §42.4; the files and their depot copies go with that change.",
"lane": "E" "lane": "E"
}, },
{ {

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Some files were not shown because too many files have changed in this diff Show More