Lane C S13 gate 2.3 + D's sliver: ambient leaves from 30 km/h; hail pool hides at zero

Leaves: a pooled handful (cap 7) streaming with the wind from 8.3 m/s — the
same number D keys the player's lean on, so the yard and the body start
telling one story at one speed. Count rides a 2.5 s speed EMA so it doesn't
strobe; own seeded rng (seed ^ 0x1eaf) so the pool never shifts the crate
sequence; bit-identical under identical (dt, t) streams (asserted).
debris.clear() rewinds the layer. dev_skyfx flies the debris layer now and
warms it to the scrub point, so ?t=50 shows the leaves t=50 would have.

D's aftermath sliver, exactly as diagnosed on lane/d: the hail InstancedMesh
at count:0 with frustum culling and depthWrite off still draws instance 0's
identity matrix — a 5 cm always-on-top speck at the origin. mesh.visible now
gates on n > 0, sky.hail joins the public fx object, and a whole-storm assert
demands hidden-at-zero / shown-when-falling in both directions (gated on
floor(1300 x intensity) >= 1: a burst's first frames honestly draw nothing).

THREADS: gate-2 wrap entry — D's judging unblocked, A's M key live.
Selftest 340/0/0 (+2 tests). Mutation-checked: dropped stepLeaves, a 3 m/s
threshold, and an always-visible hail pool all go red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 11:51:26 +10:00
parent c2a19df7d5
commit b70699189c
5 changed files with 216 additions and 3 deletions

View File

@ -4677,3 +4677,59 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
· A: the separation-target proposal above stands as my formal gate-1.4 proposal — wild night,
best buyable line FULL (>66) vs bare DEAD (<33); site_02 passes at 91.5/35.7, backyard_01
fails it for anchor-placement reasons that are yours. Gate 2 starts now, this session.
[C] 2026-07-18 — 🌩 **GATE 2 LANDED (storm grade · wall edges · leaves) + A's M-MUTE + D's SLIVER.
D: your gate-2 judging is UNBLOCKED — pull lane/c and fly the wild night.** Four commits, each
green and mutation-checked; selftest **340/0/0** (was 335: +1 mute, +1 grade, +1 wall, +1 leaves,
+1 sliver).
**2.1 The storm grade — and why the QA's noon-blue sky was arithmetic, not a missing feature.**
The dimming EXISTED; it multiplied the weather by the author's palette. `darkness` is 0.5 on the
southerly, so 65 km/h at full rain moved the sky 0.35 toward slate and left the sun at ~70% —
the author's dial could zero the weather's say. The grade floors it:
`stormGrade = storminess × lerp(0.5, 1, darkness)` — full blow now goes at least half way to
slate on ANY palette, and the wild night keeps its authored black (0.94 → 0.97, unchanged to the
eye). Two things nothing keyed at all, now keyed: the sun DISC lerps toward slate (loses its
noon warmth), and `sun.shadow.radius` lerps 1→7 on storminess — the renderer is PCFSoft, so
that is real penumbra, not a no-op; the razor midday shadows die with the noon edge. Pure in t,
same curve the rain uses, exposed as `sky.stormGrade` above the render gate. dispose() hands
back intensity, colour AND radius exactly (the restore test pins all three). Verified by eye on
dev_skyfx: southerly t=55 at 18.5 m/s reads WEATHER.
**2.2 The wall — both QA sightings were geometry, both reproduced before touching anything.**
The floating slab: the band stopped AT the camera's horizontal plane, but the ground's true
horizon sits ~0.6° below it (the dip), so a bright sliver of sky showed under the base. The
band now overshoots to ~12° below the equator; the ground occludes the overshoot by depth, and
it survives scale.y's 0.3 rise floor (37 m compressed to 11 m is still 3.7°, under the dip
from any eye height that matters). The seam: sin^0.75 left the arc's outer tenth at ~0.36
alpha; now sin^1.6 with the arc widened 2.2→2.6 rad — same solid core, ends under 0.12. Both
pinned in numbers (bbox min.y < 20; end-alpha < 0.2). **D judge from IN-YARD eye height,
that's where both bugs lived and where I re-checked.**
**2.3 Leaves — the ambient tell.** A pooled handful (cap 7) streaming with the wind, keyed on a
2.5 s speed EMA so the count doesn't strobe. **Threshold 8.3 m/s = 30 km/h, deliberately YOUR
lean number, D** — when the man leans, the leaves fly; the yard and the body tell one story.
Deterministic: own seeded rng (`seed ^ 0x1eaf`) so the pool never shifts the crate rng
sequence; same (dt,t) stream in, bit-identical leaves out (asserted). debris.clear() rewinds
the layer. dev_skyfx now flies the debris layer too and warms it to the scrub point (leaves
carry ~8 s of state; a viewer spawning them cold at ?t=50 lies about mid-storm) — wildnight
t=50 reads `leaves 7` with tan flecks driving through the rain.
**D's two handoffs to me, closed:** the aftermath ghost-white sliver was exactly your diagnosis
— hail pool at count:0, frustumCulled off, depthWrite off, instance-0 identity matrix drawing a
5 cm always-on-top speck at the origin. Took your cheap fix (`mesh.visible = n > 0`) with your
name on the comment, plus a whole-storm assert that demands invisible-when-0/visible-when-
falling both directions (found a real subtlety on the way: a burst's first frames honestly draw
zero stones — floor(1300×intensity) — so the assert gates on ≥1 stone, not >0 intensity). And
`sky.hail` is now on the public fx object like `rain` always was, for your judging.
**A — your M key is live.** `sky.setMute(on)` + `sky.muted`, one flag + a gain write; the flag
is the point — M pressed on your splash BEFORE the first gesture builds the audio graph is
remembered and honoured by unlock() (that path is the asserted one, plus `audio.masterGain`
reads the real bus so the test can't be lied to by the flag). Your feature-detect should light
the key the moment you pull lane/c.
**Still open on my plate, none blocking:** nothing from gates 12. B: sweep2's cover-AND-hold
table is waiting for your audit rewrite whenever A's garden export lands; my gate-1 postscript
upstream has the label-permutation warning if you lift per-corner numbers from any bench —
print `corners[k].anchorId`, never the input order.

View File

@ -34,6 +34,7 @@ import * as THREE from './vendor/three.module.js';
import { FIXED_DT } from './js/contracts.js';
import { loadStorm, createWind } from './js/weather.js';
import { createSkyFx } from './js/skyfx.js';
import { createDebris } from './js/debris.js';
const q = new URLSearchParams(location.search);
const stormName = q.get('storm') || 'storm_03b_earlybuster';
@ -65,9 +66,16 @@ slab.position.set(0, 1.5, -11);
scene.add(slab);
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
// gate 2.3: the viewer flies the ambient-leaf layer too — the sky stack and
// the yard's tell belong in the same eyeball test
const debris = createDebris({ wind, scene });
addEventListener('pointerdown', () => sky.unlockAudio(), { once: false });
let t = Math.max(0, +q.get('t') || 0), paused = false, last = performance.now(), acc = 0;
// Warm the ambient layer to the scrub point: leaves carry a few seconds of
// state (speed EMA + travel), so a viewer that spawns them cold at ?t=50
// shows an empty sky the real t=50 would not.
for (let wt = Math.max(0, t - 8); wt < t; wt += FIXED_DT) debris.step(FIXED_DT, wt, {});
addEventListener('keydown', (e) => {
if (e.code === 'Space') paused = !paused;
if (e.key === ']') t = Math.min(def.duration, t + 5);
@ -86,13 +94,13 @@ function frame(now) {
const wall = Math.min(0.1, (now - last) / 1000); last = now;
if (!paused && t < def.duration) {
acc += wall;
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); }
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); debris.step(FIXED_DT, t, {}); }
}
camera.rotation.set(0, yaw, 0, 'YXZ');
const ch = (def.events || []).find((e) => e.type === 'windchange');
hud.textContent = `${stormName} t=${t.toFixed(1)}s / ${def.duration}s${paused ? ' ⏸' : ''}\n`
+ `wind ${wind.speedAt(camera.position, t).toFixed(1)} m/s rain ${wind.rainAt(t).toFixed(2)} `
+ `front ${sky.changeFront.toFixed(2)}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
+ `front ${sky.changeFront.toFixed(2)} leaves ${debris.leafCount}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
+ `space pause · [ ] scrub · ←→ turn · click for audio`;
renderer.render(scene, camera);
}

