Compare commits

..

No commits in common. "cdce67c8f89a05e588439ba9508528718859752f" and "d9100962f64c214f52190c568059152ee761557f" have entirely different histories.

10 changed files with 15 additions and 536 deletions

View File

@ -3,85 +3,7 @@
*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-07-17 (round 28) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-17 (round 28, THE SPIKE AND THE SWEEP) — `audioEmitter`: the till rings, and the panner lies
R28 §Lane C (ledger #3) — my own idea, parked since R7, brief states acceptance only. Wave 1, alongside E+B.
### The trap my own parked note set
v3.0's deferred note said: *"`userData.audioEmitter = musicKey` so Lane B can position/spatialise the
bed"*. **Both halves were wrong**, and the second one is dangerous:
1. **The shape.** The engine holds no reference to the room group — it would have to traverse a scene it
cannot reach. Emitters belong on `room.audio` as data, where they ride the **existing**
`playInterior(room.audio)` call: no new lifecycle, no traverse, no dispose hook, no F plumbing.
2. **The method.** "Spatialise" invites a `PannerNode`. **Nothing in this tree sets `ctx.listener`** (zero
hits, whole tree), so a panner resolves against a listener frozen at (0,0,0) — and since rooms are built
in room-local coords at the origin, that is *the room's own centre, forever*. Measured with
`OfflineAudioContext` on a real pub stage bed:
| wiring | RMS at door | RMS at stage | ratio |
|---|---|---|---|
| panner, listener never set (**the engine as it ships**) | 0.106905 | 0.106905 | **1.0000** |
| panner + listener tracking the player | 0.055665 | 0.500000 | 8.98 |
It **plays, throws nothing, warns nothing, and never changes by one sample as you cross the room**
while failing at the only thing it exists to do. *"Routed through a panner?"* yes. *"Audible in a
venue?"* yes. *"In a shop?"* yes. All green, all worthless. **The only assertion that can see this is a
DELTA: gain at the door ≠ gain at the stage.** Vacuous-gate law, 4th application — and this one had no
author to blame but me. There is no listener code anywhere to teach a lane otherwise.
**The house idiom was already right**: tram rumble (R=55), door spill (R=9), gig spill (R=26) are all
hand-rolled distance-gains against `PROCITY.camera.position`. No listener, no panner. Emitters match it.
### What shipped (C's files only)
- **`room.audio.emitters`** = `{ stage?, counter? }`, room-local, 3-dp, **18/18 same-seed deep-equal**.
`r` (reach) is the **room diagonal, not a constant** — measured diagonals 9.8115.63 m, and scaling to
the room keeps door→stage proximity in a tight **0.420.59** band from a pokey band_room to a hall pub.
`floor` is the room-filling residue, **never 0** — a PA fills a pub; what changes as you walk in is the
*balance*, not the presence. Swing **2.654.52 dB** (John R20: *subtle changes are great*).
- **The till rings from the counter***audible positioning in a normal shop with **no engine change***,
via B's already-public `playSfx(key,{gain})`: C does its own distance math, hands B a scalar. Measured
through the real BUY button in a real record shop: **0.9489 @ 0.60 m vs 0.6436 @ 4.19 m = 3.37 dB**;
negative control (no emitters) fires at exactly **1.0**. `sfx-till.ogg` had been on disk since 15 Jul and
had **never once played**. Routing, not content — exactly as the brief said.
- **`?mute=1` now silences the crate riffle.** See below.
**Why the venue half is an ask, not a ship:** a **one-shot** resolves its gain once, at fire time, from a
position C already knows. A **bed** must be re-gained every frame inside the engine's `update()` loop —
B's file. The split is structural, not political. B got a ~12-line paste-ready diff in the house idiom; C
did not touch audio.js. The letter of "consulted not modified" could have been satisfied by reaching
through B's exposed `state.layers` diag to rewire its graph from outside — **that backdoor is strictly
worse than the edit it avoids** (invisible to B, breaks on any change of B's). Refused.
### Two bugs found on the way
- **F drops emitters on the one night they matter.** `interior_mode.js:289` rebuilds the spec on a gig
night: `{ musicKey: ra.gigKey, toneKey: WALLA_KEY }` — every unnamed field dies. Measured on a real
gig-night pub: `quietNight_emittersSurvive: true`, **`gigNight_emittersSurvive: false`**. Silent, fail-
soft, no error. -> F: don't just spread `ra` — pass emitters **deliberately, gig-night only**, because on
a quiet night the radio and room-tone genuinely *are* room-filling.
- **`?mute=1` never silenced the riffle (R5 → R28).** `dig.js` opened a **second, private AudioContext**
straight to `destination`, breaking both of audio.js's stated laws (*ONE AudioContext*; *mute forces
silence*). It survived three epochs because **no gate asserts mute ⇒ silence**: the engine returns its
silent surface and looks honest *from its own side*, while the sound came from somewhere it never knew
existed. *A gate that asks the engine whether it is muted can only ever answer yes.* Fixed by reading
B's **public** `.muted` getter (consulted, not modified), URL fallback when standalone. Measured with a
positive control so the probe is provably able to see sound: live 1 ctx / 1 source; **muted 0 / 0**.
Blips stay procedural on purpose — zero-fetch, deterministic, and they still work under `?noassets=1`
where a sampled riffle cannot. Only mute was broken; only mute was fixed.
### Harness honesty (again)
First till rig reported a clean 3.37 dB — from a **contaminated harness**. `createDig` appends a panel per
call and `close()` doesn't remove it, so `querySelector` was clicking **trial 1's stale button** every
time. The tell was `panelUp: false` on all three trials, and the **negative control** returning 0.6436
where it must return 1.0. The gains happened to match the formula exactly — I still refused them and
re-ran isolated (`panelCount: 1`, `panelUp: true`, control **1.0** ✓). R27's lesson holds: *never report a
number from a rig you have watched lie, even when you like the number.* The control is what caught it.
QA GREEN (6/0/0/0). Docs: **LANE_C_AUDIO.md → v3.1** (contract + both asks + every measurement above).
Last updated: 2026-07-17 (round 26) · owner: PROCITY-C · reviewer: Fable
---

View File

@ -1,72 +1,5 @@
# LANE B — NOTES (measured)
## Round 28 (v5.1) — ledger #2, the awning fabric ripple: **KILLED with the measurement**
**Built it, proved it worked, measured what it was worth, and killed it. A 10×-over-driven billow —
1.4 m of flap on a 0.14 m canopy — cannot move 1% of the frame. It is not the gain; it is the
geometry. The backlog item is closed permanently, and the reason names what *would* work.**
R17 shipped tree sway and parked this with a physical reason: a posted verandah is a structural roof
and does not flutter. R28 asked for the half R17 left — the stalls' striped canopies really are cloth,
and they shared the rigid awning InstancedMesh, so neither could move without the other. That part was
all true, and the split worked. The cloth just turned out to be invisible.
### It was built, and it worked
Split the canopies out of the rigid mesh (own `InstancedMesh`, own subdivided box — a 4-corner box has
nothing to ripple), gave only them a cloth billow riding R17's existing `wind.js` uniforms: a
`sin(πu)·sin(πv)` pin (zero at the four corner posts by construction, peak mid-panel), two crossed
travelling waves, per-instance phase off world position so forty stalls don't breathe in unison.
Proven by **rendered-pixel hashes**, not by eye:
| proof | result |
|---|---|
| the cloth actually displaces (calm vs gusty) | `40f59eaf``d6d3e1f1`**moves** |
| returning to calm reproduces the calm frame | `40f59eaf`**identical** |
| `uWindTime = 99` at amp 0 | `40f59eaf`**identical** (amp 0 ⇒ `+= …*0.0`, the R17 pattern) |
| **`?classic=1`, same fixed pose, pre- vs post-change build** | **`453c0154``453c0154` — byte-identical** |
| rigid verandahs / rest of town ride the wind? | cloth+trees hidden: `76c8b518``76c8b518`**still** |
| …and the control proves I hid the right thing | cloth restored: frame **moves** again |
Cost at `market_square`, true A/B: **+0 draws** (98 → 98) / **+2,160 tris**. +0, not the +1 I'd written
in my own comment — the square's chunks hold *only* stalls, so the rigid list empties, `makeInstanced`
returns null, and the cloth mesh replaces it rather than adding to it.
### And it is worth nothing — the sweep (amp is the same multiplier as gain)
| peak billow | frame changed |
|---|---|
| **0.02 m — CALM, the default weather amp** | **0.05%** |
| **0.14 m — STORM, the most any player ever sees** | **0.21%** |
| 0.35 m (2.5× over-driven) | 0.33% |
| 0.70 m (5×, absurd on a 0.14 m slab) | 0.49% |
| **1.40 m (10×, a canopy flapping a body-length)** | **0.77%** |
At the player's actual browsing pose, storm-force: **0.00%, max channel delta 1**. Not one pixel.
**The reason is physical, and it is the same physics that killed the verandah in R17.** A canopy is a
horizontal sheet at y 2.2 viewed by a 1.7 m player: you see it edge-on or from underneath, so a
*vertical* displacement runs almost parallel to the view ray and produces almost no pixel change. You
cannot see a flat horizontal sheet flex vertically while standing under it. No gain fixes that — 10×
buys 0.77%, and anything that large stops reading as cloth and starts reading as a trampoline.
**What would work, if wind in the market is ever wanted (for whoever reads this in round 35):** the
motion has to be *perpendicular* to the view, which means **vertical cloth** — hanging stall skirts, a
flag, a banner, an awning valance. That is an **asset** (Lane E), not a shader. B's `applyWind` /
`applyFabricWind` seam is ready for it the day such a mesh exists; the shader was never the missing
piece. **Ledger #2 leaves the backlog forever.**
### Kept from the round
Nothing shipped: `buildings.js` and `wind.js` are byte-identical to HEAD. Shipping an invisible ripple
would have cost 2,160 tris and a second draw path in the covenant town to move 0.05% of a calm frame.
*The R17 rule was "ships or dies". It died — and it died with numbers, which is the only way an item
this old stops coming back.*
**Two vacuous measurements caught on the way (the epoch's own disease, still catching me):** (1) a
`!!material.onBeforeCompile` check to prove the rigid verandah was untouched — every `THREE.Material`
inherits a prototype no-op, so it is **always** truthy; the assert could never go red, and I replaced it
with `hasOwnProperty` plus a behavioural hide-and-hash control. (2) a gain sweep that reported the
identical 0.21% at gains 0.4→4.0 because I never stored the shader ref, so `uFabricGain` never moved —
**four identical numbers across a 10× range is a sweep that proves nothing**, and it was the constancy,
not the value, that gave it away. Both would have passed as green.
---
## Round 27 wave 4 — `poseForShopFront` shipped · **and every real town's shops face away from the street**
**The micro-task is done and verified. In doing it I found why F photographed empty road: it is not

View File

@ -1,11 +1,10 @@
# LANE C — interior audio contract (v3.1) → for Lane F + Lane B
# LANE C — interior audio contract (v3.0-FROZEN) → for Lane F + Lane B
> **v3.1** · 2026-07-17 (R28) · durable contract for Lane F + Lane B. Amends v3.0-FROZEN: adds
> `room.audio.emitters` (§Emitters) and retires the v3.0 deferred `userData.audioEmitter` note — that
> note proposed a shape its own consumer could not use. **The line was the stale thing.**
> **v3.0-FROZEN** · 2026-07-16 · durable contract for Lane F + Lane B. Describes the shipped state; changes
> require a version bump.
*`buildInterior` returns `room.audio = { musicKey, toneKey, gigKey?, emitters }`, seeded per shop. Lane F's
interior_mode just plays it; Lane B's audio.js resolves the keys → files.*
*`buildInterior` returns `room.audio = { musicKey, toneKey }`, seeded per shop. Lane F's interior_mode just
plays it; Lane B's audio.js resolves the keys → files.*
> **Scope:** this doc covers the **base interior audio** (the seeded music bed + room-tone every shop
> returns). The **gig audio**`room.audio.gigKey`, the live-band bed, canonical form `gig-<genreKey>`
@ -18,7 +17,6 @@ interior_mode just plays it; Lane B's audio.js resolves the keys → files.*
room.audio = {
musicKey, // string | null — an interior MUSIC bed key in manifest.audio.music, or null (no music)
toneKey, // string — an interior ROOM-TONE key in manifest.audio.ambience (scope:'interior')
emitters, // object — WHERE keys sound from (R28, §Emitters). Never null; may be {}.
}
```
- **Keys, not files.** `buildInterior` stays synchronous and asset-free — it only *names* what should
@ -41,58 +39,6 @@ room.audio = {
Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches its manifest
`types` membership; dedicated-music types are stable, non-music types vary on/off by seed.
## Emitters — WHERE a key sounds from (R28)
```js
room.audio.emitters = {
stage?: { x, y, z, r, floor }, // VENUE ONLY — the band + the crowd sound from here
counter?: { x, y, z, r, floor }, // the till
}
```
Room-local metres, 3-dp, deterministic (18/18 same-seed deep-equal across 6 types × 3 seeds).
**Absent emitter ⇒ room-filling**, which stays the right default for a shop radio (the v3.0 call, kept).
- **`r` is the REACH** — the distance at which the emitter has decayed to `floor`. It is the **room
diagonal**, not a constant. Measured over 8 types × 4 seeds: diagonals run **9.8115.63 m**, and scaling
`r` to the room holds the door→stage proximity in a consistent **0.420.59** band from a pokey
band_room to a hall pub. A fixed radius would make the same walk mean different things in different rooms.
- **`floor` is the room-filling RESIDUE, never 0.** A pub PA fills the room; what changes as you walk in is
the **balance**, not the presence. `stage` 0.35, `counter` 0.15 (a till is a point source, a PA is not).
- **The curve** (both consumers use exactly this):
```js
const d = Math.hypot(e.x - cam.x, e.z - cam.z); // emitters and the interior camera are
const prox = Math.max(0, Math.min(1, (e.r - d) / e.r)); // BOTH room-local — no transform
const gain = e.floor + (1 - e.floor) * prox; // never 0
```
Measured swing door→stage: **2.654.52 dB** across pub / band_room / rsl. Audible, not a new game — the
wide rooms swing least because the stage genuinely *is* nearer the door there. (John, R20: *"subtle
changes are great … the player should notice the town feels more alive, not that the game changed."*)
### DO NOT use a PannerNode. It is inert here. (measured)
Nothing in the tree sets `ctx.listener` — zero hits, whole tree. A `PannerNode` therefore resolves against
a listener frozen at **(0,0,0)**, and because rooms are built in **room-local coords at the origin**, that
is *the room's own centre, forever*. Measured via `OfflineAudioContext`, a stage bed in a real pub:
| wiring | RMS at the door | RMS at the stage | ratio |
|---|---|---|---|
| **panner, listener never set** (the engine as it ships) | 0.106905 | 0.106905 | **1.0000** |
| panner + listener tracking the player | 0.055665 | 0.500000 | 8.98 |
The naive build **plays, throws nothing, warns nothing, and never changes by one sample as you cross the
room** — while doing the one thing the feature exists to prevent. Every plausible gate goes green on it:
*"is it routed through a panner?"* yes. *"is it audible in a venue?"* yes. *"in a shop?"* yes.
**-> The only assertion that can see this is a DELTA: the bed's gain at the door ≠ its gain at the stage.**
Assert the change, not the presence. (Vacuous-gate law, 4th application.)
This trap was set by **this document's own v3.0 note** — *"`userData.audioEmitter = musicKey` so Lane B can
position/spatialise the bed"* — which invites exactly the panner, in a codebase with no listener code to
teach otherwise. Both halves of that note were wrong: the *shape* (the engine holds no reference to the
room group and cannot traverse a scene it can't reach) and the *method*. It is retired.
**The house idiom is already correct**: every spatial effect B ships — tram rumble (R=55), door spill
(R=9), gig spill (R=26, low-passed through the wall) — is a hand-rolled distance-gain against
`PROCITY.camera.position`. No listener, no panner. Emitters are the same shape as what already works.
## Lane F wiring (interior_mode)
```
on enter(room):
@ -104,92 +50,9 @@ on exit / dispose(room):
Nothing before the first gesture; `?mute=1`/`?noassets=1` short-circuit before any fetch. `room.audio`
is plain data on the return — no new lifecycle, no dispose hook needed from Lane C's side.
---
# R28 — what C shipped, and the two asks
**C shipped (this round, C's files only):**
1. `room.audio.emitters` — the data above. Rides the **existing** `playInterior(room.audio)` call: no new
lifecycle, no traverse, no dispose hook.
2. **The till rings from the counter** (`dig.js`) — *audible positioning in a normal shop, with no engine
change*, via B's already-public `playSfx(key, {gain})`: C does its own distance math and hands B a
scalar. Measured through the real BUY button in a real record shop — **0.9489 at the counter (0.60 m)
vs 0.6436 at the door (4.19 m), 3.37 dB**; negative control (`emitters` omitted) fires at exactly
**1.0**. `sfx-till.ogg` has been on disk since 15 Jul and had **never once played**. Routing, not content.
3. **`?mute=1` now actually silences the crate riffle** — see the house-law fix below.
**Why the venue half is an ask and the shop half wasn't:** a **one-shot** resolves its gain once, at fire
time, from a position C already knows — so C can do it alone. A **bed** must be re-gained every frame from
inside the engine's `update()` loop. That loop is B's file. The split is structural, not political.
## ASK → Lane B (audio.js) — ~12 lines, paste-ready, house-idiomatic
Gives the round its venue half: *the band comes from the stage, not from the whole room.*
```js
// near the other interior state:
let iEmit = null, iMusicG = 0.5, iToneG = 0.35;
async function playInterior(spec) {
// … existing body …
iEmit = spec.emitters || null; // [R28] Lane C — where the beds sound from (gig night only)
iMusicG = mEntry?.gain ?? 0.5;
iToneG = tEntry?.gain ?? 0.35;
swapBed(L.iMusic, spec.musicKey || null, mEntry || null, iMusicG, 1.0);
swapBed(L.iTone, spec.toneKey || null, tEntry || null, iToneG, 1.0);
}
function stopInterior() { iEmit = null; ramp(L.iMusic.g.gain, 0, 0.7); ramp(L.iTone.g.gain, 0, 0.7); }
// in update(), alongside the tram/spill blocks — the SAME idiom, no panner, no ctx.listener:
if (!street && iEmit && iEmit.stage) {
const e = iEmit.stage, d = Math.hypot(e.x - _cam.x, e.z - _cam.z);
const k = e.floor + (1 - e.floor) * Math.max(0, Math.min(1, (e.r - d) / e.r));
ramp(L.iMusic.g.gain, (L.iMusic.src ? iMusicG : 0) * k, 0.25);
ramp(L.iTone.g.gain, (L.iTone.src ? iToneG : 0) * k, 0.25);
}
```
Emitters are room-local; the interior camera is room-local. Same space, no transform. Your call entirely —
C has not touched audio.js.
**Second, smaller ask (not this round):** expose the sfx bus or the ctx so `dig.js` can retire its private
AudioContext (below). C will not open that seam unilaterally.
## ASK → Lane F (interior_mode.js) — 1 line, and it is load-bearing
`interior_mode.js:289` rebuilds the audio spec on a gig night and **drops every field it doesn't name**:
```js
const gigAudio = gigOn && ra.gigKey ? { musicKey: ra.gigKey, toneKey: WALLA_KEY } : ra;
```
Measured against a real gig-night pub: `quietNight_emittersSurvive: true`, **`gigNight_emittersSurvive:
false`**. So emitters reach B on every night *except the one night the stage emitter exists for* — silently,
fail-soft, no error. Don't just spread `ra`: the split is real and should be **deliberate**, because on a
quiet night the radio and the room-tone genuinely *are* room-filling.
```js
// [R28] On a gig night BOTH beds come from the stage — music = the band, tone = the crowd in front of it.
// On a quiet night the radio + room-tone fill the room, so no emitter is passed. Deliberate, not a spread.
const gigAudio = gigOn && ra.gigKey
? { musicKey: ra.gigKey, toneKey: WALLA_KEY, emitters: ra.emitters }
: { musicKey: ra.musicKey, toneKey: ra.toneKey };
```
**Also, when you next touch `openDig`:** pass `emitters: current.audio.emitters` to `dig.open()` and the
till becomes positional. Optional — omitted, it still rings, just un-positioned. Never required.
## House-law fix — `?mute=1` did not silence the crate riffle (R5 → R28)
`dig.js` opened a **second, private AudioContext** wired straight to `destination`, breaking both of
audio.js's stated laws (*ONE AudioContext*; *`?mute=1` forces silence*). The riffle sang right through
`?mute=1` and never saw B's master gain. **It survived three epochs because no gate asserts mute ⇒
silence** — the engine returns its silent surface and looks honest from its own side, while the sound came
from somewhere the engine never knew existed. *A gate that asks the engine whether it is muted can only
ever answer yes.*
Fixed in `dig.js` by reading B's **public** `.muted` getter (consulted, not modified), falling back to the
URL when there is no engine (standalone `interior_test.html`). Measured, with a positive control so the
instrument is provably able to see sound:
| | AudioContexts built | sounds started |
|---|---|---|
| live (mute off), after a riffle | 1 | 1 |
| **muted, same riffle** | **0** | **0** |
The blips stay **procedural** on purpose: zero-fetch, deterministic, and they still work under
`?noassets=1` where a sampled riffle cannot. Only mute was broken; only mute was fixed.
**-> F: assert SILENCE, not muted-state.** The honest gate is *"no second AudioContext exists / no source
starts under `?mute=1`"* — with a positive control proving the probe can see a sound when one is played.
## Deferred idea (v3.1+, not in the frozen contract)
A `places`-tagged visual source ("the music comes from *there*") — e.g. a radio/hifi mesh with
`userData.audioEmitter = musicKey` so Lane B can position/spatialise the bed. Only the record shop has a
fitting for it today (`listeningCorner`); milk-bar/video would need a new radio prop. A tidy v3.1 item: the
`userData.audioEmitter` hook + the two props. v3.0 plays the bed non-spatially (room-filling), which is the
right default for an interior anyway.

View File

@ -6,54 +6,6 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 28 — v5.1 THE SWEEP: instrument LOD — **KILLED with the measurement** (ledger #4)
*PROCITY-D, 2026-07-17. The oldest tri item on the books (v3.1-era). The brief attached the wind-sway rule:
**"if the measured win is <20k tris in practice, KILL the item with the measurement and it leaves the
backlog forever."** It does not survive. **E: do not build the `_lo` variants — that work is now unnecessary.***
### The measurement
Instrument tri counts, parsed straight from the shipped depot GLBs (`web/assets/models/`):
| instrument | tris |
|---|---|
| electric_guitar | 14,000 |
| bass_guitar | 14,000 |
| drum_kit | 14,000 |
| guitar_amp | 14,000 |
| mic_stand | 14,000 |
| **band + amp, all on stage** | **70,000** |
On paper that clears the bar: a 34k `_lo` each would "save" ~52k. **But the rule says *in practice*, and the
brief named the reason — "interiors are close-quarters."** So I measured the rooms an LOD would have to fire in
(live, all three venue archetypes, `interiorMode` driven by hand since rAF is throttled):
| venue | room | **max diagonal** | room tris (no band) | draws |
|---|---|---|---|---|
| pub — The Exchange Hotel | 9.7 × 9.7 m | 13.7 m | 12,522 | 38 |
| band_room — The Vinyl Shed | 8.9 × 9.6 m | 13.0 m | 11,699 | 35 |
| rsl — Wangaratta RSL (biggest) | 12 × 9.4 m | **15.2 m** | 15,115 | 38 |
### The verdict: practical win = **0 tris**. KILLED.
**A player can never be more than 15.2 m from the band** — that is corner-to-corner in the *largest* venue, and
the stage sits at one end, so the real stage-to-player max is shorter still. An LOD is a *distance* trade: it
pays when the subject is small on screen. Inside a ≤15.2 m room the band is never small — it is the subject of
the gig, and of F's tour money shot. Any sane swap threshold (1525 m) **never fires**; an aggressive 8 m one
would fire only in half a room, at a distance where a 34 k `_lo` is *visibly* worse with no perceptual cover.
The win is not "small" — it is **structurally zero**, because the distance the LOD needs does not exist in this
game. **Per the wind-sway rule: killed, off the backlog forever.**
No budget motivates it either: a venue interior is ~1215 k tris, ~85 k with the band — comfortably inside the
200 k stress ceiling E's R22 tri diet restored, at 3538 draws against a ≤350 ceiling.
### Side-observation for E (NOT a proposal — no breach motivates it)
All five instruments are **exactly 14,000 tris**, which is a decimator target, not a measurement of the objects:
a **mic stand does not need 14 k**. If instrument tris ever *do* become the driver of a real breach, the lever is
a **flat re-decimation by object complexity** (mic stand → ~1 k), which beats any LOD because it needs no
distance — not a `_lo` swap. Filing the observation so the next tri hunt starts from it; **not** asking for work.
---
## ROUND 23 — v5.0-alpha: the GIG_RANGE chunk-neighbour fix (ledger #6, the carried v4.x item)
*PROCITY-D, 2026-07-16. My own filed finding, finally fixed — post-release, as Fable scheduled. Hot path,

View File

@ -1,62 +1,5 @@
# LANE E — cross-lane notes
## Round 28 — Spike 1: half of it shipped, half of it doesn't exist ⟨v5.1⟩
**1. `look.glb` — DONE, published, verified.** `Looking_Around.fbx` (675 KB, from the cached tailnet stash,
not re-downloaded — the skill's law) → `fbx_to_clip.py`**`web/models/peds/look.glb`**: mesh-free, **65
`mixamorig` joints, 195 tracks, inverseBindMatrices present, 136 KB** — structurally identical to the
`sit.glb` precedent on every field I can measure (`meshes/nodes/skins/joints/bind`), so D's `_canon` binds
it exactly like walk/idle/sit. Depot: published to `digalot.fyi/3god` under John's **R22 standing
authorization for Lane E assets** (`_published.json` 43 → 44). **→ D: it's ready.**
**2. 🚩 `lean` DOES NOT EXIST — the spike's premise is half wrong, and only John can unblock it.** The
brief and the kickoff both say "two of the 34 already-cached clips (lean, look-around)" and "Spike 1 needs
nothing from anyone". **`look-around` is cached. `lean` is not — anywhere.** Measured, not assumed:
- The stash's 34 clips contain no lean (`Agreeing · Bored · Breathing_Idle · Counting · … · Looking_Around
· … · Yawn`).
- `find ~/Documents -iname "*lean*"` on ultra: **nothing** — not in `mixamo-fetch`, not in `character_kit`,
not in `3D=models`, no FBX by that name at any depth.
- The fetch tool can't help unattended: `node fetch.cjs search "lean"`
`FATAL nothing on debug port 9222 — quit Brave and relaunch with --remote-debugging-port=9222`. It needs
**John's logged-in Brave session** (his browser, his Mixamo account) — **I won't relaunch it.**
**→ John, one small unblocker (adds to your two):** relaunch Brave with `--remote-debugging-port=9222`,
open a Mixamo tab, and either run `node fetch.cjs search "lean"` yourself or tell me and I'll fetch +
convert it in minutes (the path is proven twice now). **I did NOT substitute a lookalike.** `Bored` is the
nearest stash clip and it's a standing weight-shift idle, not a wall-lean — D's half explicitly needs
shopfront adjacency ("`lean` against a wall wants the same care" as the scale-aware traps), so a standing
idle would quietly change what Spike 1 proves. Half a spike, honestly, beats a whole one that lies.
**3. Instrument LOD — D killed it, and D's measurement is better than mine.** I'd built the five `_lo`
variants in scratch (14k → 3500/3500/4000/**6000**/3000) before D's commit landed saying *"E: STOP — do not
build the _lo variants."* **Nothing shipped**; scratch discarded. D measured the rooms an LOD would have to
fire in — **max diagonal 15.2 m in the largest venue, so the player can never be far enough: practical win
0 tris.** That's decisive in a way my argument wasn't. My own reading agreed on the verdict by a different
route, and it's worth recording as corroboration: **C's §4 already says "the interior gate is *draws*; tris
are the *street* budget (200k)"** — and an LOD swap doesn't change draws *at all* (same object, same
material, fewer tris). So the item's entire win landed on a metric no interior gate reads. Two independent
measurements, one verdict. **The oldest tri item is dead, correctly.**
*(One salvage worth keeping if it ever returns: at the brief's ~3k the **guitar_amp shreds** — white gaps
punched through its silhouette, confirmed against the shipped 14k thumb as a decimation artifact and not a
source defect, the R22 `food_cart` discipline. It needs **6k** to read clean. The other four were clean at
34k.)*
**4. 🚩 → A: your own file's comment now contradicts its code.** Investigating why the fingerprint read
`0x5f76e76` instead of the `0x3fa36874` I'd seen every round, I confirmed it's **legitimate** — your R27 w5
facade fix re-pinned 51 goldens under the amended covenant. But `selfcheck.js:267` pins
`hash: 0x5f76e76` while **`selfcheck.js:301` still says "the synthetic golden above stays 0x3fa36874"**.
Code and comment disagree by 34 lines. That's the exact species as R24's validator bug (my docstring said
nested, my code read top-level) — the next reader trusts whichever they read first. Your file, your call;
flagging per the round's "flag loudly if anything disagrees".
**5. Gates.** selfcheck **ALL GREEN 156,352/156,352** · `qa.sh --strict` **6 passed / 0 failed**. `look.glb`
moves **no golden** — proved by moving it aside and re-running: fingerprint identical either way, because an
unreferenced clip is never fetched. The `?classic=1` covenant is untouched: like `sit.glb`, a raw fetch
would breach the zero-fetch-delta rule, so **D must gate `look` behind an opt** the way `rigs.js` gates
`sit` (`loadPedFleet(base, { sit })` → the same shape for `look`). Depot-live verified by fetching the
bytes back: `https://digalot.fyi/3god/a/look.glb`**HTTP 200, 139,640 bytes, sha1 identical to local**.
## Round 27 — provenance v5-final, and the roster gap the docs freeze caught ⟨v5.0⟩
**1. THE BUG THE FREEZE FOUND: `redhill_godverse` was on disk but NOT in the roster — for three rounds.**
@ -807,8 +750,6 @@ loader wiring (`spawnRig`/fleet). Bench-sit loiter (Fable's stretch goal) reuses
| Audio pack | 28 | 100% procedural numpy synthesis (`gen_audio.py`) | oscillator/noise render → ffmpeg OGG+M4A | 🟢 CC0-equiv, parody-safe |
| Stock packs | 3 (350/311/273) | GODVERSE `dealgod` Postgres — **real** cover art | `build_stock_pack.py`: parody-transform metadata + atlas | 🟢 real-art / parody-metadata |
| Ped clip **⟨v3.1⟩** | `sit.glb` (E R16; + pre-existing `walk`/`idle` from D/90sDJsim) | Adobe **Mixamo** `Sitting_Idle.fbx` | `fbx_to_clip.py` (Blender, mesh-strip) | 🟢 Mixamo royalty-free (motion only, mesh stripped) |
| Ped clip **⟨v5.1⟩** | `look.glb` (E R28 — v6 Spike 1) — mesh-free, 65 `mixamorig` joints, 195 tracks, 136 KB; structurally identical to `sit`/`walk`/`idle` | Adobe **Mixamo** `Looking_Around.fbx` (675 KB) — from the **cached tailnet stash** (`johnking@ultra:~/Documents/mixamo-fetch/out/`), not re-downloaded | `fbx_to_clip.py` (Blender, mesh-strip) — the R16 path, unchanged | 🟢 Mixamo royalty-free (motion only, mesh stripped) — same zone as walk/idle/sit |
| Ped clip **⟨v5.1⟩ NOT BUILT** | `lean.glb`**the source does not exist** (see R28 §1) | — | — | — |
| Town pack **⟨v5.0-FINAL⟩** | **21 real AU towns, all 8 states/territories****1196 shops** (299 heroes) + real street geometry (`roads[]`) | **OpenStreetMap** via Overpass API — bounded per-town bbox; raw shops+roads snapshots committed in `_raw/` | `build_towns.py`: fetch → dedupe/suburb-fill/parody → C's §6 class map → 3× texture cap (drops counted) → v2 cache + roads | 🟢 **ODbL 1.0** (© OSM contributors; `web/assets/towns/SOURCES.md` + `license`/`attribution` on every cache) |
| Godverse towns *(Lane G's, listed for completeness)* **⟨v5.0-FINAL⟩** | `newtown_godverse` (72 shops) · **`redhill_godverse` (37)** — the town whose record shop is THE shop. Both rostered in E's `index.json` (**23 total**) | GODVERSE/thriftgod shop census + OSM road geometry; `redhill_real` is E's donor | `pipeline/godverse_town.py` (**Lane G owns this provenance**) | 🟢 ODbL 1.0 + GODVERSE census (G's own `license`/`attribution` fields) |
| Real-stock atlas **⟨v5.0-FINAL⟩** | **1 crate, 120 items**, 2×2048² webp — Monster Robot Party (`3962749`) | **the real POS** (`recordgod`) — crate 550 "DEEP HOUSE"; covers are **the shop's own product shots**, cached by dealgod | `pipeline/godverse_stock.py` (**Lane G owns**) — `snapshot`(DB-side, sha256-pinned) → `pack`(pure) | 🟡 **in-house-green — FLAG BEFORE ANY PUBLIC/COMMERCIAL RELEASE** · `sourcing:"real"` · ids `sku_<POS id>` |

View File

@ -1373,43 +1373,3 @@ Attempts 13 were also never winnable: the shops faced backwards the whole tim
at a wall and tuning the geometry.** The measurement that would have found it in five minutes — compare
the two conventions against each other — is the one nobody ran for nine rounds, F included, right up to
the vacuous 72/72 F nearly filed as "B is wrong".
---
## 28. Round 28 — v5.1 THE SPIKE AND THE SWEEP. **The sweep landed. The spike did not reach the game.**
### ⚠ F's pose gate has NO SUBJECT — SKIPPED with the reason, per the law
The brief: *"New-clip figure checks (lean/look poses through the scale gate), a bookmark spot-check
(leaning peds in old frames)."* **There are no new poses in the game, so there is nothing to check:**
- **`lean` does not exist.** E measured it rather than assumed it — the stash's 34 clips contain no
lean, and `find -iname "*lean*"` across ultra returns nothing. The brief and the kickoff both named it
as already-cached; **the premise was half wrong**, and E found it the only way anyone ever finds these:
by looking instead of trusting the list.
- **`look.glb` is shipped, verified, published — and NOTHING BINDS IT.** `loadPedFleet()` loads
`walk.glb`, `idle.glb`, and `sit` (gated). No `look`. E's own handoff said it plainly: *"→ D: it's
ready. Gate it behind an opt the way rigs.js gates sit."* **D's entire R28 commit is
`LANE_D_NOTES.md` — 48 lines, docs only.** The run order said *"When E's clips land: D (#1 wiring)"*;
the log says **D committed BEFORE E shipped** (`afad68b` precedes `640bdbb`). Nobody was careless —
the wave order was, and the file on disk looks exactly like a delivered spike from every angle except
the one that matters.
**The R17/R23 pattern, fifth appearance, and the cheapest one yet to have missed**: an asset on disk,
verified, published to a public depot, sha1-checked by fetching the bytes BACK — and no consumer. Every
check E ran was real and passed. None of them could see that nothing loads it. *A gate on the artifact
cannot see the wire.*
### So: v5.1 SHIPS, and it does not claim the spike
Nothing is broken — an unbound clip is inert, the game is unchanged and correct. This is not R18's
"known breach"; it is an **unfinished item**, and holding a polish tag over an inert file would be
precious. **What ships is the sweep, and the tag says exactly that:**
- **C's `audioEmitter`** — the till rings from the counter, the bed from the stage. C's own idea, parked
since R7, and C found its own v3.0 note had specified it wrong on both halves (*"the panner was
inert"*).
- **Two items KILLED with the measurement** — B's awning ripple and D's instrument LOD, the oldest tri
item on the books. They don't leave the backlog "deferred"; they leave it **gone**, with the number
that killed them. *A backlog that only grows is a backlog nobody measures.*
- **`look.glb`** — staged on the depot, and honestly described as staged.
**Filed for R29 (D, one opt):** `rigs.js` gains `look` the way it gates `sit`; F passes it from the shell.
Then the pose gate has a subject and F will run it. **v6 has not begun in-game — the tag must not say it
has.**

View File

@ -1,5 +1,4 @@
[
"look.glb",
"procity_fit_arcade_cabinet_01.glb",
"procity_fit_bass_guitar_01.glb",
"procity_fit_bass_guitar_01_hi.glb",

View File

@ -103,7 +103,6 @@ export function createDig(THREE, renderer) {
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional.
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
const sideMat = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a8070, roughness: 0.85 }));
@ -142,29 +141,8 @@ export function createDig(THREE, renderer) {
document.body.append(hint, curLbl, panel, outBtn, toast);
// ── procedural audio ──
// [R28] HOUSE AUDIO LAW (audio.js, Lane B): (1) ONE AudioContext; (2) ?mute=1 forces silence. This
// file broke both from R5 to R28 — a second, private AudioContext wired straight to `destination`,
// so the riffle sang right through ?mute=1 and never saw B's master gain. It survived three epochs
// because NO gate asserts mute ⇒ silence: the engine dutifully returns its silent surface, `?mute`
// looks honest from the engine's side, and the sound came from somewhere the engine never knew about.
// A gate that asks the engine whether it's muted can only ever answer yes. -> F: assert SILENCE, not
// muted-state (there is no second AudioContext ⇔ the law holds).
// The blips STAY procedural on purpose: zero-fetch, deterministic, and they still work under
// ?noassets=1, where a sampled riffle cannot. Only mute was broken; fix only mute.
// Routing them through B's bus (so master gain applies) needs a ctx/bus seam this lane must not open
// unilaterally — published as the R28 ask in LANE_C_AUDIO.md §Emitters.
const MUTED_BY_URL = (() => {
try { const p = new URLSearchParams(location.search).get('mute'); return p != null && p !== '0'; }
catch { return false; }
})();
function muted() {
const eng = window.PROCITY && window.PROCITY.audio; // B's PUBLIC getter — consulted, not modified
if (eng && typeof eng.muted === 'boolean') return eng.muted;
return MUTED_BY_URL; // standalone interior_test: no engine to ask
}
let actx;
function blip(kind) {
if (muted()) return;
try {
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
if (kind === 'fwip') {
@ -263,37 +241,11 @@ export function createDig(THREE, renderer) {
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
const ok = onBuyCb ? onBuyCb(o) : true;
if (ok === false) { unpull(); return; }
// [R28] the sale rings on the till across the shop; the thunk stays as the no-engine fallback so
// the standalone harness (and ?noassets) keeps its sold cue.
if (!sfxFrom('till', emitters && emitters.counter)) blip('thunk');
blip('thunk');
showToast(`SOLD — ${o.a} · $${o.price}`);
removeEntry(entry);
};
}
// [R28 audioEmitter] The till rings from the COUNTER, not from inside your head. You riffle at a bin,
// so the counter is 2.838.15 m away (measured, 8 types × 4 seeds) — a real distance to carry.
// This needs NO engine seam: B's playSfx(key,{gain}) is already public, so C does its own distance
// math and hands B a scalar. That is the whole reason a ONE-SHOT is tractable and a BED is not — a
// one-shot resolves its gain once, at fire time, from a position C already knows; a bed has to be
// re-gained every frame from inside the engine's update loop, which is B's file. Hence the split ask.
// `sfx-till.ogg` has been on disk since 15 Jul and has never once played: this is routing, not content.
// No emitter (F hasn't plumbed it, or no counter) ⇒ gain 1 ⇒ a plain, un-positioned till. No engine
// ⇒ silence. Both fail soft, both still ring true.
function emitterGain(em) {
if (!em) return 1;
const cam = window.PROCITY && window.PROCITY.camera && window.PROCITY.camera.position;
if (!cam) return 1; // no camera to measure from → room-filling
const d = Math.hypot(cam.x - em.x, cam.z - em.z); // emitters are ROOM-LOCAL; so is the interior camera
const prox = Math.max(0, Math.min(1, (em.r - d) / em.r));
return em.floor + (1 - em.floor) * prox; // never 0 — a till across the shop is quiet, not absent
}
function sfxFrom(key, em) {
const eng = window.PROCITY && window.PROCITY.audio;
if (!eng || !eng.playSfx) return false; // standalone/no engine → silent-and-happy
eng.playSfx(key, { gain: emitterGain(em) }); // ?mute / ?noassets / pre-gesture handled inside
return true;
}
let toastT = null;
function showToast(msg) { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); }
@ -363,13 +315,10 @@ export function createDig(THREE, renderer) {
return out;
}
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters })
// `emitters` [R28, OPTIONAL] — pass `room.audio.emitters` and the till rings from the counter at the
// right distance. Omit it and the till still rings, just un-positioned. Never required.
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash })
function open(opts = {}) {
active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
emitters = opts.emitters || null;
const count = opts.count || 16;
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');

View File

@ -128,43 +128,6 @@ function audioFor(type, ctx, opts, shop) {
return audio;
}
// ── audio EMITTERS (round 28) — WHERE a key sounds from ────────────────────────────
// room.audio.emitters says which room-local point each bed/sfx belongs to, so the gig comes from the
// stage and the till from the counter instead of from the whole room. Pure data on the EXISTING
// `playInterior(room.audio)` call — no new lifecycle, no traverse, no dispose hook (the v3.0 note
// proposed `userData.audioEmitter` on a prop mesh; that shape is wrong — the engine holds no reference
// to the room group and would have to walk a scene it can't reach. The line was the stale thing).
//
// `r` is the REACH: distance at which the emitter has decayed to `floor`. It is the ROOM DIAGONAL, not
// a constant — measured across 8 types × 4 seeds, diagonals run 9.8115.63 m, and scaling by the room
// keeps the door→stage proximity in a consistent 0.420.59 band from a pokey band_room to a hall pub.
// `floor` is the room-filling RESIDUE and is never 0: a pub PA fills the room, what changes as you walk
// in is the BALANCE, not the presence. floor 0.35 ⇒ ~4 dB door→stage — audible, not a new game.
//
// NOTE FOR THE CONSUMER (measured, R28): do NOT spatialise these with a PannerNode. Nothing in the tree
// sets `ctx.listener`, so a panner resolves against a listener frozen at (0,0,0) — which, because rooms
// are built in room-local coords at the origin, is the room's own centre, forever. Measured via
// OfflineAudioContext: stage bed RMS 0.106905 at the door, 0.106905 at the stage — ratio 1.0000, no
// error, no warning, and it never changes as you cross the room. Use a distance-gain against
// PROCITY.camera.position, the way the tram/spill/gig-spill already do. See LANE_C_AUDIO.md §Emitters.
const EMITTER_FLOOR = { stage: 0.35, counter: 0.15 }; // PA fills a room; a till is a point source
const round = (v) => Math.round(v * 1000) / 1000; // same 3-dp form layout.js publishes poses in
function emittersFor(lay, dims) {
const r = round(Math.hypot(dims.W, dims.D)); // the room's own reach
const em = {};
if (lay.stage) {
// ear height above the deck, at the band's centre — not the deck surface
em.stage = { x: round(lay.stage.x), y: round((lay.stage.deckY || 0) + 1.2), z: round(lay.stage.z),
r, floor: EMITTER_FLOOR.stage };
}
if (lay.counter) {
em.counter = { x: lay.counter.pose.x, y: 1.1, z: lay.counter.pose.z,
r, floor: EMITTER_FLOOR.counter };
}
return em;
}
export function buildInterior(shop, THREE, opts) {
opts = opts || {}; // tolerate undefined AND explicit null
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
@ -222,10 +185,7 @@ export function buildInterior(shop, THREE, opts) {
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else []
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null
// { musicKey, toneKey, gigKey?, emitters } — Lane E manifest.audio keys (R11/R12) + WHERE they sound
// from (R28). emitters.stage is venue-only; emitters.counter is always present. Absent emitter ⇒
// room-filling, which stays the right default for a shop radio (the v3.0 call, unchanged).
audio: { ...audioFor(recipe.key, ctx, opts, norm.raw), emitters: emittersFor(lay, dims) },
audio: audioFor(recipe.key, ctx, opts, norm.raw), // { musicKey, toneKey, gigKey? } — Lane E manifest.audio keys (R11/R12)
dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement,

Binary file not shown.