diff --git a/B-progress.md b/B-progress.md index ee13943..2068dfb 100644 --- a/B-progress.md +++ b/B-progress.md @@ -4,6 +4,131 @@ 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. +## 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** (41–49 +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 38–51 ms while the **first `fillText` in the process** +is at 89–109 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.7–110.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 361–418 — 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 Full measurement detail in `docs/LANES/LANE_B_NOTES.md` §41. Everything below: today's tree, headless diff --git a/C-progress.md b/C-progress.md index 5d09e05..43a67b8 100644 --- a/C-progress.md +++ b/C-progress.md @@ -3,7 +3,277 @@ *Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.* -Last updated: 2026-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 — 197−123 = **+74** on +F's harness, 196−122 = **+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 7–9, + restored 6–8, 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 | **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 1–4 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 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. diff --git a/D-progress.md b/D-progress.md index f567603..b686c3e 100644 --- a/D-progress.md +++ b/D-progress.md @@ -1,5 +1,281 @@ # 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 (toe−heel Δ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.687–1.728 m · +0.586 m | 64 · 0.8 s · 47.2 · 1.761–1.799 m · +0.583 m | +| `person_casual_01` | 64 · 0.8 s · 81.9 · 1.663–1.722 m · +0.580 m | 64 · 0.8 s · 45.4 · 1.754–1.783 m · +0.578 m | +| `man_smart_01` | 64 · 0.8 s · 84.3 · 1.667–1.722 m · +0.541 m | 64 · 0.8 s · 46.9 · 1.748–1.786 m · +0.539 m | +| `woman_smart_02` | 64 · 0.8 s · 83.6 · 1.652–1.721 m · +0.567 m | 64 · 0.8 s · 45.8 · 1.746–1.775 m · +0.565 m | +| `person_youth_01` | 64 · 0.8 s · 83.9 · 1.662–1.731 m · +0.534 m | 64 · 0.8 s · 47.0 · 1.736–1.765 m · +0.529 m | +| `man_casual_01` (control) | 64 · 0.8 s · 85.9 · 1.678–1.727 m · +0.577 m | 64 · 0.8 s · 47.8 · 1.764–1.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 ~173–180 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 17–21. 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 16–25 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 16–25 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.470–2.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) **The measurement that is the round.** A census of the live crowd at the retail heart, 12 samples × diff --git a/E-progress.md b/E-progress.md index f2094c1..371c36a 100644 --- a/E-progress.md +++ b/E-progress.md @@ -11,6 +11,255 @@ > 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.)* +## 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 2–5 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 5–12 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 *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`. 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`. + +--- + +## 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__.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 # 50–59k -> 2.5–3.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 494–2 956, inside the shipped 2 067–4 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/) 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`. diff --git a/F-progress.md b/F-progress.md index 2e59116..5f89b7c 100644 --- a/F-progress.md +++ b/F-progress.md @@ -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/.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/, 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 49–60 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 **53–62 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 ` 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,509–2,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/`, `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,067–3,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 *Full runbook detail (gate arms, the control transcripts, the A/B method): [LANE_F_NOTES §41](docs/LANES/LANE_F_NOTES.md). diff --git a/README.md b/README.md index d00b52b..34974c2 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ master off: | `?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 | | `?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 | | `?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 | @@ -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 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 -no-giants gate at the seated stature band `[0.9,1.5] m`. +default-boot gate + smokes + no-giants) `--soak` (the full-week soak) `--matrix` (the town matrix) +`--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 diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index 7c1e3ba..2977b6f 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -1,5 +1,260 @@ # 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: **41–49 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.7–110.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 **361–418** — 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 All numbers this section: today's tree, headless Chromium, **fresh contexts**, port-isolated diff --git a/docs/LANES/LANE_C_NOTES.md b/docs/LANES/LANE_C_NOTES.md index d250062..6da34aa 100644 --- a/docs/LANES/LANE_C_NOTES.md +++ b/docs/LANES/LANE_C_NOTES.md @@ -1,9 +1,88 @@ # 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 (197−123 and 196−122), 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. 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 diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index 4e751d6..dfadacb 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -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.470–2.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 16–25 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 Summary in `D-progress.md` §Round 41; this is the measured detail. Lane E's `web/models/clips/*.glb` diff --git a/docs/LANES/LANE_E_NOTES.md b/docs/LANES/LANE_E_NOTES.md index c2611c8..dc03991 100644 --- a/docs/LANES/LANE_E_NOTES.md +++ b/docs/LANES/LANE_E_NOTES.md @@ -1,5 +1,66 @@ # 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 ` 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⟩ 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. - **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. + +--- + +## 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:` | `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__.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. diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md index 48c56fc..35545fc 100644 --- a/docs/LANES/LANE_F_NOTES.md +++ b/docs/LANES/LANE_F_NOTES.md @@ -3000,6 +3000,226 @@ favour by 141 draws. **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` — 53–62 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 ` 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/` · `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 n−1 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. *(PROCITY-F, 2026-08-07. Five lanes landed first; F consumed. Every number here is from a command run diff --git a/docs/shots/laneB_r42/README.md b/docs/shots/laneB_r42/README.md new file mode 100644 index 0000000..fd94bbd --- /dev/null +++ b/docs/shots/laneB_r42/README.md @@ -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. diff --git a/docs/shots/laneB_r42/bench_geometry_ab.jpg b/docs/shots/laneB_r42/bench_geometry_ab.jpg new file mode 100644 index 0000000..929e601 Binary files /dev/null and b/docs/shots/laneB_r42/bench_geometry_ab.jpg differ diff --git a/docs/shots/laneB_r42/bench_seated_after.jpg b/docs/shots/laneB_r42/bench_seated_after.jpg new file mode 100644 index 0000000..ccf4a0b Binary files /dev/null and b/docs/shots/laneB_r42/bench_seated_after.jpg differ diff --git a/docs/shots/laneB_r42/bench_seated_after_dwired.jpg b/docs/shots/laneB_r42/bench_seated_after_dwired.jpg new file mode 100644 index 0000000..ec513ce Binary files /dev/null and b/docs/shots/laneB_r42/bench_seated_after_dwired.jpg differ diff --git a/docs/shots/laneB_r42/bench_seated_before.jpg b/docs/shots/laneB_r42/bench_seated_before.jpg new file mode 100644 index 0000000..c811f4b Binary files /dev/null and b/docs/shots/laneB_r42/bench_seated_before.jpg differ diff --git a/docs/shots/laneB_r42/bench_shots.json b/docs/shots/laneB_r42/bench_shots.json new file mode 100644 index 0000000..2bba0a9 --- /dev/null +++ b/docs/shots/laneB_r42/bench_shots.json @@ -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" + } +} \ No newline at end of file diff --git a/docs/shots/laneB_r42/streetlight_night_ab.jpg b/docs/shots/laneB_r42/streetlight_night_ab.jpg new file mode 100644 index 0000000..a5e736f Binary files /dev/null and b/docs/shots/laneB_r42/streetlight_night_ab.jpg differ diff --git a/docs/shots/laneC/r42_dj_booth_arms.png b/docs/shots/laneC/r42_dj_booth_arms.png new file mode 100644 index 0000000..a20a11e Binary files /dev/null and b/docs/shots/laneC/r42_dj_booth_arms.png differ diff --git a/docs/shots/laneC/r42_dj_booth_before_after.png b/docs/shots/laneC/r42_dj_booth_before_after.png new file mode 100644 index 0000000..9db5504 Binary files /dev/null and b/docs/shots/laneC/r42_dj_booth_before_after.png differ diff --git a/docs/shots/laneC/r42_dj_booth_door.png b/docs/shots/laneC/r42_dj_booth_door.png new file mode 100644 index 0000000..33cecc5 Binary files /dev/null and b/docs/shots/laneC/r42_dj_booth_door.png differ diff --git a/docs/shots/laneC/r42_dj_booth_door_ab.png b/docs/shots/laneC/r42_dj_booth_door_ab.png new file mode 100644 index 0000000..8f5b3a2 Binary files /dev/null and b/docs/shots/laneC/r42_dj_booth_door_ab.png differ diff --git a/docs/shots/laneC/r42_dj_booth_variants.png b/docs/shots/laneC/r42_dj_booth_variants.png new file mode 100644 index 0000000..a680f29 Binary files /dev/null and b/docs/shots/laneC/r42_dj_booth_variants.png differ diff --git a/docs/shots/laneC/r42_record_shop.png b/docs/shots/laneC/r42_record_shop.png new file mode 100644 index 0000000..2393d2c Binary files /dev/null and b/docs/shots/laneC/r42_record_shop.png differ diff --git a/docs/shots/laneD/r41_browse_interior.jpg b/docs/shots/laneD/r41_browse_interior.jpg index cdcd411..97b5108 100644 Binary files a/docs/shots/laneD/r41_browse_interior.jpg and b/docs/shots/laneD/r41_browse_interior.jpg differ diff --git a/docs/shots/laneD/r41_browse_interior.txt b/docs/shots/laneD/r41_browse_interior.txt index f2e071e..0c7db9e 100644 --- a/docs/shots/laneD/r41_browse_interior.txt +++ b/docs/shots/laneD/r41_browse_interior.txt @@ -1,5 +1,6 @@ PROCITY R41 §41.3 — r41_browse_interior.jpg -seed 20261990 · The Op Shop (opshop) · 1 patronage occupants · 3 browse points -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]}] -draws 69 / interior budget 350 · tris 36223 -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). +seed 20261990 · Echo Toys & Games (toy) · 1 patronage occupants at entry · 3 browse points · 1 browsers +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. +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]}] +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). diff --git a/docs/shots/laneD/r41_street_postures.jpg b/docs/shots/laneD/r41_street_postures.jpg index 95dbebe..36332a5 100644 Binary files a/docs/shots/laneD/r41_street_postures.jpg and b/docs/shots/laneD/r41_street_postures.jpg differ diff --git a/docs/shots/laneD/r41_street_postures.txt b/docs/shots/laneD/r41_street_postures.txt index 514c8ec..e7cf00f 100644 --- a/docs/shots/laneD/r41_street_postures.txt +++ b/docs/shots/laneD/r41_street_postures.txt @@ -1,6 +1,6 @@ PROCITY R41 §41.3 — r41_street_postures.jpg -seed 20261990 · synthetic · MIDDAY · camera (11.6, 58.5) yaw -1.890 -8 near-tier rigs in frame · states {'walk': 7, 'bench-sit': 1} · 3 distinct clips: @walk, sit_hands_thighs, walk_shopping_bag -draws 126 / budget 300 · tris 52858 +seed 20261990 · synthetic · MIDDAY · camera (13.5, 68.3) yaw -0.876 +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 130 / budget 300 · tris 48176 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). diff --git a/docs/shots/laneD/r42_cast.json b/docs/shots/laneD/r42_cast.json new file mode 100644 index 0000000..9dae817 --- /dev/null +++ b/docs/shots/laneD/r42_cast.json @@ -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 + } + } +} \ No newline at end of file diff --git a/docs/shots/laneD/r42_impostor_boundary.jpg b/docs/shots/laneD/r42_impostor_boundary.jpg new file mode 100644 index 0000000..5b64765 Binary files /dev/null and b/docs/shots/laneD/r42_impostor_boundary.jpg differ diff --git a/docs/shots/laneD/r42_pub_crowd.jpg b/docs/shots/laneD/r42_pub_crowd.jpg new file mode 100644 index 0000000..e620e2a Binary files /dev/null and b/docs/shots/laneD/r42_pub_crowd.jpg differ diff --git a/docs/shots/laneD/r42_pub_crowd.txt b/docs/shots/laneD/r42_pub_crowd.txt new file mode 100644 index 0000000..8410130 --- /dev/null +++ b/docs/shots/laneD/r42_pub_crowd.txt @@ -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). diff --git a/docs/shots/laneD/r42_street_crowd.jpg b/docs/shots/laneD/r42_street_crowd.jpg new file mode 100644 index 0000000..4b00f27 Binary files /dev/null and b/docs/shots/laneD/r42_street_crowd.jpg differ diff --git a/docs/shots/laneD/r42_street_crowd.txt b/docs/shots/laneD/r42_street_crowd.txt new file mode 100644 index 0000000..396a3f8 --- /dev/null +++ b/docs/shots/laneD/r42_street_crowd.txt @@ -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). diff --git a/docs/shots/laneE/r42_deck_before_after.png b/docs/shots/laneE/r42_deck_before_after.png new file mode 100644 index 0000000..b3c3f74 Binary files /dev/null and b/docs/shots/laneE/r42_deck_before_after.png differ diff --git a/docs/shots/laneE/r42_deck_diagnosis.png b/docs/shots/laneE/r42_deck_diagnosis.png new file mode 100644 index 0000000..3ce96d4 Binary files /dev/null and b/docs/shots/laneE/r42_deck_diagnosis.png differ diff --git a/docs/shots/laneE/r42_deck_isolated_ab.png b/docs/shots/laneE/r42_deck_isolated_ab.png new file mode 100644 index 0000000..19264f3 Binary files /dev/null and b/docs/shots/laneE/r42_deck_isolated_ab.png differ diff --git a/docs/shots/laneE/r42_pub_ab_top5.png b/docs/shots/laneE/r42_pub_ab_top5.png new file mode 100644 index 0000000..4077501 Binary files /dev/null and b/docs/shots/laneE/r42_pub_ab_top5.png differ diff --git a/docs/shots/laneE/r42_pub_contact_39.png b/docs/shots/laneE/r42_pub_contact_39.png new file mode 100644 index 0000000..b3b0e72 Binary files /dev/null and b/docs/shots/laneE/r42_pub_contact_39.png differ diff --git a/docs/shots/laneE/r42_roster_contact.jpg b/docs/shots/laneE/r42_roster_contact.jpg new file mode 100644 index 0000000..a7e7cd2 Binary files /dev/null and b/docs/shots/laneE/r42_roster_contact.jpg differ diff --git a/docs/shots/laneE/r42_roster_rejects.jpg b/docs/shots/laneE/r42_roster_rejects.jpg new file mode 100644 index 0000000..4fca215 Binary files /dev/null and b/docs/shots/laneE/r42_roster_rejects.jpg differ diff --git a/docs/shots/laneF_r42/README.md b/docs/shots/laneF_r42/README.md new file mode 100644 index 0000000..060075b --- /dev/null +++ b/docs/shots/laneF_r42/README.md @@ -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 53–62 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. diff --git a/docs/shots/laneF_r42/credits_panel.jpg b/docs/shots/laneF_r42/credits_panel.jpg new file mode 100644 index 0000000..29927ef Binary files /dev/null and b/docs/shots/laneF_r42/credits_panel.jpg differ diff --git a/docs/shots/laneF_r42/credits_panel.txt b/docs/shots/laneF_r42/credits_panel.txt new file mode 100644 index 0000000..94b0a7b --- /dev/null +++ b/docs/shots/laneF_r42/credits_panel.txt @@ -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. diff --git a/docs/shots/laneF_r42/pub_furnished.jpg b/docs/shots/laneF_r42/pub_furnished.jpg new file mode 100644 index 0000000..b39a3bc Binary files /dev/null and b/docs/shots/laneF_r42/pub_furnished.jpg differ diff --git a/docs/shots/laneF_r42/pub_furnished.txt b/docs/shots/laneF_r42/pub_furnished.txt new file mode 100644 index 0000000..9b34a4e --- /dev/null +++ b/docs/shots/laneF_r42/pub_furnished.txt @@ -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. diff --git a/docs/shots/laneF_r42/pub_furnished_ab.jpg b/docs/shots/laneF_r42/pub_furnished_ab.jpg new file mode 100644 index 0000000..9d71d79 Binary files /dev/null and b/docs/shots/laneF_r42/pub_furnished_ab.jpg differ diff --git a/docs/shots/laneF_r42/record_dj_booth.jpg b/docs/shots/laneF_r42/record_dj_booth.jpg new file mode 100644 index 0000000..e9ba70c Binary files /dev/null and b/docs/shots/laneF_r42/record_dj_booth.jpg differ diff --git a/docs/shots/laneF_r42/record_dj_booth.txt b/docs/shots/laneF_r42/record_dj_booth.txt new file mode 100644 index 0000000..3816ffa --- /dev/null +++ b/docs/shots/laneF_r42/record_dj_booth.txt @@ -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. diff --git a/docs/shots/laneF_r42/record_dj_booth_ab.jpg b/docs/shots/laneF_r42/record_dj_booth_ab.jpg new file mode 100644 index 0000000..b6ad44c Binary files /dev/null and b/docs/shots/laneF_r42/record_dj_booth_ab.jpg differ diff --git a/docs/shots/laneF_r42/record_dj_booth_door.jpg b/docs/shots/laneF_r42/record_dj_booth_door.jpg new file mode 100644 index 0000000..8ed5117 Binary files /dev/null and b/docs/shots/laneF_r42/record_dj_booth_door.jpg differ diff --git a/docs/shots/laneF_r42/record_dj_booth_door.txt b/docs/shots/laneF_r42/record_dj_booth_door.txt new file mode 100644 index 0000000..d34e699 --- /dev/null +++ b/docs/shots/laneF_r42/record_dj_booth_door.txt @@ -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. diff --git a/docs/shots/laneF_r42/shot_goldens.json b/docs/shots/laneF_r42/shot_goldens.json new file mode 100644 index 0000000..e0c27e8 --- /dev/null +++ b/docs/shots/laneF_r42/shot_goldens.json @@ -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 --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" + } +} diff --git a/docs/shots/laneF_r42/street_bench.jpg b/docs/shots/laneF_r42/street_bench.jpg new file mode 100644 index 0000000..f8bd8cd Binary files /dev/null and b/docs/shots/laneF_r42/street_bench.jpg differ diff --git a/docs/shots/laneF_r42/street_bench.txt b/docs/shots/laneF_r42/street_bench.txt new file mode 100644 index 0000000..7da1f82 --- /dev/null +++ b/docs/shots/laneF_r42/street_bench.txt @@ -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. diff --git a/docs/shots/laneF_r42/street_postures.jpg b/docs/shots/laneF_r42/street_postures.jpg new file mode 100644 index 0000000..81b2854 Binary files /dev/null and b/docs/shots/laneF_r42/street_postures.jpg differ diff --git a/docs/shots/laneF_r42/street_postures.txt b/docs/shots/laneF_r42/street_postures.txt new file mode 100644 index 0000000..80ffb26 --- /dev/null +++ b/docs/shots/laneF_r42/street_postures.txt @@ -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. diff --git a/docs/shots/laneF_r42/street_postures_ab.jpg b/docs/shots/laneF_r42/street_postures_ab.jpg new file mode 100644 index 0000000..97228d8 Binary files /dev/null and b/docs/shots/laneF_r42/street_postures_ab.jpg differ diff --git a/pipeline/README.md b/pipeline/README.md index b9ed59b..9643132 100644 --- a/pipeline/README.md +++ b/pipeline/README.md @@ -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`. **R41 ships one licence regime: Mixamo.** CMU/100STYLE/ACCAD are CC-BY — they wait on 41.5's credits 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/.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. diff --git a/pipeline/_published.json b/pipeline/_published.json index e031424..f7de7fa 100644 --- a/pipeline/_published.json +++ b/pipeline/_published.json @@ -6,6 +6,10 @@ "dj_phrtt_01.glb", "dj_techno_01.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_arcade_cabinet_01.glb", "procity_fit_ash_urn_01.glb", @@ -167,5 +171,6 @@ "vintage-cash-register.glb", "woman_dj_01.glb", "woman_dj_02.glb", - "woman_dj_03.glb" + "woman_dj_03.glb", + "woman_smart_02.glb" ] \ No newline at end of file diff --git a/pipeline/_r42_publish_list.txt b/pipeline/_r42_publish_list.txt new file mode 100644 index 0000000..d74de4b --- /dev/null +++ b/pipeline/_r42_publish_list.txt @@ -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 diff --git a/pipeline/_r42_sha1.json b/pipeline/_r42_sha1.json new file mode 100644 index 0000000..207de89 --- /dev/null +++ b/pipeline/_r42_sha1.json @@ -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 + } +} \ No newline at end of file diff --git a/pipeline/contact_sheet.py b/pipeline/contact_sheet.py index ed41514..818195a 100644 --- a/pipeline/contact_sheet.py +++ b/pipeline/contact_sheet.py @@ -17,6 +17,9 @@ each asset as the ruler. Options (after OUT.png, in any order among the file list): --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) --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 @@ -28,6 +31,7 @@ ARGV = sys.argv[sys.argv.index("--") + 1:] OUT = ARGV[0] rest = ARGV[1:] COLS = TILE = None +PITCH_OPT = None FIT = False TITLE = "" files = [] @@ -36,6 +40,8 @@ while i < len(rest): a = rest[i] if a == "--cols": COLS = int(rest[i + 1]); i += 2 + elif a == "--pitch": + PITCH_OPT = float(rest[i + 1]); i += 2 elif a == "--tile": TILE = int(rest[i + 1]); i += 2 elif a == "--title": @@ -92,7 +98,7 @@ def main(): wipe() scn = bpy.context.scene 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, # 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. @@ -205,9 +211,15 @@ def main(): cam.data.ortho_scale = max(need_h, need_w * res_y / res_x) if TITLE: - # well clear of row 0: tall assets project upward in screen space from y=0, so a title - # parked at +0.6 m lands on top of them. - label(TITLE, W / 2, PITCH_Y * 0.72 + tall, PITCH * 0.085, (0.05, 0.05, 0.1, 1)) + # SOLVE for the title's world Y instead of guessing a metre offset. A camera rotated theta + # about X puts a point at screen-vertical (Y - tgt.y)*cos(theta) + (z - tgt.z)*sin(theta); + # 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. engines = scn.bl_rna.properties["render"].fixed_type.properties["engine"].enum_items.keys() \ diff --git a/pipeline/isolate.py b/pipeline/isolate.py new file mode 100644 index 0000000..db8c8e0 --- /dev/null +++ b/pipeline/isolate.py @@ -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() diff --git a/pipeline/montage.py b/pipeline/montage.py new file mode 100644 index 0000000..64b06be --- /dev/null +++ b/pipeline/montage.py @@ -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()) diff --git a/pipeline/palette.py b/pipeline/palette.py new file mode 100644 index 0000000..1e6f880 --- /dev/null +++ b/pipeline/palette.py @@ -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()) diff --git a/pipeline/ped_from_fbx.py b/pipeline/ped_from_fbx.py new file mode 100644 index 0000000..bcc5db1 --- /dev/null +++ b/pipeline/ped_from_fbx.py @@ -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:` 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}") diff --git a/pipeline/ped_sheet.py b/pipeline/ped_sheet.py new file mode 100644 index 0000000..382932c --- /dev/null +++ b/pipeline/ped_sheet.py @@ -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() diff --git a/pipeline/r42_palette.json b/pipeline/r42_palette.json new file mode 100644 index 0000000..1c27931 --- /dev/null +++ b/pipeline/r42_palette.json @@ -0,0 +1,1381 @@ +{ + "_": "PROCITY R42 \u00a742.1+\u00a742.2a \u2014 the 1990s Australian pub / cafe / milk bar palette.", + "_law": "Every asset is listed BY NAME (R41 ruling 3 / the permanent deny-list). No wildcards.", + "_route": "ramp = luminance->palette (bleached-grey sources) \u00b7 lut = exact colour->part (DJ gear) \u00b7 tint = keep hue, force sat/value \u00b7 huesplit = two ramps by source hue \u00b7 factor = baseColorFactor on a textured source.", + "_cost": "COLOR_0 is rewritten in place: same accessor, same element count, same byte length. 0 tri delta, 0 draw delta on every entry.", + "palette": { + "carpet_red": "#7a2233", + "burgundy": "#5e1f2a", + "maroon_dk": "#2a0e14", + "brown_vinyl": "#6b4526", + "tan_vinyl": "#9a7040", + "worn_tan": "#b99464", + "dark_stain": "#2e1c12", + "mid_timber": "#6e4a2a", + "pine": "#b08a54", + "laminex": "#dcd0ae", + "laminex_gr": "#b6b2a2", + "brass": "#a97f2c", + "brass_lt": "#d2a64a", + "chrome": "#c6cbd0", + "stainless": "#98a0a6", + "steel_dk": "#5a6066", + "cream": "#e6dcc2", + "hotel_green": "#2f4a3a", + "beer_gold": "#c98b22", + "amber_lit": "#e8b44a", + "fire_red": "#a81e14", + "charcoal": "#26262a", + "soot": "#141416", + "terracotta": "#9c5330", + "foliage": "#4c6b36", + "concrete": "#8e8a80", + "galv": "#9ea5aa", + "sl_silver": "#b9bdc2" + }, + "assets": { + "old_couch": { + "file": "procity_fit_old_couch_01.glb", + "route": "ramp", + "note": "brown vinyl 3-seater, worn on the cushion crowns. Screen-area #1 in the pub.", + "stops": [ + [ + 0.0, + "#1e1208" + ], + [ + 0.35, + "#402a15" + ], + [ + 0.7, + "#6b4526" + ], + [ + 1.0, + "#8d6539" + ] + ], + "roughness": 0.78, + "metallic": 0.0 + }, + "jukebox": { + "file": "procity_fit_jukebox_01.glb", + "route": "ramp", + "note": "dark cabinet, deep-red selector panel, brass arch, lit amber dome.", + "stops": [ + [ + 0.0, + "#191310" + ], + [ + 0.3, + "#2e1c12" + ], + [ + 0.52, + "#7a2233" + ], + [ + 0.78, + "#9a7228" + ], + [ + 1.0, + "#dcb15c" + ] + ], + "roughness": 0.5, + "metallic": 0.12 + }, + "banquette": { + "file": "procity_fit_banquette_01.glb", + "route": "ramp", + "note": "burgundy buttoned vinyl. The source is a 1990s-teal that reads as office furniture.", + "stops": [ + [ + 0.0, + "#2a0e14" + ], + [ + 0.5, + "#5e1f2a" + ], + [ + 1.0, + "#8f3c47" + ] + ], + "roughness": 0.62, + "metallic": 0.0 + }, + "plywood_bar": { + "file": "procity_fit_plywood_bar_01.glb", + "route": "ramp", + "note": "stained ply front, timber bar top, brass foot rail.", + "stops": [ + [ + 0.0, + "#1b1108" + ], + [ + 0.4, + "#432a14" + ], + [ + 0.72, + "#6e4a26" + ], + [ + 1.0, + "#9c7440" + ] + ], + "roughness": 0.68, + "metallic": 0.06 + }, + "bistro_table": { + "file": "procity_fit_bistro_table_01.glb", + "route": "ramp", + "note": "cream laminex top on a dark pedestal, timber chairs.", + "stops": [ + [ + 0.0, + "#1e1208" + ], + [ + 0.45, + "#4e3218" + ], + [ + 0.78, + "#8a6236" + ], + [ + 1.0, + "#dcd0ae" + ] + ], + "roughness": 0.55, + "metallic": 0.0 + }, + "pokie": { + "file": "procity_fit_pokie_01.glb", + "route": "tint", + "note": "already carries lit artwork (sat 0.45) \u2014 deepen it instead of flattening it to a ramp.", + "sat_mul": 1.45, + "hue_rot": -6, + "val": [ + 0.1, + 0.94 + ], + "gamma": 1.0, + "roughness": 0.42, + "metallic": 0.1 + }, + "bain_marie": { + "file": "procity_fit_bain_marie_01.glb", + "route": "ramp", + "note": "stainless tray unit under a warm servery lamp.", + "stops": [ + [ + 0.0, + "#2f3438" + ], + [ + 0.45, + "#7b838a" + ], + [ + 0.8, + "#b9c0c5" + ], + [ + 1.0, + "#e8c070" + ] + ], + "roughness": 0.32, + "metallic": 0.45 + }, + "pie_warmer": { + "file": "procity_fit_pie_warmer_01.glb", + "route": "ramp", + "note": "stainless + glass box, pies glowing amber inside.", + "stops": [ + [ + 0.0, + "#2b2f32" + ], + [ + 0.4, + "#6e767c" + ], + [ + 0.72, + "#aeb5ba" + ], + [ + 1.0, + "#e0a94e" + ] + ], + "roughness": 0.3, + "metallic": 0.38 + }, + "keg": { + "file": "procity_fit_keg_01.glb", + "route": "factor", + "note": "the only pub prop with a real texture and no COLOR_0 \u2014 tint the map instead.", + "factor": "#b7c0c6", + "roughness": 0.3, + "metallic": 0.62 + }, + "lounge_chair": { + "file": "procity_fit_lounge_chair_01.glb", + "route": "ramp", + "note": "worn red-brown vinyl tub chair, timber feet.", + "stops": [ + [ + 0.0, + "#25130f" + ], + [ + 0.4, + "#5a2f24" + ], + [ + 0.72, + "#7f4a34" + ], + [ + 1.0, + "#a3714f" + ] + ], + "roughness": 0.8, + "metallic": 0.0 + }, + "ash_urn": { + "file": "procity_fit_ash_urn_01.glb", + "route": "ramp", + "note": "chrome sand urn by the door.", + "stops": [ + [ + 0.0, + "#42474b" + ], + [ + 0.5, + "#8d949a" + ], + [ + 1.0, + "#d2d7db" + ] + ], + "roughness": 0.3, + "metallic": 0.58 + }, + "ashtray_post": { + "file": "procity_fit_ashtray_post_01.glb", + "route": "ramp", + "note": "chrome post, dark bowl.", + "stops": [ + [ + 0.0, + "#242428" + ], + [ + 0.5, + "#7c8288" + ], + [ + 1.0, + "#c2c7cc" + ] + ], + "roughness": 0.32, + "metallic": 0.5 + }, + "beer_umbrella": { + "file": "procity_fit_beer_umbrella_01.glb", + "route": "ramp", + "note": "green brewery canvas on a stained timber pole. Was sRGB 238,238,238.", + "stops": [ + [ + 0.0, + "#3a2618" + ], + [ + 0.3, + "#1f4030" + ], + [ + 0.62, + "#2c5a42" + ], + [ + 1.0, + "#427a5b" + ] + ], + "roughness": 0.9, + "metallic": 0.0 + }, + "bin_round": { + "file": "procity_fit_bin_round_01.glb", + "route": "ramp", + "note": "dark-green council bin, galvanised lid.", + "stops": [ + [ + 0.0, + "#1c261f" + ], + [ + 0.55, + "#33513e" + ], + [ + 1.0, + "#7d8a80" + ] + ], + "roughness": 0.8, + "metallic": 0.08 + }, + "broken_basin": { + "file": "procity_fit_broken_basin_01.glb", + "route": "ramp", + "note": "cream porcelain, grimed in the crack.", + "stops": [ + [ + 0.0, + "#6b6558" + ], + [ + 0.5, + "#b8b3a2" + ], + [ + 1.0, + "#e6e1d0" + ] + ], + "roughness": 0.28, + "metallic": 0.0 + }, + "cable_snake": { + "file": "procity_fit_cable_snake_01.glb", + "route": "ramp", + "note": "black rubber loom, gaffer-taped.", + "stops": [ + [ + 0.0, + "#0e0e10" + ], + [ + 0.6, + "#1d1d21" + ], + [ + 1.0, + "#39393f" + ] + ], + "roughness": 0.88, + "metallic": 0.0 + }, + "can_stack": { + "file": "procity_fit_can_stack_01.glb", + "route": "ramp", + "note": "aluminium cans in a cardboard tray.", + "stops": [ + [ + 0.0, + "#5a4025" + ], + [ + 0.35, + "#8a6a3e" + ], + [ + 0.62, + "#99a0a6" + ], + [ + 0.88, + "#c6cbd0" + ], + [ + 1.0, + "#d9b23a" + ] + ], + "roughness": 0.35, + "metallic": 0.4 + }, + "cdj_deck": { + "file": "procity_fit_cdj_deck_01.glb", + "route": "ramp", + "note": "charcoal desktop deck, silver jog wheel \u2014 the venue booth's read (\u00a742.2).", + "stops": [ + [ + 0.0, + "#191a1d" + ], + [ + 0.42, + "#3a3d42" + ], + [ + 0.78, + "#9ea4aa" + ], + [ + 1.0, + "#dde0e3" + ] + ], + "roughness": 0.42, + "metallic": 0.22 + }, + "champ_bucket": { + "file": "procity_fit_champ_bucket_01.glb", + "route": "ramp", + "note": "chrome ice bucket.", + "stops": [ + [ + 0.0, + "#363e42" + ], + [ + 0.45, + "#8e969c" + ], + [ + 0.8, + "#cbd0d4" + ], + [ + 1.0, + "#e9ecee" + ] + ], + "roughness": 0.22, + "metallic": 0.65 + }, + "concrete_plinth": { + "file": "procity_fit_concrete_plinth_01.glb", + "route": "ramp", + "note": "poured concrete, warm dust.", + "stops": [ + [ + 0.0, + "#4a4842" + ], + [ + 0.5, + "#86837a" + ], + [ + 1.0, + "#b4b0a4" + ] + ], + "roughness": 0.95, + "metallic": 0.0 + }, + "crowd_barrier": { + "file": "procity_fit_crowd_barrier_01.glb", + "route": "ramp", + "note": "galvanised steel bike rack.", + "stops": [ + [ + 0.0, + "#4a5054" + ], + [ + 0.5, + "#8e959a" + ], + [ + 1.0, + "#c0c6ca" + ] + ], + "roughness": 0.4, + "metallic": 0.5 + }, + "distro_box": { + "file": "procity_fit_distro_box_01.glb", + "route": "ramp", + "note": "black distro with a yellow warning face.", + "stops": [ + [ + 0.0, + "#171719" + ], + [ + 0.5, + "#37383d" + ], + [ + 0.85, + "#6e7076" + ], + [ + 1.0, + "#c89a22" + ] + ], + "roughness": 0.6, + "metallic": 0.15 + }, + "fire_ext": { + "file": "procity_fit_fire_ext_01.glb", + "route": "ramp", + "note": "proper AS fire red with a brass valve; the source is a pink-red (240,109,99).", + "stops": [ + [ + 0.0, + "#180f10" + ], + [ + 0.35, + "#7c140f" + ], + [ + 0.8, + "#a81e14" + ], + [ + 1.0, + "#cf9a3c" + ] + ], + "roughness": 0.4, + "metallic": 0.2 + }, + "firepit": { + "file": "procity_fit_firepit_01.glb", + "route": "ramp", + "note": "rusted drum, soot, embers.", + "stops": [ + [ + 0.0, + "#131010" + ], + [ + 0.45, + "#483327" + ], + [ + 0.8, + "#7a4a2a" + ], + [ + 1.0, + "#cb6a20" + ] + ], + "roughness": 0.9, + "metallic": 0.1 + }, + "flight_case": { + "file": "procity_fit_flight_case_01.glb", + "route": "ramp", + "note": "black road case. The source's top luminance decile is speckle noise, not chrome \u2014 a bright top stop turns it into a starfield, so the ramp is compressed to case-black.", + "stops": [ + [ + 0.0, + "#101013" + ], + [ + 0.5, + "#191a1d" + ], + [ + 1.0, + "#3c4046" + ] + ], + "roughness": 0.45, + "metallic": 0.3 + }, + "marble_sink": { + "file": "procity_fit_marble_sink_01.glb", + "route": "ramp", + "note": "cream marble slab, chrome tap.", + "stops": [ + [ + 0.0, + "#67645a" + ], + [ + 0.45, + "#b4af9e" + ], + [ + 0.8, + "#dcd6c4" + ], + [ + 1.0, + "#eeeade" + ] + ], + "roughness": 0.25, + "metallic": 0.15 + }, + "pallet": { + "file": "procity_fit_pallet_01.glb", + "route": "ramp", + "note": "raw pine pallet.", + "stops": [ + [ + 0.0, + "#3b2a17" + ], + [ + 0.5, + "#7e5a32" + ], + [ + 1.0, + "#b28c56" + ] + ], + "roughness": 0.95, + "metallic": 0.0 + }, + "picnic_table": { + "file": "procity_fit_picnic_table_01.glb", + "route": "ramp", + "note": "hardwood beer-garden setting, weathered grey-brown.", + "stops": [ + [ + 0.0, + "#382713" + ], + [ + 0.5, + "#7c5830" + ], + [ + 1.0, + "#b08a5c" + ] + ], + "roughness": 0.95, + "metallic": 0.0 + }, + "planter": { + "file": "procity_fit_planter_01.glb", + "route": "huesplit", + "note": "a pot and a plant are not one ramp \u2014 hue-split keeps the foliage green and makes the pot terracotta.", + "hue_band": [ + 55, + 190 + ], + "hue_min_sat": 0.04, + "in_stops": [ + [ + 0.0, + "#22331c" + ], + [ + 0.5, + "#4c6b36" + ], + [ + 1.0, + "#87a355" + ] + ], + "out_stops": [ + [ + 0.0, + "#3d1f11" + ], + [ + 0.5, + "#9c5330" + ], + [ + 1.0, + "#c98a5c" + ] + ], + "roughness": 0.9, + "metallic": 0.0 + }, + "topiary": { + "file": "procity_fit_topiary_01.glb", + "route": "huesplit", + "note": "same split: clipped bay in a terracotta pot.", + "hue_band": [ + 55, + 190 + ], + "hue_min_sat": 0.04, + "in_stops": [ + [ + 0.0, + "#1e2f1a" + ], + [ + 0.5, + "#415f30" + ], + [ + 1.0, + "#7d9a4e" + ] + ], + "out_stops": [ + [ + 0.0, + "#3d1f11" + ], + [ + 0.5, + "#9c5330" + ], + [ + 1.0, + "#c98a5c" + ] + ], + "roughness": 0.9, + "metallic": 0.0 + }, + "raffle_drum": { + "file": "procity_fit_raffle_drum_01.glb", + "route": "ramp", + "note": "brass meat-raffle drum on a stained stand.", + "stops": [ + [ + 0.0, + "#281a0e" + ], + [ + 0.45, + "#7a5a20" + ], + [ + 0.8, + "#a97f2c" + ], + [ + 1.0, + "#d8ae55" + ] + ], + "roughness": 0.35, + "metallic": 0.5 + }, + "rope_post": { + "file": "procity_fit_rope_post_01.glb", + "route": "ramp", + "note": "brass bollard, burgundy velvet rope.", + "stops": [ + [ + 0.0, + "#2f210e" + ], + [ + 0.45, + "#7a2233" + ], + [ + 0.75, + "#a97f2c" + ], + [ + 1.0, + "#dcb35a" + ] + ], + "roughness": 0.35, + "metallic": 0.45 + }, + "sandwich_board": { + "file": "procity_fit_sandwich_board_01.glb", + "route": "ramp", + "note": "chalkboard A-frame in a timber surround.", + "stops": [ + [ + 0.0, + "#191917" + ], + [ + 0.45, + "#4a3a22" + ], + [ + 0.8, + "#8a6236" + ], + [ + 1.0, + "#c8a76e" + ] + ], + "roughness": 0.85, + "metallic": 0.0 + }, + "smoke_machine": { + "file": "procity_fit_smoke_machine_01.glb", + "route": "ramp", + "note": "black plastic hazer.", + "stops": [ + [ + 0.0, + "#0e0e10" + ], + [ + 0.6, + "#222226" + ], + [ + 1.0, + "#4a4a50" + ] + ], + "roughness": 0.6, + "metallic": 0.1 + }, + "speaker_stack": { + "file": "procity_fit_speaker_stack_01.glb", + "route": "ramp", + "note": "black-carpeted PA column, grey grille cloth.", + "stops": [ + [ + 0.0, + "#0f0f11" + ], + [ + 0.5, + "#1e1e22" + ], + [ + 0.8, + "#3c3c42" + ], + [ + 1.0, + "#74767c" + ] + ], + "roughness": 0.85, + "metallic": 0.0 + }, + "strobe_light": { + "file": "procity_fit_strobe_light_01.glb", + "route": "ramp", + "note": "black body, chrome reflector, cold lens.", + "stops": [ + [ + 0.0, + "#0d0d0f" + ], + [ + 0.55, + "#202024" + ], + [ + 0.85, + "#7e858b" + ], + [ + 1.0, + "#e2e4e6" + ] + ], + "roughness": 0.35, + "metallic": 0.3 + }, + "sub_bass": { + "file": "procity_fit_sub_bass_01.glb", + "route": "ramp", + "note": "black sub, grey cones.", + "stops": [ + [ + 0.0, + "#0e0e10" + ], + [ + 0.55, + "#1c1c20" + ], + [ + 0.85, + "#343440" + ], + [ + 1.0, + "#63656b" + ] + ], + "roughness": 0.85, + "metallic": 0.0 + }, + "urinal_trough": { + "file": "procity_fit_urinal_trough_01.glb", + "route": "factor", + "note": "second textured prop \u2014 tint the map to stainless.", + "factor": "#aeb5ba", + "roughness": 0.28, + "metallic": 0.5 + }, + "wheelie_bin": { + "file": "procity_fit_wheelie_bin_01.glb", + "route": "ramp", + "note": "dark-green council wheelie bin.", + "stops": [ + [ + 0.0, + "#141e18" + ], + [ + 0.5, + "#2e4433" + ], + [ + 1.0, + "#556f59" + ] + ], + "roughness": 0.85, + "metallic": 0.0 + }, + "deck_1200_rigged": { + "file": "procity_fit_deck_1200_rigged_01.glb", + "route": "lut", + "note": "\u00a742.2 \u2014 Technics-silver chassis, alloy platter, BLACK RECORD with a red label, red start/stop, contrast pitch cap. Per-mesh because a colour means a different part in each node.", + "rules": [ + { + "src": [ + 0.05, + 0.05, + 0.05 + ], + "to": "#b9bdc2" + }, + { + "src": [ + 0.08, + 0.08, + 0.09 + ], + "to": "#9aa0a6" + }, + { + "src": [ + 0.55, + 0.55, + 0.58 + ], + "to": "#c0202a" + }, + { + "src": [ + 0.6, + 0.6, + 0.62 + ], + "to": "#7e838a" + }, + { + "src": [ + 0.75, + 0.75, + 0.78 + ], + "to": "#aeb4ba" + }, + { + "src": [ + 0.85, + 0.85, + 0.88 + ], + "to": "#cbd0d5" + }, + { + "src": [ + 0.92, + 0.92, + 0.9 + ], + "to": "#d5d9dd" + } + ], + "roughness": 0.38, + "metallic": 0.35, + "meshes": { + "Platter": { + "route": "lut", + "metallic": 0.55, + "roughness": 0.3, + "rules": [ + { + "src": [ + 0.05, + 0.05, + 0.05 + ], + "to": "#17171a" + }, + { + "src": [ + 0.75, + 0.75, + 0.78 + ], + "to": "#c9ced3" + } + ], + "note": "the record. Measured as a triangle fan (160 verts at r=0, 162 at r=0.15), so a centre-vs-rim label colour would interpolate across the entire disc \u2014 flat vinyl black is the only honest colouring of this topology." + }, + "Headshell": { + "route": "lut", + "metallic": 0.5, + "roughness": 0.3, + "rules": [ + { + "src": [ + 0.05, + 0.05, + 0.05 + ], + "to": "#2c2e33" + }, + { + "src": [ + 0.08, + 0.08, + 0.09 + ], + "to": "#1d1f22" + }, + { + "src": [ + 0.75, + 0.75, + 0.78 + ], + "to": "#c2c7cc" + }, + { + "src": [ + 0.85, + 0.85, + 0.88 + ], + "to": "#d4d8dc" + }, + { + "src": [ + 0.92, + 0.92, + 0.9 + ], + "to": "#c8202c" + } + ] + }, + "PitchCap": { + "route": "lut", + "metallic": 0.0, + "roughness": 0.35, + "emissive": "#3c1206", + "rules": [ + { + "src": [ + 0.55, + 0.55, + 0.58 + ], + "to": "#f0ece2" + } + ] + }, + "StartStopBtn": { + "route": "lut", + "metallic": 0.1, + "roughness": 0.35, + "rules": [ + { + "src": [ + 0.55, + 0.55, + 0.58 + ], + "to": "#c8202c" + }, + { + "src": [ + 0.05, + 0.05, + 0.05 + ], + "to": "#1f2124" + } + ] + } + } + }, + "deck_1200": { + "file": "procity_fit_deck_1200_01.glb", + "route": "lut", + "note": "\u00a742.2 \u2014 the static twin of the hero. One joined primitive, so the record disc is found by POSITION: measured at y=0.079, up-facing, r<0.158 of the platter centre (-0.0455, 0).", + "rules": [ + { + "src": [ + 0.05, + 0.05, + 0.05 + ], + "y_min": 0.0775, + "y_max": 0.0805, + "cyl": { + "center": [ + -0.0455, + 0.0 + ], + "r_max": 0.158 + }, + "to": "#17171a" + }, + { + "src": [ + 0.05, + 0.05, + 0.05 + ], + "y_min": 0.082, + "to": "#2c2e33" + }, + { + "src": [ + 0.05, + 0.05, + 0.05 + ], + "to": "#b9bdc2" + }, + { + "src": [ + 0.03, + 0.03, + 0.03 + ], + "to": "#6e7378" + }, + { + "src": [ + 0.016, + 0.016, + 0.016 + ], + "to": "#4a4d51" + }, + { + "src": [ + 0.012, + 0.012, + 0.012 + ], + "to": "#3c3f43" + }, + { + "src": [ + 0.08, + 0.08, + 0.09 + ], + "to": "#9aa0a6" + }, + { + "src": [ + 0.55, + 0.55, + 0.58 + ], + "to": "#c0202a" + }, + { + "src": [ + 0.6, + 0.6, + 0.62 + ], + "to": "#7e838a" + }, + { + "src": [ + 0.72, + 0.7, + 0.66 + ], + "to": "#c2b9a6" + }, + { + "src": [ + 0.75, + 0.75, + 0.78 + ], + "to": "#c9ced3" + }, + { + "src": [ + 0.85, + 0.85, + 0.88 + ], + "to": "#cbd0d5" + }, + { + "src": [ + 0.92, + 0.92, + 0.9 + ], + "to": "#d5d9dd" + }, + { + "src": [ + 0.42, + 0.02, + 0.02 + ], + "to": "#c8202c" + } + ], + "roughness": 0.38, + "metallic": 0.35 + }, + "mixer_ttm54i_rigged": { + "file": "procity_fit_mixer_ttm54i_rigged_01.glb", + "route": "tint", + "note": "\u00a742.2 \u2014 battle mixer: lift the near-black face to a readable graphite and put colour back in the knob caps.", + "sat_mul": 1.9, + "val": [ + 0.14, + 0.96 + ], + "roughness": 0.45, + "metallic": 0.25, + "meshes": { + "Fader_CROSS_cap": { + "route": "lut", + "metallic": 0.0, + "roughness": 0.35, + "rules": [ + { + "src": [ + 0.82, + 0.82, + 0.8 + ], + "to": "#f0ece2" + }, + { + "src": [ + 0.035, + 0.035, + 0.038 + ], + "to": "#c8202c" + }, + { + "src": [ + 0.004, + 0.004, + 0.004 + ], + "to": "#1f2124" + } + ] + } + } + }, + "mixer_pmc05pro_rigged": { + "file": "procity_fit_mixer_pmc05pro_rigged_01.glb", + "route": "tint", + "note": "\u00a742.2 \u2014 same treatment, second rigged mixer.", + "sat_mul": 1.9, + "val": [ + 0.14, + 0.96 + ], + "roughness": 0.45, + "metallic": 0.25, + "meshes": { + "Fader_CROSS_cap": { + "route": "tint", + "sat_mul": 1.0, + "sat": 0.0, + "val": [ + 0.86, + 0.98 + ], + "metallic": 0.0, + "roughness": 0.35 + } + } + }, + "mixer_shdj1200_rigged": { + "file": "procity_fit_mixer_shdj1200_rigged_01.glb", + "route": "tint", + "note": "\u00a742.2 \u2014 same treatment, third rigged mixer.", + "sat_mul": 1.9, + "val": [ + 0.14, + 0.96 + ], + "roughness": 0.45, + "metallic": 0.25, + "meshes": { + "Fader_CROSS_cap": { + "route": "tint", + "sat_mul": 1.0, + "sat": 0.0, + "val": [ + 0.86, + 0.98 + ], + "metallic": 0.0, + "roughness": 0.35 + } + } + }, + "mixer_pmx9000": { + "file": "procity_fit_mixer_pmx9000_01.glb", + "route": "tint", + "note": "\u00a742.2 \u2014 the venue booth's mixer, static. Same lift so the booth reads as gear, not as a slab.", + "sat_mul": 1.9, + "val": [ + 0.14, + 0.96 + ], + "roughness": 0.45, + "metallic": 0.25 + }, + "sl_deck_3": { + "file": "procity_fit_sl_deck_3_01.glb", + "route": "tint", + "note": "\u00a742.2 \u2014 the venue booth's third part.", + "sat_mul": 1.6, + "val": [ + 0.16, + 0.97 + ], + "roughness": 0.42, + "metallic": 0.28 + } + } +} \ No newline at end of file diff --git a/tools/flags_check.py b/tools/flags_check.py index 0fa833e..65859ad 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -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 # this log can never mistake the two. "Should classic show the town selector at all?" is on the v8 debt # 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' DEPOT_HOSTS = ('digalot.fyi',) # (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}' if path in paths: 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' h = url.split('/')[2] if '://' in url else '' 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] 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") + # [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: 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]) @@ -2245,6 +2302,12 @@ def smoke_djdance(p): tAfter: after[i] ? after[i].t : null, clipAfter: after[i] ? after[i].name : null, 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) })) }; }""") 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: FAIL('gig night: no dancers in the crowd — the subject is absent') 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 # 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 @@ -2272,12 +2351,18 @@ def smoke_djdance(p): return ((d['tAfter'] - d['t']) % dur) > 1e-4 stalled = [d for d in dn if not _advanced(d)] 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 bouncing: FAIL(f"gig night: dancer position.y bouncing {bouncing[0]['amp']}m — the PROCEDURAL fallback is running despite loaded clips") else: - OK(f"gig night: {len(dn)} dancer(s) driven by real dance clips (durations {sorted(set(d['clipDur'] for d in dn))}s, " - f"mixers advancing, position.y flat — the clip moves the body, not the fallback)") + r13 = [d for d in dn if d['isDance']] + 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)] 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)}") @@ -2500,23 +2585,45 @@ def _at_bin(pg, idx=0): }""", 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. - 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.wait_for_timeout(500) pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement; c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true })); c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""") - pg.wait_for_timeout(350) + 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) r = pg.evaluate("""() => { const P = window.PROCITY, g = P.game, w = P.wallet; const btn = document.querySelector('.pcdg-panel button'); 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; btn.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) return r @@ -2811,9 +2918,11 @@ def smoke_nopump(p): return { ok, cash: g.cash, n: g.collection.length }; }""") if not (broke['ok'] and broke['cash'] == 0): 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) - rd = _dig_buy(pg) + rd = _dig_buy(pg, expect_enabled=False) if rd.get('err'): FAIL(f"no-pump/control: dig arm — {rd}") elif rd['disabled'] and rd['n1'] == rd['n0'] and rd['cash1'] == 0: diff --git a/tools/qa.sh b/tools/qa.sh index 852409a..6d06665 100755 --- a/tools/qa.sh +++ b/tools/qa.sh @@ -16,6 +16,8 @@ # 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 · # 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 # 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 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 - # WARN-LEVEL, and the reason is measured, not a shrug (R6 policy for a gate that can go red for a - # reason unrelated to the property it asserts). Its street arm is solid — 8 near-tier rigs, 3 distinct - # clips, every stature clean, reproducible run to run. Its BROWSE arm is not: it scans up to 8 shops - # for browsers, keeps the best, then RE-ENTERS the winner — and a patronage occupant is free to walk - # out during the ~3 s per candidate that scan costs, so the re-entry spawns 0 browsers and the arm - # fails on an empty room. Measured on this tree: 3 consecutive standalone runs all land on `T & R - # Books` with figures 1 (keeper only), byte-identical (34 draws / 36,751 tris), while the same gate - # passed with figures 2 earlier the same day under different machine load. Nothing in R41's shipped - # code moved between those runs. FILED TO D (F-progress §Round 41): re-check `k.browse` after the - # re-entry and re-pick, or keep the winner entered instead of re-entering it. - warn_gate "R41 acceptance shots (D §41.3: measured camera · occlusion raycast · R10 stature line per figure)" \ + # [Lane F R42 §42.6] PROMOTED FROM warn_gate TO run_gate. R41 wired this warn-level because its + # BROWSE arm scanned up to 8 shops for browsers, kept the best, then RE-ENTERED the winner 16-25 s + # later — and a patronage occupant is free to walk out in the meantime, so the re-entry could spawn + # 0 browsers and the arm asserted on an empty room. F filed it as deterministically red; D measured + # the filing before fixing it (§42.4) and found it was FLAKY BY CONSTRUCTION, not always red — which + # is the property that cannot go strict. D's fix removes the window rather than widening it: 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 residual failure ("no occupied shop has + # browse points with browsers at all") is real, deterministic and worth failing on. So it is strict. + run_gate "R41 acceptance shots (D §41.3 + §42.4: measured camera · shot in place · R10 stature line per figure)" \ 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)" \ 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" 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 ────────────────────────────────────────────────────────────────── hr printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \ diff --git a/tools/qa/interior_scale_check.py b/tools/qa/interior_scale_check.py index 3ee7dc0..0a0425d 100644 --- a/tools/qa/interior_scale_check.py +++ b/tools/qa/interior_scale_check.py @@ -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 17–21. This leg # 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. -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 = [] 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)) else: 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() finally: if srv: srv.terminate() diff --git a/tools/qa/r41_shots.py b/tools/qa/r41_shots.py index 85feabc..3d4dcf2 100644 --- a/tools/qa/r41_shots.py +++ b/tools/qa/r41_shots.py @@ -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 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. + [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 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""" -async () => { - const P = window.PROCITY, D = window.DBG, C = P.citizens; +# ── the browse arm. [Lane D R42 §42.4] SHOOT THE WINNER WHILE IT IS STILL THE WINNER. ───────────── +# The R41 shape of this arm was scan-then-re-enter: walk up to 8 occupied shops at ~3.1 s each, keep +# 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 // 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: - // 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 || []) + // has one, an op-shop three), so several are tried and the BEST kept rather than the first that + // works: taking the first gave a one-browser stall on one run and a three-browser op-shop on the next. + return (P.plan.shops || []) .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); - 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""" () => { const P = window.PROCITY, V3 = P.scene.position.constructor; @@ -372,31 +389,49 @@ def main(): print(cap) if not ok: rc = 1 - # ── shot 2: a browsing interior ─────────────────────────────────────────────────────── - info = pg.evaluate(INTERIOR) - print('interior:', json.dumps(info)) - if not info.get('ok'): - print(' skipped interior shot'); rc = 1 + # ── shot 2: a browsing interior (shot from inside — see INT_CANDS) ──────────────────── + cands = pg.evaluate(INT_CANDS) + print(f'interior: {len(cands)} occupied shops; scanning up to 8, shooting in place') + best, tried = None, [] + for s in cands[:8]: + 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) + if fr.get('ok'): + 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: - fr = pg.evaluate(INT_FRAME) - print('frame:', json.dumps(fr)) - if fr.get('ok'): - im = pg.evaluate(INT_MEASURE) - browsers = [r for r in im['rows'] if r['kind'] == 'browser'] - line, ok = human_line(im['rows']) - name = 'r41_browse_interior.jpg' - pg.screenshot(path=str(OUTDIR / name), type='jpeg', quality=90) - cap = (f"PROCITY R41 §41.3 — {name}\n" - f"seed {SEED} · {info['shop']['name']} ({info['shop']['type']}) · " - f"{info['shop']['count']} patronage occupants · {info['browsePoints']} browse points\n" - f"figures: {json.dumps(im['rows'])}\n" - f"draws {fr['draws']} / interior budget 350 · tris {fr['tris']}\n" - f"{line}\n") - (OUTDIR / name.replace('.jpg', '.txt')).write_text(cap) - print(cap) - if not ok or not browsers: rc = 1 - else: - rc = 1 + # 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'] + line, ok = human_line(im['rows']) + name = 'r41_browse_interior.jpg' + cap = (f"PROCITY R41 §41.3 — {name}\n" + f"seed {SEED} · {best['shop']['name']} ({best['shop']['type']}) · " + 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"draws {fr['draws']} / interior budget 350 · tris {fr['tris']}\n" + f"{line}\n") + (OUTDIR / name.replace('.jpg', '.txt')).write_text(cap) + print(cap) + if not ok or not browsers: rc = 1 if errs: print('CONSOLE ERRORS:', errs[:6]); rc = 1 b.close() diff --git a/tools/qa/r42_budget.py b/tools/qa/r42_budget.py new file mode 100644 index 0000000..ea48e0d --- /dev/null +++ b/tools/qa/r42_budget.py @@ -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()) diff --git a/tools/qa/r42_cast.py b/tools/qa/r42_cast.py new file mode 100644 index 0000000..6aa8793 --- /dev/null +++ b/tools/qa/r42_cast.py @@ -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(' 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()) diff --git a/tools/qa/r42_repro.py b/tools/qa/r42_repro.py new file mode 100644 index 0000000..f6682b9 --- /dev/null +++ b/tools/qa/r42_repro.py @@ -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 --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()) diff --git a/tools/qa/r42_roster.mjs b/tools/qa/r42_roster.mjs new file mode 100644 index 0000000..90d8493 Binary files /dev/null and b/tools/qa/r42_roster.mjs differ diff --git a/tools/qa/r42_shots.py b/tools/qa/r42_shots.py new file mode 100644 index 0000000..7d93abc --- /dev/null +++ b/tools/qa/r42_shots.py @@ -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()) diff --git a/tools/qa/r42_tour.py b/tools/qa/r42_tour.py new file mode 100644 index 0000000..6fb695e --- /dev/null +++ b/tools/qa/r42_tour.py @@ -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 feet→crown 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()) diff --git a/web/assets/credits.json b/web/assets/credits.json index 7257721..0bdfef0 100644 --- a/web/assets/credits.json +++ b/web/assets/credits.json @@ -63,17 +63,16 @@ }, { "id": "ped-bodies-base", - "name": "Citizen bodies — the base roster (17 normal + 2 comical)", + "name": "Citizen bodies — the character roster", "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, "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 (Ch01–Ch49), 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.", - "count": 19, - "files": ["models/peds/man_*.glb", "models/peds/woman_*.glb", "models/peds/comical_*.glb"], - "evidence": "unverified", - "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.", + "count": 24, + "files": ["models/peds/man_*.glb", "models/peds/woman_*.glb", "models/peds/person_*.glb", "models/peds/comical_*.glb"], + "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", "lane": "E" }, { @@ -88,6 +87,22 @@ "count": 5, "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'", + "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" }, { diff --git a/web/assets/thumbs/man_casual_04.png b/web/assets/thumbs/man_casual_04.png new file mode 100644 index 0000000..f304732 Binary files /dev/null and b/web/assets/thumbs/man_casual_04.png differ diff --git a/web/assets/thumbs/man_smart_01.png b/web/assets/thumbs/man_smart_01.png new file mode 100644 index 0000000..5a84f72 Binary files /dev/null and b/web/assets/thumbs/man_smart_01.png differ diff --git a/web/assets/thumbs/person_casual_01.png b/web/assets/thumbs/person_casual_01.png new file mode 100644 index 0000000..d50d6f1 Binary files /dev/null and b/web/assets/thumbs/person_casual_01.png differ diff --git a/web/assets/thumbs/person_youth_01.png b/web/assets/thumbs/person_youth_01.png new file mode 100644 index 0000000..9e7e7f6 Binary files /dev/null and b/web/assets/thumbs/person_youth_01.png differ diff --git a/web/assets/thumbs/procity_fit_ash_urn_01.png b/web/assets/thumbs/procity_fit_ash_urn_01.png index ac904d3..991266c 100644 Binary files a/web/assets/thumbs/procity_fit_ash_urn_01.png and b/web/assets/thumbs/procity_fit_ash_urn_01.png differ diff --git a/web/assets/thumbs/procity_fit_ashtray_post_01.png b/web/assets/thumbs/procity_fit_ashtray_post_01.png index e787641..7d2c070 100644 Binary files a/web/assets/thumbs/procity_fit_ashtray_post_01.png and b/web/assets/thumbs/procity_fit_ashtray_post_01.png differ diff --git a/web/assets/thumbs/procity_fit_bain_marie_01.png b/web/assets/thumbs/procity_fit_bain_marie_01.png index b6b49bd..5152eb7 100644 Binary files a/web/assets/thumbs/procity_fit_bain_marie_01.png and b/web/assets/thumbs/procity_fit_bain_marie_01.png differ diff --git a/web/assets/thumbs/procity_fit_banquette_01.png b/web/assets/thumbs/procity_fit_banquette_01.png index 8dfa393..304f37d 100644 Binary files a/web/assets/thumbs/procity_fit_banquette_01.png and b/web/assets/thumbs/procity_fit_banquette_01.png differ diff --git a/web/assets/thumbs/procity_fit_beer_umbrella_01.png b/web/assets/thumbs/procity_fit_beer_umbrella_01.png index 8c20e8c..4cd1173 100644 Binary files a/web/assets/thumbs/procity_fit_beer_umbrella_01.png and b/web/assets/thumbs/procity_fit_beer_umbrella_01.png differ diff --git a/web/assets/thumbs/procity_fit_bin_round_01.png b/web/assets/thumbs/procity_fit_bin_round_01.png index 21e6745..25555cc 100644 Binary files a/web/assets/thumbs/procity_fit_bin_round_01.png and b/web/assets/thumbs/procity_fit_bin_round_01.png differ diff --git a/web/assets/thumbs/procity_fit_bistro_table_01.png b/web/assets/thumbs/procity_fit_bistro_table_01.png index dc50337..139b1f9 100644 Binary files a/web/assets/thumbs/procity_fit_bistro_table_01.png and b/web/assets/thumbs/procity_fit_bistro_table_01.png differ diff --git a/web/assets/thumbs/procity_fit_broken_basin_01.png b/web/assets/thumbs/procity_fit_broken_basin_01.png index 2e0634c..be9230d 100644 Binary files a/web/assets/thumbs/procity_fit_broken_basin_01.png and b/web/assets/thumbs/procity_fit_broken_basin_01.png differ diff --git a/web/assets/thumbs/procity_fit_cable_snake_01.png b/web/assets/thumbs/procity_fit_cable_snake_01.png index 2f79a48..7f226eb 100644 Binary files a/web/assets/thumbs/procity_fit_cable_snake_01.png and b/web/assets/thumbs/procity_fit_cable_snake_01.png differ diff --git a/web/assets/thumbs/procity_fit_can_stack_01.png b/web/assets/thumbs/procity_fit_can_stack_01.png index 60eda7f..46220db 100644 Binary files a/web/assets/thumbs/procity_fit_can_stack_01.png and b/web/assets/thumbs/procity_fit_can_stack_01.png differ diff --git a/web/assets/thumbs/procity_fit_cdj_deck_01.png b/web/assets/thumbs/procity_fit_cdj_deck_01.png index 4a58f76..fc88497 100644 Binary files a/web/assets/thumbs/procity_fit_cdj_deck_01.png and b/web/assets/thumbs/procity_fit_cdj_deck_01.png differ diff --git a/web/assets/thumbs/procity_fit_champ_bucket_01.png b/web/assets/thumbs/procity_fit_champ_bucket_01.png index 0458d57..088d65b 100644 Binary files a/web/assets/thumbs/procity_fit_champ_bucket_01.png and b/web/assets/thumbs/procity_fit_champ_bucket_01.png differ diff --git a/web/assets/thumbs/procity_fit_concrete_plinth_01.png b/web/assets/thumbs/procity_fit_concrete_plinth_01.png index 78ee2af..6653982 100644 Binary files a/web/assets/thumbs/procity_fit_concrete_plinth_01.png and b/web/assets/thumbs/procity_fit_concrete_plinth_01.png differ diff --git a/web/assets/thumbs/procity_fit_crowd_barrier_01.png b/web/assets/thumbs/procity_fit_crowd_barrier_01.png index dd3d4ec..a593f0b 100644 Binary files a/web/assets/thumbs/procity_fit_crowd_barrier_01.png and b/web/assets/thumbs/procity_fit_crowd_barrier_01.png differ diff --git a/web/assets/thumbs/procity_fit_deck_1200_01.png b/web/assets/thumbs/procity_fit_deck_1200_01.png index 3c93743..7f758b7 100644 Binary files a/web/assets/thumbs/procity_fit_deck_1200_01.png and b/web/assets/thumbs/procity_fit_deck_1200_01.png differ diff --git a/web/assets/thumbs/procity_fit_deck_1200_rigged_01.png b/web/assets/thumbs/procity_fit_deck_1200_rigged_01.png index 4f6765b..0e5377c 100644 Binary files a/web/assets/thumbs/procity_fit_deck_1200_rigged_01.png and b/web/assets/thumbs/procity_fit_deck_1200_rigged_01.png differ diff --git a/web/assets/thumbs/procity_fit_distro_box_01.png b/web/assets/thumbs/procity_fit_distro_box_01.png index 4c12e64..8bbb153 100644 Binary files a/web/assets/thumbs/procity_fit_distro_box_01.png and b/web/assets/thumbs/procity_fit_distro_box_01.png differ diff --git a/web/assets/thumbs/procity_fit_fire_ext_01.png b/web/assets/thumbs/procity_fit_fire_ext_01.png index f1ea5cf..bc7fd2b 100644 Binary files a/web/assets/thumbs/procity_fit_fire_ext_01.png and b/web/assets/thumbs/procity_fit_fire_ext_01.png differ diff --git a/web/assets/thumbs/procity_fit_firepit_01.png b/web/assets/thumbs/procity_fit_firepit_01.png index 51ff12c..ca90206 100644 Binary files a/web/assets/thumbs/procity_fit_firepit_01.png and b/web/assets/thumbs/procity_fit_firepit_01.png differ diff --git a/web/assets/thumbs/procity_fit_flight_case_01.png b/web/assets/thumbs/procity_fit_flight_case_01.png index de8129b..8f7c401 100644 Binary files a/web/assets/thumbs/procity_fit_flight_case_01.png and b/web/assets/thumbs/procity_fit_flight_case_01.png differ diff --git a/web/assets/thumbs/procity_fit_jukebox_01.png b/web/assets/thumbs/procity_fit_jukebox_01.png index c37eed4..b1d3754 100644 Binary files a/web/assets/thumbs/procity_fit_jukebox_01.png and b/web/assets/thumbs/procity_fit_jukebox_01.png differ diff --git a/web/assets/thumbs/procity_fit_keg_01.png b/web/assets/thumbs/procity_fit_keg_01.png index aab2056..40ab2b4 100644 Binary files a/web/assets/thumbs/procity_fit_keg_01.png and b/web/assets/thumbs/procity_fit_keg_01.png differ diff --git a/web/assets/thumbs/procity_fit_lounge_chair_01.png b/web/assets/thumbs/procity_fit_lounge_chair_01.png index bb2244e..90f373a 100644 Binary files a/web/assets/thumbs/procity_fit_lounge_chair_01.png and b/web/assets/thumbs/procity_fit_lounge_chair_01.png differ diff --git a/web/assets/thumbs/procity_fit_marble_sink_01.png b/web/assets/thumbs/procity_fit_marble_sink_01.png index 2b83496..3a03b8f 100644 Binary files a/web/assets/thumbs/procity_fit_marble_sink_01.png and b/web/assets/thumbs/procity_fit_marble_sink_01.png differ diff --git a/web/assets/thumbs/procity_fit_mixer_pmc05pro_rigged_01.png b/web/assets/thumbs/procity_fit_mixer_pmc05pro_rigged_01.png index 26380c4..056adf6 100644 Binary files a/web/assets/thumbs/procity_fit_mixer_pmc05pro_rigged_01.png and b/web/assets/thumbs/procity_fit_mixer_pmc05pro_rigged_01.png differ diff --git a/web/assets/thumbs/procity_fit_mixer_pmx9000_01.png b/web/assets/thumbs/procity_fit_mixer_pmx9000_01.png index d66029c..b955df2 100644 Binary files a/web/assets/thumbs/procity_fit_mixer_pmx9000_01.png and b/web/assets/thumbs/procity_fit_mixer_pmx9000_01.png differ diff --git a/web/assets/thumbs/procity_fit_mixer_shdj1200_rigged_01.png b/web/assets/thumbs/procity_fit_mixer_shdj1200_rigged_01.png index 8bc416e..1082f4d 100644 Binary files a/web/assets/thumbs/procity_fit_mixer_shdj1200_rigged_01.png and b/web/assets/thumbs/procity_fit_mixer_shdj1200_rigged_01.png differ diff --git a/web/assets/thumbs/procity_fit_mixer_ttm54i_rigged_01.png b/web/assets/thumbs/procity_fit_mixer_ttm54i_rigged_01.png index e75ab15..da8d03b 100644 Binary files a/web/assets/thumbs/procity_fit_mixer_ttm54i_rigged_01.png and b/web/assets/thumbs/procity_fit_mixer_ttm54i_rigged_01.png differ diff --git a/web/assets/thumbs/procity_fit_old_couch_01.png b/web/assets/thumbs/procity_fit_old_couch_01.png index b476e2d..63dc14d 100644 Binary files a/web/assets/thumbs/procity_fit_old_couch_01.png and b/web/assets/thumbs/procity_fit_old_couch_01.png differ diff --git a/web/assets/thumbs/procity_fit_pallet_01.png b/web/assets/thumbs/procity_fit_pallet_01.png index f4e26d7..f2c0a20 100644 Binary files a/web/assets/thumbs/procity_fit_pallet_01.png and b/web/assets/thumbs/procity_fit_pallet_01.png differ diff --git a/web/assets/thumbs/procity_fit_picnic_table_01.png b/web/assets/thumbs/procity_fit_picnic_table_01.png index 4d770f2..99cbc0c 100644 Binary files a/web/assets/thumbs/procity_fit_picnic_table_01.png and b/web/assets/thumbs/procity_fit_picnic_table_01.png differ diff --git a/web/assets/thumbs/procity_fit_pie_warmer_01.png b/web/assets/thumbs/procity_fit_pie_warmer_01.png index ef57f20..1f0872b 100644 Binary files a/web/assets/thumbs/procity_fit_pie_warmer_01.png and b/web/assets/thumbs/procity_fit_pie_warmer_01.png differ diff --git a/web/assets/thumbs/procity_fit_planter_01.png b/web/assets/thumbs/procity_fit_planter_01.png index 8559417..5c39557 100644 Binary files a/web/assets/thumbs/procity_fit_planter_01.png and b/web/assets/thumbs/procity_fit_planter_01.png differ diff --git a/web/assets/thumbs/procity_fit_plywood_bar_01.png b/web/assets/thumbs/procity_fit_plywood_bar_01.png index 2a960de..9c6bd5f 100644 Binary files a/web/assets/thumbs/procity_fit_plywood_bar_01.png and b/web/assets/thumbs/procity_fit_plywood_bar_01.png differ diff --git a/web/assets/thumbs/procity_fit_pokie_01.png b/web/assets/thumbs/procity_fit_pokie_01.png index ec4855f..006c7b5 100644 Binary files a/web/assets/thumbs/procity_fit_pokie_01.png and b/web/assets/thumbs/procity_fit_pokie_01.png differ diff --git a/web/assets/thumbs/procity_fit_raffle_drum_01.png b/web/assets/thumbs/procity_fit_raffle_drum_01.png index 8d6a441..09a0696 100644 Binary files a/web/assets/thumbs/procity_fit_raffle_drum_01.png and b/web/assets/thumbs/procity_fit_raffle_drum_01.png differ diff --git a/web/assets/thumbs/procity_fit_rope_post_01.png b/web/assets/thumbs/procity_fit_rope_post_01.png index b6ed5b9..833a744 100644 Binary files a/web/assets/thumbs/procity_fit_rope_post_01.png and b/web/assets/thumbs/procity_fit_rope_post_01.png differ diff --git a/web/assets/thumbs/procity_fit_sandwich_board_01.png b/web/assets/thumbs/procity_fit_sandwich_board_01.png index ecdb5f9..5fb5120 100644 Binary files a/web/assets/thumbs/procity_fit_sandwich_board_01.png and b/web/assets/thumbs/procity_fit_sandwich_board_01.png differ diff --git a/web/assets/thumbs/procity_fit_sl_deck_3_01.png b/web/assets/thumbs/procity_fit_sl_deck_3_01.png index f22422e..5cc6661 100644 Binary files a/web/assets/thumbs/procity_fit_sl_deck_3_01.png and b/web/assets/thumbs/procity_fit_sl_deck_3_01.png differ diff --git a/web/assets/thumbs/procity_fit_smoke_machine_01.png b/web/assets/thumbs/procity_fit_smoke_machine_01.png index 655bef5..0778a7b 100644 Binary files a/web/assets/thumbs/procity_fit_smoke_machine_01.png and b/web/assets/thumbs/procity_fit_smoke_machine_01.png differ diff --git a/web/assets/thumbs/procity_fit_speaker_stack_01.png b/web/assets/thumbs/procity_fit_speaker_stack_01.png index 31fba8a..c864d2b 100644 Binary files a/web/assets/thumbs/procity_fit_speaker_stack_01.png and b/web/assets/thumbs/procity_fit_speaker_stack_01.png differ diff --git a/web/assets/thumbs/procity_fit_strobe_light_01.png b/web/assets/thumbs/procity_fit_strobe_light_01.png index 539ed00..6907ccd 100644 Binary files a/web/assets/thumbs/procity_fit_strobe_light_01.png and b/web/assets/thumbs/procity_fit_strobe_light_01.png differ diff --git a/web/assets/thumbs/procity_fit_sub_bass_01.png b/web/assets/thumbs/procity_fit_sub_bass_01.png index 3f986f7..7e825a0 100644 Binary files a/web/assets/thumbs/procity_fit_sub_bass_01.png and b/web/assets/thumbs/procity_fit_sub_bass_01.png differ diff --git a/web/assets/thumbs/procity_fit_topiary_01.png b/web/assets/thumbs/procity_fit_topiary_01.png index b956a83..4cb7029 100644 Binary files a/web/assets/thumbs/procity_fit_topiary_01.png and b/web/assets/thumbs/procity_fit_topiary_01.png differ diff --git a/web/assets/thumbs/procity_fit_urinal_trough_01.png b/web/assets/thumbs/procity_fit_urinal_trough_01.png index e171701..cc70eeb 100644 Binary files a/web/assets/thumbs/procity_fit_urinal_trough_01.png and b/web/assets/thumbs/procity_fit_urinal_trough_01.png differ diff --git a/web/assets/thumbs/procity_fit_wheelie_bin_01.png b/web/assets/thumbs/procity_fit_wheelie_bin_01.png index f3a6d49..b89bc5c 100644 Binary files a/web/assets/thumbs/procity_fit_wheelie_bin_01.png and b/web/assets/thumbs/procity_fit_wheelie_bin_01.png differ diff --git a/web/assets/thumbs/woman_smart_02.png b/web/assets/thumbs/woman_smart_02.png new file mode 100644 index 0000000..22aa520 Binary files /dev/null and b/web/assets/thumbs/woman_smart_02.png differ diff --git a/web/index.html b/web/index.html index 3576d90..7969d84 100644 --- a/web/index.html +++ b/web/index.html @@ -451,7 +451,15 @@ window.addEventListener('procity:sellOpen', () => player.unlock()); const rosterV1 = params.get('roster') === 'v1'; const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets, fleet, chunkStream: rosterV1 ? null : { radius: 2 } }); // default-on -citizens.setPopulation((parseInt(params.get('pop'), 10) || 140) >>> 0); // fixed-roster (?roster=v1) size; stream = per-chunk density +// [Lane F R42 §42.6 — Lane B's §42.5-F filing #1] `?pop=0` WAS A NO-OP FOR 35 ROUNDS. This line read +// `(parseInt(params.get('pop'), 10) || 140)`, and 0 is falsy — so every "town frozen" measurement any +// lane has quoted since the flag landed booted 140 citizens (96 active in the window, B measured it). +// `Number.isFinite` is the fix: a parsed 0 is finite and wins; an absent or unparseable value is NaN +// and falls to the default. Negative values still clamp at 0 through the `>>> 0`. +// NB `?pop=0` empties the FIXED roster only (`?roster=v1`); the chunk-streamed default ignores +// `target` entirely, which is why B's `DBG.freeze()` also hides `citizens.group` — see dbg.js §42.5. +const _pop = parseInt(params.get('pop'), 10); +citizens.setPopulation((Number.isFinite(_pop) ? _pop : 140) >>> 0); // fixed-roster (?roster=v1) size; stream = per-chunk density // [Lane F] hours-aware: keep the open-late block lively at night (Lane D3 setNightLivelyChunks). Keys via // sim.chunkKeyAt so they match sim's own chunk-key format; openLate shop = hours close ≥22 (Lane A contract). if (!rosterV1) { diff --git a/web/interior_test.html b/web/interior_test.html index ac90c06..b163f12 100644 --- a/web/interior_test.html +++ b/web/interior_test.html @@ -69,7 +69,7 @@