View File

@ -52,6 +52,66 @@ export function createDebris(o = {}) {
const w = new THREE.Vector3();
const probe = new THREE.Vector3();
// ---------------------------------------------------------------- leaves
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3"
// — the event-driven crates are the drama, but nothing SELLS the gale
// between events. A handful of leaves streaming with the wind does, from
// 30 km/h up. Numbers that matter: LEAF_START is 8.3 m/s on purpose — the
// same threshold D keys the player's lean on (their table, lane/d), so the
// yard and the body start telling the same story at the same speed. Count
// stays single digits (cap 7); these are a tell, not a particle system.
//
// Deterministic: own seeded rng (so the leaf pool never shifts the piece
// rng sequence), distance accumulated over the fixed-dt stream, flutter
// pure in t. Same (dt, t) stream in, same leaves out.
const LEAF_START = 8.3; // m/s = 30 km/h; matches D's lean threshold
const LEAF_MAX = 7; // "a handful" — single digits, capped
const LEAF_SPAN = 26; // m of downwind run before a leaf recycles
const lrand = rng(((wind && wind.seed) || 1) ^ 0x1eaf);
const leafGeo = new THREE.PlaneGeometry(0.16, 0.09);
const leafMat = new THREE.MeshLambertMaterial({ color: 0x8a7a3f, side: THREE.DoubleSide });
const leafMeshes = [];
const leafSeed = [];
for (let i = 0; i < LEAF_MAX; i++) {
const m = new THREE.Mesh(leafGeo, leafMat);
m.visible = false;
if (scene) scene.add(m);
leafMeshes.push(m);
leafSeed.push({
lat: lrand() * 16 - 8, // lane across the wind, m
base: 0.35 + lrand() * 1.5, // ride height, m
off: lrand() * LEAF_SPAN, // where on the loop it starts
fl: 1.6 + lrand() * 1.6, // flutter frequency
ph: lrand() * 6.283,
});
}
let leafDist = 0; // m travelled downwind, accumulated over the dt stream
let leafEma = 0; // ~2.5 s smoothed speed, so the count doesn't strobe
function stepLeaves(dt, t) {
probe.set(0, 1.6, 0);
wind.sample(probe, t, w);
const sp = Math.hypot(w.x, w.z);
leafEma += (sp - leafEma) * Math.min(1, dt / 2.5);
const n = leafEma < LEAF_START ? 0
: Math.min(LEAF_MAX, 1 + Math.floor((leafEma - LEAF_START) / 1.5));
leafDist += sp * 0.85 * dt; // leaves ride a little under the wind
const inv = sp > 1e-4 ? 1 / sp : 0;
const dx = w.x * inv, dz = w.z * inv;
for (let i = 0; i < LEAF_MAX; i++) {
const m = leafMeshes[i], s = leafSeed[i];
const vis = i < n && inv > 0;
m.visible = vis;
if (!vis) continue;
const along = ((leafDist + s.off + i * (LEAF_SPAN / LEAF_MAX)) % LEAF_SPAN) - LEAF_SPAN / 2;
const lat = s.lat + Math.sin(t * 0.9 + s.ph) * 1.1;
const x = dx * along - dz * lat;
const z = dz * along + dx * lat;
m.position.set(x, groundAt(x, z) + s.base + Math.sin(t * s.fl + s.ph) * 0.3, z);
m.rotation.set(t * s.fl, s.ph + t * 2.1, t * 1.3 + s.ph);
}
}
/** Graybox stand-in so a missing GLB can't break Lane A's merge. */
function placeholder(spec) {
const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8);
@ -108,6 +168,11 @@ export function createDebris(o = {}) {
const debris = {
get pieces() { return pieces; },
/** How many ambient leaves are flying right now (gate 2.3). 0 in a calm. */
get leafCount() { let n = 0; for (const m of leafMeshes) if (m.visible) n++; return n; },
/** Positions of the flying leaves — for asserts and D's judging. */
get leaves() { return leafMeshes.filter((m) => m.visible).map((m) => m.position); },
/** Lane E's GLBs, once they land. name -> Object3D template. */
setModels(map) { Object.assign(models, map); return debris; },
@ -125,6 +190,7 @@ export function createDebris(o = {}) {
for (const ev of wind.eventsBetween(t - dt, t)) {
if (ev.type === 'debris') spawn(ev, t);
}
stepLeaves(dt, t); // gate 2.3 — the ambient tell
}
const player = world.player;
@ -219,6 +285,10 @@ export function createDebris(o = {}) {
clear() {
for (const p of pieces) despawn(p);
pieces.length = 0;
// leaves are a pooled ambient layer, not spawned pieces: hide and rewind
// so the next night's stream starts from the same state every time
for (const m of leafMeshes) m.visible = false;
leafDist = 0; leafEma = 0;
},
};

View File

@ -191,6 +191,11 @@ function createHail(opts) {
step(dt, camPos, vel, intensity, size, shadow) {
const n = Math.floor(max * clamp01(intensity));
mesh.count = n;
// D's aftermath find (S13): count:0 with frustumCulled off and depthWrite
// off still draws instance 0's identity matrix — a ~5 cm always-on-top
// white sliver at the world origin, QA's "ghost near the shed table".
// visible is the honest gate; count alone is not.
mesh.visible = n > 0;
if (n === 0) return;
const s = 0.6 + size * 0.9; // bigger stones read bigger
m.makeScale(s, s, s);
@ -744,7 +749,7 @@ export function createSkyFx(o = {}) {
const w = new THREE.Vector3();
const fx = {
rain, audio, dome, shadow, hailShadow, front: frontMesh,
rain, hail, audio, dome, shadow, hailShadow, front: frontMesh,
get flash() { return flash; },
/** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */
get hailAmount() { return hailAmt; },

View File

@ -136,6 +136,80 @@ export default async function run(t) {
'clear() replaced the pieces array instead of emptying it — B holds a reference');
});
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3";
// the crates are event drama, the leaves are the continuous tell. Threshold
// 8.3 m/s (30 km/h) matches D's lean threshold on purpose — the yard and the
// body start telling the same story at the same speed. Count stays single
// digits, they stream WITH the wind, and the layer is deterministic.
t.test('ambient leaves: none in a calm, a streaming handful from ~30 km/h', () => {
const stub = (sp) => ({
seed: 5,
sample: (p, t2, o) => o.set(sp, 0, 0),
eventsBetween: () => [],
});
const calm = createDebris({ wind: stub(5) });
fixedLoop(8, FIXED_DT, (dt, time) => calm.step(dt, time, {}));
assert(calm.leafCount === 0,
`${calm.leafCount} leaves flying at 18 km/h — below the threshold the yard is still`);
const gale = createDebris({ wind: stub(13) });
fixedLoop(8, FIXED_DT, (dt, time) => gale.step(dt, time, {}));
assert(gale.leafCount > 0, 'no leaves at 47 km/h — the gale has no ambient tell');
assert(gale.leafCount <= 9, `${gale.leafCount} leaves — the plan says single digits`);
// they stream WITH the wind (+x here): over one step every leaf either
// moves downwind or recycles ~a span upwind; none drifts against it
const before = gale.leaves.map((pos) => pos.x);
gale.step(FIXED_DT, 8, {});
const after = gale.leaves.map((pos) => pos.x);
assert(before.length === after.length, 'leaf count strobed across one step');
for (let i = 0; i < before.length; i++) {
const d = after[i] - before[i];
assert(d > 0 || d < -10, `leaf ${i} moved ${d.toFixed(3)} m against a +x wind`);
}
// deterministic: same seed, same (dt, t) stream, same leaves — bit for bit
const a = createDebris({ wind: stub(13) });
const b = createDebris({ wind: stub(13) });
fixedLoop(5, FIXED_DT, (dt, time) => { a.step(dt, time, {}); b.step(dt, time, {}); });
assert(a.leafCount === b.leafCount && a.leaves.every((pos, i) =>
pos.x === b.leaves[i].x && pos.y === b.leaves[i].y && pos.z === b.leaves[i].z),
'two identical (dt, t) streams produced different leaves');
// clear() rewinds the layer — next night starts from the same state
gale.clear();
assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card');
});
// D's aftermath find (S13, lane/d): the hail InstancedMesh at count:0 with
// frustum culling and depthWrite both off still draws instance 0's identity
// matrix — a ~5 cm always-on-top white sliver at the origin, the QA's "ghost
// near the shed table". The gate is mesh.visible, not count. This flies the
// wild night and demands the pool is invisible whenever no hail is falling
// and visible whenever it is — both directions, whole storm.
t.test("hail pool: invisible when nothing falls (D's aftermath sliver)", () => {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera();
const wind = createWind(storms.storm_02_wildnight);
const sky = createSkyFx({ scene, camera, wind });
let sawHail = false, sawCalm = false;
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
sky.step(dt, time, {});
// the mesh draws floor(1300 × intensity) stones, so a burst's first
// millisecond can honestly show zero — demand visibility only once the
// intensity buys at least one stone, and invisibility only at exactly 0
if (sky.hailAmount >= 1 / 1300) {
sawHail = true;
assert(sky.hail.mesh.visible, `hail falling at t=${time.toFixed(1)} but the pool is hidden`);
} else if (sky.hailAmount === 0) {
sawCalm = true;
assert(!sky.hail.mesh.visible, `no hail at t=${time.toFixed(1)} but the pool still draws — the sliver`);
}
});
assert(sawHail && sawCalm, 'storm never exercised both states — this test proves nothing');
sky.dispose();
});
// Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path.
// They verified sun/hemi restore exactly and spotted that fog didn't; this pins
// both. The vacuity guards matter — a restore test where nothing ever moved is