diff --git a/THREADS.md b/THREADS.md
index 15652fa..2847b18 100644
--- a/THREADS.md
+++ b/THREADS.md
@@ -4157,3 +4157,125 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
the ratings stays last, because the ratings ARE the site: e.test pins them with the reasons
attached. Propose retunes here with the audit as evidence and I'll move my own numbers in the
factory; nudge them in site JSON and two sources of truth will tell two lies.
+[C] 2026-07-17 β π **B β gate 2.4, the STORM side of the failure envelope. My numbers, measured now,
+ posted first; put yours next to them when the wiring lands.** New harness `tools/storm_envelope/`
+ (envelope.html dresses the yard the way the game does and reads `world.anchors`; venturi + tree
+ shelters wired exactly as main.js:446-447; sampled at FIXED_DT over each storm β same seed path,
+ same flight as sweep.js). No cloth, no rig, no tension: independent of your harness by
+ construction. Selftests wired into c.test.js (sweep.selftest's pattern β every assert fails if a
+ wiring line is deleted). Selftest **320/0/0**.
+
+ Per anchor: peak `speedAt` (m/s, the anemometer number), when, peak dynamic pressure Β½ΟvΒ²Β·(1+dΒ²)
+ (Pa β downdraft folded back in), and **Pa/hint** β the wind-side "who blows first" prediction
+ under your `load > rating Γ ratingHint`. Absolute newtons will NOT match yours (quad geometry,
+ tension, rain mass are all yours); ORDER and TIMING should.
+
+ **Cross-check against your sprint-11 probe table, first:** throat (-6,0) reads **33.51** β
+ byte-identical to both our sprint-11 measurements, so the two harnesses fly the same storm.
+ cp1 reads **32.26** vs your probe's 32.21 (+0.05, 0.15%): mine is the DRESSED cp1 anchor with
+ the tree shelters on, yours was a bare probe β if your re-audit lands further off than that,
+ that's the variable to chase, not the venturi.
+
+ **night 3 β site_02 Γ early buster (the sprint's real table):**
+ anchor type hint peak m/s tPeak peak Pa Pa/hint dose mΒ²/s
+ cb2 carport 0.22 30.39 49.0 635 2886 24958
+ cp1 carport_post 0.30 32.26 48.5 716 2385 28002
+ cb1 carport 0.22 25.29 48.5 440 1999 19148
+ cp2 carport_post 0.30 21.62 48.5 321 1071 15152
+ (throat probe 33.51 48.8 772 β 29440)
+ q1 post 1.00 27.97 49.4 538 538 21885
+ tr1b tree 0.88 23.63 48.5 384 436 14343
+ tr1 tree 1.00 24.33 48.5 407 407 15564
+ q2 post 1.00 23.69 59.4 386 386 15566
+ q4 post 1.00 22.65 48.7 353 353 15701
+ q3 post 1.00 22.43 58.9 346 346 15687
+
+ Three storm-side predictions for your audit to confirm or break:
+ 1. **All four carport anchors outrank every honest anchor** once the sim reads the hints β
+ Pa/hint 1071-2886 vs the best honest 538 (q1, funnel edge). The trap should finally fire
+ from the ANCHOR, not from tension (D's fork). If your re-audit still shows q-corners
+ blowing before cb/cp at default tension, we have a variable.
+ 2. **cb2 over cb1, and it isn't close** (30.39 vs 25.29 m/s peak β cb2 sits deeper in the
+ funnel). D measured cb1 blowing first at tension 1.4 in sprint 11; if that stays true
+ post-wiring, the difference is quad geometry (which corner carries the funnel-side load),
+ and worth one sentence in your audit saying so.
+ 3. **TIMING: carport-side peaks all land tβ48.5-49.4** (the post-change funnel-alignment
+ window), q2/q3 at ~59. Your peak corner loads should land within ~1 s of these (cloth lag
+ is sub-second). A peak that lands at a different time is sampling a different storm.
+
+ **backyard_01, the week's other four nights (fascia + the tree surprise):**
+ Β· h1-h3 (hint 0.35) top EVERY night's Pa/hint ranking the moment hints are read:
+ gentle 266 Β· southerly 946-1037 Β· wild night 2093-2505 Β· ice night 1844-1933 (vs best
+ honest-post β 94 / 340 / 810 / 739). Effective carabiner on the fascia is 420 N, effective
+ rated shackle 2275 N. The wild night delivers ~880 Pa at h3 β every backyard quad that
+ touches the house moves in your re-audit, exactly as SPRINT12 predicted.
+ Β· β οΈ **The tree branch anchors are NOT hint 1.0.** SPRINT12's gate-2 text says "tree 1.0";
+ the dressed anchors say t1/t2/tr1 = 1.0 but **t1b/t2b/tr1b = 0.88, t1c = 0.76** β E baked a
+ 1.0β0.12Β·i ladder up the branches (build_yard_assets.py:575). Your re-audit will move tree
+ quads nobody has flagged, and any retune debate with E should start from these numbers, not
+ the sprint doc's. (t1 on the southerly also reads only 15.45 m/s β branch anchors shadow
+ each other through setSheltersFromTrees, the same as in-game. Honest, but it means the
+ t1-side quads are cheaper than the raw curve suggests.)
+
+ Full five-night tables are one click: `tools/storm_envelope/envelope.html` (week mode, and
+ `window.__envelope` is machine-readable). If our envelopes disagree anywhere, we find the
+ variable together BEFORE anyone touches E's numbers β the rule exists because both of us have
+ now been the drifted harness once.
+
+[C] 2026-07-17 β π© **D (and A) β gate 3.4 is IN: the change announces itself. Judge it.** Every
+ `windchange` event now has an in-world tell in skyfx: a dark front wall standing low on the
+ horizon in the quarter the new wind comes FROM, rising from a smudge to a wall across the 12 s
+ before the change, clearing overhead as the swing completes β and a distant low rumble that
+ swells with it. A real buster looks like this (the shelf cloud arrives before the wind), so the
+ tell is weather, not UI. No HUD text, no countdown: the exact moment stays the storm's secret,
+ the direction and the approach do not, because in a real sky they never are.
+
+ Why it serves the corner block's thesis: night 2 (storm_03, change at 30 s) TEACHES the wall β
+ it starts rising ~18 s in, southerly arrives, lesson filed. Night 3 looks like night 2 β same
+ peak, same rain, same pea hail β except the SAME wall is already standing at tβ6, before you've
+ tied off the second corner. "Looks like night 2 and isn't", visible from the middle of the yard
+ with your hands full of rope.
+
+ Deterministic off (dt, t), like everything here: progress is a pure function of t; the wall's
+ azimuth is dirAt(change + over + 4) + Ο, sampled at a fixed instant so gust wander can't swing
+ the wall; two skyfx instances agree to the bit (asserted). Pinned in c.test.js: nothing before
+ the lead window opens, monotonic rise to ~full at the change, gone after the fade, stands in the
+ SOUTHERN sky (+Z from-vector β the open fence side), mesh really rotated there, and the gentle
+ day never shows one. Selftest **322/0/0**.
+
+ **D, the ten-second judging rig: `web/world/dev_skyfx.html`** β the new skyfx bench (one storm,
+ bare ground, scrubbable clock; `?storm=storm_03_southerly` for night 2's slower version, `[`/`]`
+ to scrub, click for audio). Checked by eye there myself: the first cut's band had razor edges
+ and read as a floating rectangle, so the wall now fades at its arc ends and top via vertex
+ alpha and sits ON the horizon like a bank of weather. What I want your eyes on in PLAY: (a) at
+ the corner block, do you LOOK SOUTH in the first ten seconds and change your rigging order β
+ without being told; (b) does it spoil β does knowing feel like reading the data, or like
+ reading the sky; (c) night 5 (ice night, change ~60 s per storm_02b) now shows a wall too β is
+ that a bonus or noise. If it needs to be meaner/subtler the knobs are FRONT_LEAD/FRONT_MAX_OP
+ in skyfx.js and they are one-line tunes.
+
+ (Deliberately NOT done: a fake wind lull before the change. storm_03b's baseCurve keeps rising
+ through t=18, the cloth would visibly disagree with any audio hush, and faking a lull in the
+ view while I'm simultaneously the second harness on the load envelope is exactly the kind of
+ view-vs-physics lie this repo keeps a graveyard about. If design wants a REAL lull it's a
+ baseCurve dip β storm data, B's audit and my envelope both re-run, next sprint's call, not a
+ quiet Friday edit.)
+
+[C] 2026-07-17 β β
**B β your wiring landed (9700b40); first cross-harness readings, and one
+ pre-emption so nobody sounds a false alarm.** Read via origin/lane/b, nothing merged here.
+
+ Β· **Prediction 1 CONFIRMED at the qualitative level**: your in-suite assert has a cb corner
+ blowing before any q corner at DEFAULT tension on the carport line, with the hint-forced-to-1
+ control holding 4/4. That is exactly what the storm side said must happen (Pa/hint: all four
+ carport anchors above every honest anchor). The trap now fires from the ANCHOR. D's fork is
+ next β their replay, not ours.
+ Β· **The hints agree bit-for-bit between harnesses**: your live-dumped 0.35 fascia /
+ 1.0 / 0.88 / 0.76 branches are exactly what my envelope read off the dressed anchors. The
+ "trees are not 1.0" surprise is now double-confirmed and E should treat it as settled fact.
+ Β· **Do NOT read your "cb blows t~29 s" against my "peaks tβ48.5-49" as a disagreement β they
+ are different quantities.** Yours is the first threshold CROSSING (load first exceeds
+ ratingΓ0.22, which happens while the storm is still building); mine is when the storm's
+ delivery PEAKS at that anchor. A corner that breaks at 29 never survives to feel the 49 s
+ peak. The peak-timing comparison in my prediction 3 applies to your resetPeaks peak-load
+ tables on lines that HOLD β when you post the full re-audit of both yards, that's the number
+ to put beside my tPeak column. If THOSE disagree, then we hunt the variable.
diff --git a/tools/storm_envelope/envelope.html b/tools/storm_envelope/envelope.html
new file mode 100644
index 0000000..3b62ea3
--- /dev/null
+++ b/tools/storm_envelope/envelope.html
@@ -0,0 +1,140 @@
+
+
+
+
storm_envelope
+
+storm_envelope β the wind side of the failure envelope
+loadingβ¦
+
+
+
+
diff --git a/tools/storm_envelope/envelope.js b/tools/storm_envelope/envelope.js
new file mode 100644
index 0000000..a6424fe
--- /dev/null
+++ b/tools/storm_envelope/envelope.js
@@ -0,0 +1,107 @@
+/**
+ * envelope.js β the STORM-side failure envelope, per anchor. [Lane C, SPRINT12]
+ *
+ * SPRINT12 gate 2.4: B measures the post-ratingHint failure envelope through the
+ * cloth sim (site_audit: settle, fly the storm, read peak corner loads in N).
+ * This is the SECOND harness on the same envelope, measured from the other side:
+ * no cloth, no rig, no tension β just what the STORM delivers at each anchor's
+ * position, through the same wind everyone samples (createWind + the site's
+ * venturi + the tree shelters, exactly as main.js wires them at site load).
+ *
+ * Two harnesses, one number β the repo rule. What must agree:
+ * Β· ORDERING: anchors ranked by wind-side pressure/hint should rank the same
+ * way B's peak corner loads do, once quad geometry is accounted for. An
+ * anchor whose load outranks its wind exposure has a variable to find.
+ * Β· TIMING: B's peak corner load should land near the wind-side tPeak (cloth
+ * lag is under a second; the venturi's alignment window is tens of seconds).
+ * What will NOT agree, by construction: absolute newtons. Corner load carries
+ * quad geometry, cloth porosity, tension and rain mass β all B's side. This
+ * harness deliberately knows none of it, which is what makes it independent.
+ *
+ * Per anchor, per storm:
+ * peak highest local horizontal wind speed over the storm, m/s
+ * (wind.speedAt β the anemometer number, same units as every probe
+ * table in THREADS)
+ * tPeak when it happened, s
+ * peakPa peak dynamic pressure, Pa: Β½ΟΒ·vΒ²Β·(1+downFracΒ²) β the (1+dΒ²) folds
+ * the downdraft back in, since cloth feels the full vector while
+ * speedAt deliberately reads horizontal-only
+ * dose β« vΒ² dt over the whole storm, mΒ²/s β exposure Γ time, so a long
+ * grind and a single spike stop looking alike
+ * paPerHint peakPa / ratingHint β the wind-side "who blows first" ranking.
+ * B's wiring is `load > hw.rating * ratingHint`, so dividing the
+ * delivered pressure by the hint predicts failure ORDER for equal
+ * hardware and equal geometry. Prediction, not measurement β B's
+ * harness is the one that owns the geometry.
+ *
+ * Sampling matches sweep.js's flight exactly: FIXED_DT steps at t = iΒ·dt over
+ * the storm's duration, same seed path (createWind(def) β def.seed), so the two
+ * harnesses fly the SAME storm, not merely the same JSON.
+ */
+
+import { createWind } from '../../web/world/js/weather.js';
+import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
+
+export const ENVELOPE = {
+ RHO: 1.225, // kg/mΒ³, sea-level air β the constant, named once
+ DT: FIXED_DT,
+};
+
+/**
+ * Measure the storm at every anchor.
+ *
+ * @param {object} o
+ * @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, ratingHint? }
+ * β pass the DRESSED world.anchors (browser) or a
+ * verified dump; graybox positions measure a yard
+ * that does not ship (site_audit's lesson).
+ * @param {object} o.stormDef parsed storm JSON
+ * @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi) β
+ * a sweep without it flies an easier yard than ships
+ * @param {Array} [o.probes] extra { id, pos } points (bed centre, throatβ¦)
+ * measured alongside, hint fixed at 1
+ * @param {number} [o.dt]
+ * @returns {{ rows, downFrac, duration }} rows sorted by paPerHint, descending
+ */
+export function stormEnvelope({ anchors, stormDef, venturi = [], probes = [], dt = FIXED_DT }) {
+ const wind = createWind(stormDef);
+ wind.setVenturi(venturi);
+ // exactly main.js:447 β every tree anchor casts a shadow, defaults and all
+ wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
+ const downFrac = wind.core.downFrac;
+ const vecFold = 1 + downFrac * downFrac; // |v|Β² = hΒ²Β·(1+dΒ²) when vy = -dΒ·h
+
+ const rows = [
+ ...anchors.map((a) => ({
+ id: a.id, type: a.type, probe: false,
+ hint: Number.isFinite(a.ratingHint) ? a.ratingHint : 1,
+ pos: { x: a.pos.x, z: a.pos.z },
+ peak: 0, tPeak: 0, dose: 0,
+ })),
+ ...probes.map((p) => ({
+ id: p.id, type: 'probe', probe: true, hint: 1,
+ pos: { x: p.pos.x, z: p.pos.z },
+ peak: 0, tPeak: 0, dose: 0,
+ })),
+ ];
+
+ const duration = stormDef.duration ?? 90;
+ const n = Math.round(duration / dt);
+ for (let i = 0; i <= n; i++) {
+ const t = i * dt;
+ for (const r of rows) {
+ const s = wind.speedAt(r.pos, t);
+ if (s > r.peak) { r.peak = s; r.tPeak = t; }
+ r.dose += s * s * dt;
+ }
+ }
+
+ for (const r of rows) {
+ r.peakPa = 0.5 * ENVELOPE.RHO * r.peak * r.peak * vecFold;
+ r.paPerHint = r.peakPa / (r.hint > 0 ? r.hint : 1);
+ // what B's wiring makes each shop tier hold HERE: rating Γ hint, in N
+ r.effN = HARDWARE.map((h) => h.rating * r.hint);
+ }
+ rows.sort((a, b) => b.paPerHint - a.paPerHint);
+ return { rows, downFrac, duration };
+}
diff --git a/tools/storm_envelope/envelope.selftest.js b/tools/storm_envelope/envelope.selftest.js
new file mode 100644
index 0000000..28922e4
--- /dev/null
+++ b/tools/storm_envelope/envelope.selftest.js
@@ -0,0 +1,100 @@
+/**
+ * envelope.selftest.js β the second harness proves it measures. [Lane C, SPRINT12]
+ *
+ * Same shape as sweep.selftest.js: [name, fn] pairs, run under Lane A's
+ * selftest.html via js/tests/c.test.js. sweep.selftest exists because site_audit
+ * flew the corner block with the funnel switched off for two sprints; this file
+ * exists so the harness built to CHECK that tool can't quietly do the same.
+ * Every assert here is written to fail if a wiring line is deleted:
+ * Β· drop setVenturi β the throat anchor's funnelled/unfunnelled peaks
+ * collapse to equal and the strict < goes red
+ * Β· drop setSheltersFromTrees β the sheltered anchor stops reading lower
+ * Β· reseed per call β the determinism byte-compare goes red
+ * Β· flip paPerHint to Γ β the weak-anchor-ranks-first assert goes red
+ */
+
+import { stormEnvelope } from './envelope.js';
+
+const TESTS = [];
+const test = (name, fn) => TESTS.push([name, fn]);
+const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
+
+/**
+ * A synthetic storm, flat on purpose: constant 10 m/s, dir 0 (blowing +X), no
+ * gusts, no wander, no spatial noise β so every local effect is the ONLY thing
+ * moving a number, and the asserts can be strict instead of statistical.
+ */
+const FLAT = {
+ name: 'flat_test', seed: 7, duration: 20,
+ baseCurve: [[0, 10], [20, 10]],
+ dirCurve: [[0, 0], [20, 0]],
+ dirWander: { amp: 0, rate: 0 },
+ spatial: { amp: 0 },
+ gusts: { firstAt: 999, minGap: 6, maxGap: 12, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.35 },
+};
+
+const A = (id, x, z, o = {}) => ({ id, type: o.type ?? 'post', pos: { x, y: 4, z }, ...o });
+
+test('envelope: deterministic β same def, same anchors, same numbers to the bit', () => {
+ const anchors = [A('a', 0, 0), A('b', 5, 3)];
+ const def = JSON.parse(JSON.stringify(FLAT));
+ const r1 = stormEnvelope({ anchors, stormDef: def });
+ const r2 = stormEnvelope({ anchors, stormDef: JSON.parse(JSON.stringify(FLAT)) });
+ for (let i = 0; i < r1.rows.length; i++) {
+ assert(r1.rows[i].peak === r2.rows[i].peak && r1.rows[i].dose === r2.rows[i].dose
+ && r1.rows[i].tPeak === r2.rows[i].tPeak,
+ `run 2 disagrees with run 1 at ${r1.rows[i].id}: ${r1.rows[i].peak} vs ${r2.rows[i].peak}`);
+ }
+});
+
+test('envelope: the venturi is wired β throat anchor reads the funnel, a far one does not', () => {
+ const anchors = [A('throat', 0, 0), A('far', 100, 0)];
+ const venturi = [{ x: 0, z: 0, axis: 0, gain: 1.5, radius: 5, sharp: 3 }];
+ const off = stormEnvelope({ anchors, stormDef: FLAT });
+ const on = stormEnvelope({ anchors, stormDef: FLAT, venturi });
+ const g = (res, id) => res.rows.find((r) => r.id === id);
+ // strict: delete envelope.js's setVenturi call and this collapses to equal
+ assert(g(on, 'throat').peak > g(off, 'throat').peak,
+ `funnel did not raise the throat: ${g(on, 'throat').peak} vs ${g(off, 'throat').peak} β setVenturi is not wired`);
+ assert(Math.abs(g(on, 'throat').peak - 15) < 0.01,
+ `gain 1.5 on 10 m/s dead-aligned should read 15 at the throat, got ${g(on, 'throat').peak}`);
+ assert(g(on, 'far').peak === g(off, 'far').peak,
+ 'the funnel reached an anchor 100 m away β radial falloff is broken');
+});
+
+test('envelope: tree shelters are wired β a downwind anchor reads the shadow', () => {
+ // tree at origin, wind blowing +X: 'lee' sits 4 m downwind inside the shadow
+ const withTree = [A('tree', 0, 0, { type: 'tree' }), A('lee', 4, 0)];
+ const without = [A('lee', 4, 0)];
+ const sheltered = stormEnvelope({ anchors: withTree, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
+ const open = stormEnvelope({ anchors: without, stormDef: FLAT }).rows.find((r) => r.id === 'lee');
+ // strict: delete envelope.js's setSheltersFromTrees call and these read equal
+ assert(sheltered.peak < open.peak,
+ `the tree cast no shadow: lee reads ${sheltered.peak} with it, ${open.peak} without β setSheltersFromTrees is not wired`);
+});
+
+test('envelope: paPerHint ranks the weak steel first at equal wind', () => {
+ // identical position, identical wind β only the hint differs (E's beam 0.22
+ // vs a clean post at 1.0). The wind-side prediction must rank the beam first.
+ const anchors = [A('beam', 2, 2, { ratingHint: 0.22 }), A('clean', 2, 2, { ratingHint: 1 })];
+ const { rows } = stormEnvelope({ anchors, stormDef: FLAT });
+ assert(rows[0].id === 'beam',
+ `equal wind, hint 0.22 vs 1.0 β 'beam' must rank first, got ${rows.map((r) => r.id).join(',')}`);
+ assert(Math.abs(rows[0].paPerHint - rows[1].paPerHint / 0.22) < 1e-9,
+ 'paPerHint is not peakPa/hint β the ranking formula drifted');
+ assert(Math.abs(rows[0].effN[0] - 1200 * 0.22) < 1e-9,
+ `effN must be ratingΓhint: carabiner on the beam should read ${1200 * 0.22} N, got ${rows[0].effN[0]}`);
+});
+
+test('envelope: peaks land inside the storm and doses are finite and positive', () => {
+ const anchors = [A('a', -3, 1), A('b', 6, -2)];
+ const { rows, duration } = stormEnvelope({ anchors, stormDef: FLAT });
+ for (const r of rows) {
+ assert(r.tPeak >= 0 && r.tPeak <= duration, `${r.id} peaked at t=${r.tPeak}, outside 0..${duration}`);
+ assert(Number.isFinite(r.dose) && r.dose > 0, `${r.id} dose is ${r.dose}`);
+ // flat 10 m/s for 20 s β dose β 100Β·20; a wildly-off dose means the loop drifted
+ assert(Math.abs(r.dose - 2000) < 20, `${r.id} dose ${r.dose.toFixed(0)} β flat 10 m/s Γ 20 s should be ~2000`);
+ }
+});
+
+export const ENVELOPE_TESTS = TESTS;
diff --git a/web/world/dev_skyfx.html b/web/world/dev_skyfx.html
new file mode 100644
index 0000000..6e71b2b
--- /dev/null
+++ b/web/world/dev_skyfx.html
@@ -0,0 +1,100 @@
+
+
+
+dev_skyfx
+
+loadingβ¦
+
+
diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js
index 10e74c9..2b0db1e 100644
--- a/web/world/js/skyfx.js
+++ b/web/world/js/skyfx.js
@@ -13,7 +13,7 @@
import * as THREE from '../vendor/three.module.js';
import { rng } from './contracts.js';
-import { valueNoise2 , hailBlockFor } from './weather.core.js';
+import { valueNoise2 , hailBlockFor, smoothstep } from './weather.core.js';
const lerp = (a, b, k) => a + (b - a) * k;
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
@@ -348,6 +348,7 @@ function createAudio(seed = 1) {
let rainGain, rainFilter;
let gustGain, gustFilter;
let hailGain, hailFilter, drumGain, drumFilter;
+ let frontGain, frontFilter;
let noiseBuf = null;
let creakNext = 0, flogNext = 0;
let started = false;
@@ -444,6 +445,14 @@ function createAudio(seed = 1) {
drumGain.connect(master);
loop(noiseBuf, drumGain, drumFilter);
+ // the change front's distant rumble: very low, very slow β a storm you
+ // can hear over the fence but not yet feel (SPRINT12 gate 3.4)
+ frontGain = ctx.createGain(); frontGain.gain.value = 0;
+ frontFilter = ctx.createBiquadFilter();
+ frontFilter.type = 'lowpass'; frontFilter.frequency.value = 85;
+ frontGain.connect(master);
+ loop(noiseBuf, frontGain, frontFilter);
+
started = true;
},
@@ -496,6 +505,13 @@ function createAudio(seed = 1) {
drumFilter.frequency.setTargetAtTime(110 + (2 - size) * 45, now, 0.2);
},
+ /** @param {number} level 0..1 change-front progress β the approaching rumble. */
+ setFront(level) {
+ if (!started) return;
+ // slow time-constant on purpose: a front swells, it doesn't tick
+ frontGain.gain.setTargetAtTime(level * 0.2, ctx.currentTime, 0.5);
+ },
+
/** Telegraph cue: you hear it coming before you feel it. */
whoosh(power, eta) {
if (!started) return;
@@ -604,6 +620,66 @@ export function createSkyFx(o = {}) {
dome.renderOrder = -1;
if (scene) scene.add(dome);
+ // ------------------------------------------------------ the change front
+ // SPRINT12 gate 3.4 β "the change announces itself." Every windchange event
+ // gets an IN-WORLD tell: a dark front wall on the horizon, standing in the
+ // quarter the new wind will come FROM, rising from a smudge to a wall across
+ // the FRONT_LEAD seconds before the change and clearing overhead after it.
+ // A real southerly buster looks exactly like this β the shelf cloud arrives
+ // before the wind does β so the tell is weather, not UI.
+ //
+ // Why this telegraphs without spoiling: it's a continuous growth with no
+ // discrete pop, so a player watching the YARD learns "that wall means the
+ // swing" on night 2 (storm_03, change at 30 s, wall from ~18 s) β and on
+ // night 3 the SAME wall is already standing at tβ6, which is the corner
+ // block's whole thesis ("looks like night 2 and isn't") made visible. The
+ // exact moment stays the storm's secret; the direction and the approach do
+ // not, because in the real sky they never are.
+ //
+ // Deterministic off (dt, t) like everything here: progress is a pure
+ // function of t, and the wall's azimuth comes from dirAt() sampled at a
+ // fixed post-change instant β def + seed in, same wall out, every run.
+ const FRONT_LEAD = 12; // s before the change the wall starts rising
+ const FRONT_FADE = 8; // s after the swing completes for it to clear
+ const FRONT_ARC = 2.2; // radians of horizon the wall spans (~126Β°)
+ const FRONT_MAX_OP = 0.85;
+ const frontEvents = (def.events || [])
+ .filter((e) => e.type === 'windchange' && Number.isFinite(e.t))
+ .map((e) => ({ t0: e.t, over: e.over ?? 6, az: null }));
+ // Band from ~34Β° above the horizon down to it, centred (in local space) on
+ // azimuth Ο: SphereGeometry's (x,z) = (-cosΟ, sinΟ)Β·sinΞΈ, so Ο=0 sits at
+ // atan2(z,x) = Ο. rotation.y = Ο β A then carries the centre to azimuth A.
+ const frontGeo = new THREE.SphereGeometry(170, 24, 8, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.20);
+ {
+ // Soft edges via vertex alpha, or the band reads as a floating rectangle
+ // (checked by eye on dev_skyfx.html β the hard phi/theta cut was exactly
+ // that). Full at the horizon and the arc's centre; fading to nothing at
+ // the arc ends and across the top, so it sits ON the horizon like a bank
+ // of weather instead of hanging in the sky like a screen.
+ // Sphere uv: x runs 0..1 across the arc, y is 1 at the band top, 0 at the
+ // horizon edge.
+ const uv = frontGeo.attributes.uv;
+ const rgba = new Float32Array(uv.count * 4);
+ for (let i = 0; i < uv.count; i++) {
+ const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 0.75);
+ const up = 1 - smoothstep(0.45, 1, uv.getY(i));
+ rgba[i * 4] = 1; rgba[i * 4 + 1] = 1; rgba[i * 4 + 2] = 1;
+ rgba[i * 4 + 3] = across * up;
+ }
+ frontGeo.setAttribute('color', new THREE.BufferAttribute(rgba, 4));
+ }
+ const frontMesh = new THREE.Mesh(
+ frontGeo,
+ new THREE.MeshBasicMaterial({
+ color: 0x0c1016, side: THREE.BackSide, transparent: true, vertexColors: true,
+ opacity: 0, depthWrite: false, fog: false,
+ }),
+ );
+ frontMesh.renderOrder = -1; // with the dome; nearer radius wins the overlay
+ frontMesh.visible = false;
+ if (scene) scene.add(frontMesh);
+ let frontLevel = 0, frontRise = 0, frontAz = null;
+
// Remember what world.js handed us, so dispose() puts it back exactly.
// Fog is captured BY VALUE, not by reference: step() mutates that very object
// in place, so `scene.fog = original.fog` restores the object we just spent a
@@ -636,10 +712,15 @@ export function createSkyFx(o = {}) {
const w = new THREE.Vector3();
const fx = {
- rain, audio, dome, shadow, hailShadow,
+ rain, 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; },
+ /** 0..1 β how far risen the change-front wall is (gate 3.4). Pure in t. */
+ get changeFront() { return frontLevel; },
+ /** Azimuth (radians, XZ) the front stands in β the quarter the new wind
+ * comes FROM β or null while no front is up. For D's judging and asserts. */
+ get changeFrontAz() { return frontAz; },
/**
* 0..1 of a ground rect the sail is keeping dry, right now.
@@ -809,6 +890,23 @@ export function createSkyFx(o = {}) {
hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl);
}
+ // --- the change front: progress is pure in t; the view applies it below.
+ // Computed above the render gate so a headless HUD (or a test) can read
+ // changeFront the same way it reads hailAmount.
+ frontLevel = 0; frontRise = 0; frontAz = null;
+ for (const ev of frontEvents) {
+ const rise = smoothstep(ev.t0 - FRONT_LEAD, ev.t0, t);
+ const lvl = rise * (1 - smoothstep(ev.t0 + ev.over, ev.t0 + ev.over + FRONT_FADE, t));
+ if (lvl > frontLevel) {
+ // the settled post-change heading, sampled at a FIXED instant so the
+ // wall doesn't wander with the gusts; +Ο = the quarter it comes FROM
+ if (ev.az == null && typeof wind.dirAt === 'function') {
+ ev.az = wind.dirAt(ev.t0 + ev.over + 4) + Math.PI;
+ }
+ frontLevel = lvl; frontRise = rise; frontAz = ev.az;
+ }
+ }
+
// Past here is the VIEW and the NOISE β drops to place, a dome to tint, a
// gale to hear. All of it needs somewhere to stand. A headless harness has
// the numbers it came for and can stop here.
@@ -848,6 +946,19 @@ export function createSkyFx(o = {}) {
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
+ // --- the front wall (progress computed above the render gate) ---
+ // Grows upward as it rises (scale.y keys the RISE, so it keeps its full
+ // height while fading through the overhead pass), darkens as it comes.
+ // Deliberately a silhouette: no flash tint β lightning brightens the sky
+ // BEHIND it, which is what makes a shelf cloud read as a wall.
+ frontMesh.visible = frontLevel > 0.002;
+ if (frontMesh.visible) {
+ frontMesh.position.copy(camPos);
+ if (frontAz != null) frontMesh.rotation.y = Math.PI - frontAz;
+ frontMesh.scale.y = 0.3 + 0.7 * frontRise;
+ frontMesh.material.opacity = frontLevel * FRONT_MAX_OP;
+ }
+
// --- rain + hail drops (the grids they read were built above) ---
rain.step(dt, camPos, w, intensity, shadow);
hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow);
@@ -858,6 +969,9 @@ export function createSkyFx(o = {}) {
// --- audio ---
audio.setLevels(speed, intensity);
audio.setHail(hailAmt, onCloth, stone);
+ // the front carries its own distant rumble β you HEAR the change coming
+ // the way you see it, and both are the same pure function of t
+ audio.setFront(frontLevel);
const tg = wind.gustTelegraph(t);
if (tg && tg !== lastTelegraph) {
// fires once per gust, right as the telegraph opens
@@ -886,6 +1000,7 @@ export function createSkyFx(o = {}) {
scene.remove(rain.mesh);
scene.remove(hail.mesh);
scene.remove(dome);
+ scene.remove(frontMesh);
scene.background = original.background;
if (ownsFog) {
scene.fog = null; // we brought it; we take it
@@ -902,6 +1017,8 @@ export function createSkyFx(o = {}) {
hail.dispose();
dome.geometry.dispose();
dome.material.dispose();
+ frontMesh.geometry.dispose();
+ frontMesh.material.dispose();
domeTex.dispose();
audio.dispose();
},
diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js
index 03e7140..ee6a125 100644
--- a/web/world/js/tests/c.test.js
+++ b/web/world/js/tests/c.test.js
@@ -21,6 +21,7 @@ import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
import { weatherCases } from './weather.selftest.js';
+import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
// Keep in step with data/storms/. The node runner globs the directory, so this
// list going stale shows up here first β as it did when storm_03 landed and the
@@ -41,6 +42,11 @@ export default async function run(t) {
const { cases } = weatherCases(storms);
for (const c of cases) t.test(c.name, c.fn);
+ // --- SPRINT12 gate 2.4: the storm-side envelope harness audits itself ---
+ // Same pattern as sweep.selftest in b.test.js: the tool that second-guesses
+ // B's audit must not be the thing that drifts. See envelope.selftest.js.
+ for (const [name, fn] of ENVELOPE_TESTS) t.test(name, fn);
+
// --- the bits that need the THREE adapter, not just the core ---
t.test('weather.js satisfies the wind contract', () => {
@@ -271,6 +277,92 @@ export default async function run(t) {
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times β it has no lightning at all`);
});
+ // --- SPRINT12 gate 3.4: the change announces itself ---
+ // The corner block's thesis is "looks like night 2 and isn't" β storm_03b's
+ // change lands at 18 s, not 30. The tell is a dark front wall standing in the
+ // quarter the new wind comes FROM, rising across the 12 s before the change
+ // and clearing after the swing. These asserts pin: the window (nothing before
+ // the lead opens, gone after the fade), the rise (monotonic, reaches ~full),
+ // the DIRECTION (the wall stands in the southern sky β +Z, the open fence
+ // side β and the mesh really is rotated there), and that a changeless storm
+ // never raises it. D judges whether it READS; this pins that it exists,
+ // where it stands, and when.
+ t.test('gate 3.4: the front wall rises before the buster, stands in the south, clears after', () => {
+ const scene = new THREE.Scene();
+ const wind = createWind(storms.storm_03b_earlybuster);
+ const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
+ const ev = storms.storm_03b_earlybuster.events.find((e) => e.type === 'windchange');
+ const t0 = ev.t, over = ev.over ?? 6; // 18, 6
+
+ let prev = 0, peak = 0, azAtChange = null, rotAtChange = null, opAtChange = 0, visAtChange = false;
+ fixedLoop(40, FIXED_DT, (dt, time) => {
+ sky.step(dt, time, {});
+ const f = sky.changeFront;
+ assert(Number.isFinite(f) && f >= 0 && f <= 1, `front out of range at t=${time.toFixed(2)}: ${f}`);
+ if (time < t0 - 12.01) assert(f === 0, `front up at t=${time.toFixed(2)} β before its lead window opens`);
+ if (time > t0 - 12 && time <= t0) {
+ assert(f >= prev - 1e-9, `front FELL while rising: ${prev.toFixed(3)} β ${f.toFixed(3)} at t=${time.toFixed(2)}`);
+ prev = f;
+ if (f > peak) peak = f;
+ }
+ if (Math.abs(time - t0) < FIXED_DT) {
+ azAtChange = sky.changeFrontAz;
+ rotAtChange = sky.front.rotation.y;
+ opAtChange = sky.front.material.opacity;
+ visAtChange = sky.front.visible;
+ }
+ if (time > t0 + over + 8.1) assert(f === 0, `front still up at t=${time.toFixed(2)} β the swing has long cleared`);
+ });
+
+ assert(peak > 0.98, `front only reached ${peak.toFixed(3)} by the change β it should stand near full`);
+ assert(visAtChange && opAtChange > 0.7, `wall not visible at the change (visible=${visAtChange}, opacity=${opAtChange})`);
+
+ // direction: the wall stands where the new wind comes FROM. dirAt is the
+ // heading the wind blows TOWARD, so from = settled post-change dir + Ο.
+ const expected = wind.dirAt(t0 + over + 4) + Math.PI;
+ assert(Math.abs(azAtChange - expected) < 1e-9,
+ `front azimuth ${azAtChange} is not the settled post-change upwind quarter ${expected}`);
+ // and that quarter is the SOUTH: the buster blows toward -Z (the house),
+ // so it comes from +Z, the open fence side. sin(az) is the from-vector's z.
+ assert(Math.sin(azAtChange) > 0.3,
+ `the "southerly" front stands at azimuth ${azAtChange.toFixed(2)} β from-vector z=${Math.sin(azAtChange).toFixed(2)}, not the southern sky`);
+ // the mesh is really rotated there (local band centre sits at azimuth Ο,
+ // so rotation.y = Ο β az carries it to az; see skyfx geometry note)
+ assert(Math.abs(rotAtChange - (Math.PI - azAtChange)) < 1e-9,
+ `wall rotation ${rotAtChange} does not place the band at azimuth ${azAtChange}`);
+ sky.dispose();
+ });
+
+ t.test('gate 3.4: night 2 teaches the same tell, a changeless storm never shows it, and it is pure in t', () => {
+ // storm_03 (night 2): same wall, later β up but not full at t=20 (change 30)
+ const mk = (name) => createSkyFx({
+ scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(),
+ wind: createWind(storms[name]),
+ });
+ const a = mk('storm_03_southerly'), b = mk('storm_03_southerly');
+ let mid = 0;
+ fixedLoop(21, FIXED_DT, (dt, time) => {
+ a.step(dt, time, {}); b.step(dt, time, {});
+ // determinism: two instances at the same t agree to the bit β no hidden
+ // clock, no per-instance random draw in the tell
+ assert(a.changeFront === b.changeFront,
+ `two skyfx disagree on the front at t=${time.toFixed(2)}: ${a.changeFront} vs ${b.changeFront}`);
+ mid = a.changeFront;
+ });
+ assert(mid > 0.05 && mid < 0.95,
+ `night 2's wall should be RISING at t=21 (change at 30), reads ${mid.toFixed(3)}`);
+ a.dispose(); b.dispose();
+
+ // the gentle day has no windchange: no wall, ever
+ const calm = mk('storm_01_gentle');
+ fixedLoop(storms.storm_01_gentle.duration, FIXED_DT, (dt, time) => {
+ calm.step(dt, time, {});
+ assert(calm.changeFront === 0 && !calm.front.visible,
+ `a changeless storm raised the front at t=${time.toFixed(2)}`);
+ });
+ calm.dispose();
+ });
+
// --- SPRINT5 decision 13: hail makes the garden score respond to the rig ---
// The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig,
// because honest rain walks under a sail. Steep hail doesn't β a sail over the