'use strict';
// SHADES — Lane C — skyfx: rain, storm sky, lightning, and the noise of it all.
//
// PLAN3D §5-C.2/§5-C.3. Lane A's world.js owns the calm sky and the base lights;
// this MODULATES them as the storm builds and hands them back on dispose(), so
// two lanes never fight over one scene.
//
// Everything is duck-typed and optional — no sun light, no audio, no sail? Then
// those layers just don't run. Lane A can wire the pieces as they land.
//
// Audio is synthesized, not sampled: web/world/audio/ is empty, we ship no CDN
// and no deps, and a filtered-noise bed tracks wind speed better than a loop.

import * as THREE from '../vendor/three.module.js';
import { rng } from './contracts.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);

const CALM_SKY = new THREE.Color(0x9fc4e8);
const STORM_SKY = new THREE.Color(0x2a2f3a);
const NIGHT_SKY = new THREE.Color(0x11141c);
const WHITE = new THREE.Color(0xffffff);
const FLASH_COL = new THREE.Color(0xdfe8ff);
const SUN_STORM = new THREE.Color(0xc4cedb);   // the noon disc gone slate (gate 2.1)

// ------------------------------------------------------------ rain shadow
/**
 * Where the sail is keeping the ground dry (SPRINT2 §Lane C.3).
 *
 * This is the RAIN shadow, not the sun shadow. Rain arrives along the wind, so
 * the dry patch sits downwind of the cloth and slides across the yard as the
 * wind swings — at the southerly change it walks right off the garden, which is
 * free drama and the honest physics.
 *
 * Cheap on purpose: ray-testing 3 k drops against 162 triangles every frame is
 * ~486 k intersections for an effect nobody inspects closely. Instead we project
 * the sail's triangles ALONG the rain onto the ground and rasterise them into a
 * coarse grid, a few times a second — the cloth moves slowly next to the rain.
 * Per-drop cost is then one projection and one array read.
 *
 * Reads `rig.pos`/`rig.tris`, which are already the surface Lane A's sail view
 * consumes, so this needs nothing new from Lane B.
 */
export class RainShadow {
  constructor(o = {}) {
    this.n = o.cells ?? 64;               // ~0.56 m over a 36 m span
    this.half = o.half ?? 18;
    this.groundY = o.groundY ?? 0;
    this.ceil = new Float32Array(this.n * this.n);   // sail height per cell, 0 = open sky
    this.live = false;
    this.dx = 0; this.dy = -1; this.dz = 0;
  }

  _idx(gx, gz) {
    const i = Math.floor(((gx + this.half) / (this.half * 2)) * this.n);
    const j = Math.floor(((gz + this.half) / (this.half * 2)) * this.n);
    if (i < 0 || j < 0 || i >= this.n || j >= this.n) return -1;
    return j * this.n + i;
  }

  /** @param {object} rig Lane B's SailRig @param {number} dx,dy,dz unit rain direction */
  update(rig, dx, dy, dz) {
    this.live = false;
    if (!rig || !rig.pos || !rig.tris || dy > -1e-3) return;   // rain must fall
    this.ceil.fill(0);
    this.dx = dx; this.dy = dy; this.dz = dz;

    const pos = rig.pos, tris = rig.tris, cellW = (this.half * 2) / this.n;
    const gx = [0, 0, 0], gz = [0, 0, 0], gy = [0, 0, 0];
    for (let i = 0; i < tris.length; i += 3) {
      for (let k = 0; k < 3; k++) {
        const a = tris[i + k] * 3;
        const vy = pos[a + 1];
        const tt = (vy - this.groundY) / -dy;      // slide down the rain to the ground
        gx[k] = pos[a] + dx * tt;
        gz[k] = pos[a + 2] + dz * tt;
        gy[k] = vy;
      }
      const d = (gz[1] - gz[2]) * (gx[0] - gx[2]) + (gx[2] - gx[1]) * (gz[0] - gz[2]);
      if (Math.abs(d) < 1e-9) continue;            // degenerate once projected

      const minX = Math.min(gx[0], gx[1], gx[2]), maxX = Math.max(gx[0], gx[1], gx[2]);
      const minZ = Math.min(gz[0], gz[1], gz[2]), maxZ = Math.max(gz[0], gz[1], gz[2]);
      for (let px = minX; px <= maxX + cellW; px += cellW) {
        for (let pz = minZ; pz <= maxZ + cellW; pz += cellW) {
          const c = this._idx(px, pz);
          if (c < 0) continue;
          // barycentric, with a little slop so cracks between tris don't leak rain
          const l1 = ((gz[1] - gz[2]) * (px - gx[2]) + (gx[2] - gx[1]) * (pz - gz[2])) / d;
          const l2 = ((gz[2] - gz[0]) * (px - gx[2]) + (gx[0] - gx[2]) * (pz - gz[2])) / d;
          const l3 = 1 - l1 - l2;
          if (l1 < -0.05 || l2 < -0.05 || l3 < -0.05) continue;
          const y = l1 * gy[0] + l2 * gy[1] + l3 * gy[2];
          if (y > this.ceil[c]) this.ceil[c] = y;
        }
      }
      this.live = true;
    }
  }

  /** Has a drop here already been stopped by the cloth? */
  occluded(x, y, z) {
    if (!this.live) return false;
    const tt = (y - this.groundY) / -this.dy;
    const c = this._idx(x + this.dx * tt, z + this.dz * tt);
    if (c < 0) return false;
    const ceil = this.ceil[c];
    return ceil > 0 && y < ceil;          // above the cloth it hasn't hit yet
  }

  /** 0..1 of a ground rect under cover. Same rect shape as sailRig.coverageOver. */
  fractionOver(rect, cols = 6, rows = 4) {
    if (!this.live) return 0;
    let hit = 0;
    for (let i = 0; i < cols; i++) {
      for (let j = 0; j < rows; j++) {
        const x = rect.x + ((i + 0.5) / cols - 0.5) * rect.w;
        const z = rect.z + ((j + 0.5) / rows - 0.5) * rect.d;
        const c = this._idx(x, z);
        if (c >= 0 && this.ceil[c] > 0) hit++;
      }
    }
    return hit / (cols * rows);
  }
}

/** Rain velocity, m/s. One definition, used by the drops and by the shadow. */
function rainVelocity(w, intensity, out) {
  return out.set(w.x * 0.55, -(9 + intensity * 4), w.z * 0.55);
}

// Hail falls STEEP, and that is the whole of decision 13. A 1 cm stone's
// terminal velocity is ~22 m/s (a raindrop's is ~9), and a dense stone couples
// only weakly to the crosswind, so even a 30 m/s gale leans it no more than
// ~20° off vertical — where a sail overhead still blocks it. (Rain at 9 m/s in
// the same gale comes in at atan(30/9) ≈ 73°, nearly sideways, which is why it
// walks under the sail and can't score the rig.) Do NOT re-open the rain-angle
// argument here; steep is the point.
const HAIL_FALL = 22;                              // m/s terminal, ~1 cm ice
const HAIL_LEAN_COUPLING = 0.3;                    // dense stones catch little wind
const HAIL_MAX_LEAN = Math.tan(20 * Math.PI / 180); // cap ~20° off vertical

/** Hail velocity, m/s. Steep — see the note above. Used by stones and shadow. */
function hailVelocity(w, out) {
  const cap = HAIL_FALL * HAIL_MAX_LEAN;
  let hx = w.x * HAIL_LEAN_COUPLING, hz = w.z * HAIL_LEAN_COUPLING;
  const mag = Math.hypot(hx, hz);
  if (mag > cap) { const s = cap / mag; hx *= s; hz *= s; }
  return out.set(hx, -HAIL_FALL, hz);
}

// ---------------------------------------------------------------- hail
// Instanced falling stones, same wrap-around-the-camera trick as the rain but
// fewer, whiter, faster and much steeper. Stones under the cloth are hidden so
// you SEE the sail doing its job. Count scales with intensity; a hail-free storm
// draws nothing.
function createHail(opts) {
  const max = opts.maxStones ?? 1300;
  const half = opts.half ?? 16;
  const height = opts.height ?? 22;
  const groundY = opts.groundY ?? 0;
  const rand = rng(0x4a11);

  const geo = new THREE.BoxGeometry(0.05, 0.05, 0.05);   // a little cube reads as a stone
  const mat = new THREE.MeshBasicMaterial({
    color: 0xeaf2ff, transparent: true, opacity: 0.9, depthWrite: false, fog: false,
  });
  const mesh = new THREE.InstancedMesh(geo, mat, max);
  mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
  mesh.frustumCulled = false;
  mesh.renderOrder = 3;
  mesh.count = 0;

  const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max);
  const jit = new Float32Array(max);
  for (let i = 0; i < max; i++) {
    px[i] = (rand() * 2 - 1) * half;
    py[i] = groundY + rand() * height;
    pz[i] = (rand() * 2 - 1) * half;
    jit[i] = 0.85 + rand() * 0.3;
  }

  const m = new THREE.Matrix4();
  const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);
  const top = groundY + height;

  return {
    mesh,
    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);
      for (let i = 0; i < n; i++) {
        const j = jit[i];
        px[i] += vel.x * j * dt;
        py[i] += vel.y * j * dt;                    // vel.y is negative
        pz[i] += vel.z * j * dt;
        let d = px[i] - camPos.x;
        if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2;
        d = pz[i] - camPos.z;
        if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2;
        if (py[i] < groundY) py[i] += height; else if (py[i] > top) py[i] -= height;
        if (shadow && shadow.occluded(px[i], py[i], pz[i])) { mesh.setMatrixAt(i, HIDDEN); continue; }
        m.elements[12] = px[i]; m.elements[13] = py[i]; m.elements[14] = pz[i];
        mesh.setMatrixAt(i, m);
      }
      mesh.instanceMatrix.needsUpdate = true;
    },
    dispose() { geo.dispose(); mat.dispose(); },
  };
}

// ---------------------------------------------------------------- rain
function createRain(opts) {
  const max = opts.maxDrops ?? 3000;
  const half = opts.half ?? 18;          // box half-extent around the camera
  const height = opts.height ?? 24;
  const groundY = opts.groundY ?? 0;
  const rand = rng(0xd309);

  // one thin quadish streak, instanced — cheap and reads as rain in motion
  const geo = new THREE.BoxGeometry(0.015, 1, 0.015);
  const mat = new THREE.MeshBasicMaterial({
    color: 0xb4d2ff, transparent: true, opacity: 0.34,
    depthWrite: false, fog: false,
  });
  const mesh = new THREE.InstancedMesh(geo, mat, max);
  mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
  mesh.frustumCulled = false;
  mesh.renderOrder = 2;
  mesh.count = 0;

  const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max);
  const jitter = new Float32Array(max);
  for (let i = 0; i < max; i++) {
    px[i] = (rand() * 2 - 1) * half;
    py[i] = groundY + rand() * height;
    pz[i] = (rand() * 2 - 1) * half;
    jitter[i] = 0.75 + rand() * 0.5;     // not every drop is the same drop
  }

  const m = new THREE.Matrix4();
  const q = new THREE.Quaternion();
  const up = new THREE.Vector3(0, 1, 0);
  const vel = new THREE.Vector3();
  const unit = new THREE.Vector3();
  const scale = new THREE.Vector3(1, 1, 1);
  const zero = new THREE.Vector3();
  // zero-scale: an instance that renders to nothing
  const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);

  return {
    mesh,
    /**
     * @param {THREE.Vector3} camPos
     * @param {THREE.Vector3} w local wind
     * @param {RainShadow} [shadow] drops under the cloth are not drawn
     */
    step(dt, camPos, w, intensity, shadow) {
      const n = Math.floor(max * clamp01(intensity));
      mesh.count = n;
      if (n === 0) return;

      // rain leans into the wind; that lean IS the readout of how hard it's blowing
      rainVelocity(w, intensity, vel);
      const fall = -vel.y;
      const speed = vel.length() || 1;
      q.setFromUnitVectors(up, unit.copy(vel).divideScalar(speed));
      // streak stretches with speed — drizzle is dots, a squall is lines
      scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1);
      m.compose(zero, q, scale);

      const top = groundY + height;
      for (let i = 0; i < n; i++) {
        const j = jitter[i];
        px[i] += w.x * 0.55 * j * dt;
        py[i] -= fall * j * dt;
        pz[i] += w.z * 0.55 * j * dt;

        // wrap the box around the camera instead of respawning — no bookkeeping,
        // and the rain is always exactly where the player is looking
        let d = px[i] - camPos.x;
        if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2;
        d = pz[i] - camPos.z;
        if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2;
        if (py[i] < groundY) py[i] += height;
        else if (py[i] > top) py[i] -= height;

        // Under the cloth this drop was stopped up there. Keep simulating it —
        // it wraps back to the top and rains again beyond the sail's edge — but
        // don't draw it. A degenerate matrix is cheaper than reshuffling the
        // instance list, and InstancedMesh has no per-instance visibility.
        if (shadow && shadow.occluded(px[i], py[i], pz[i])) {
          mesh.setMatrixAt(i, HIDDEN);
          continue;
        }

        m.elements[12] = px[i];
        m.elements[13] = py[i];
        m.elements[14] = pz[i];
        mesh.setMatrixAt(i, m);
      }
      mesh.instanceMatrix.needsUpdate = true;
    },
    dispose() { geo.dispose(); mat.dispose(); },
  };
}

// ------------------------------------------------------------ cloud dome
function cloudTexture(size = 256, seed = 7) {
  const cv = document.createElement('canvas');
  cv.width = cv.height = size;
  const ctx = cv.getContext('2d');
  const img = ctx.createImageData(size, size);
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      // fbm at integer frequencies, each octave wrapped at its own period, so
      // the texture tiles: it's set to repeat(3,2) and it scrolls forever, and
      // an unwrapped octave puts a dead straight seam across the sky.
      let n = 0, amp = 0.5, f = 4;
      for (let o = 0; o < 4; o++) {
        n += amp * valueNoise2((x / size) * f, (y / size) * f, seed + o * 977, f);
        amp *= 0.5; f *= 2;
      }
      const v = clamp01((n - 0.28) * 2.2);
      const i = (y * size + x) * 4;
      const shade = 150 + v * 70;
      img.data[i] = shade; img.data[i + 1] = shade; img.data[i + 2] = shade + 12;
      img.data[i + 3] = v * 235;
    }
  }
  ctx.putImageData(img, 0, 0);
  const tex = new THREE.CanvasTexture(cv);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(3, 2);
  return tex;
}

// ---------------------------------------------------------------- audio
// Synthesized layers. WebAudio won't start until a gesture (browser rule), so
// everything is built lazily on unlock() and silently absent before it.
const MASTER_GAIN = 0.55;

function createAudio(seed = 1) {
  let ctx = null, master = null;
  let muted = false;
  let windGain, windFilter, windHowl, howlGain;
  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;

  function noiseBuffer(c) {
    const len = c.sampleRate * 4;
    const buf = c.createBuffer(1, len, c.sampleRate);
    const d = buf.getChannelData(0);
    const rand = rng(seed ^ 0x0157);
    let last = 0;
    for (let i = 0; i < len; i++) {
      const white = rand() * 2 - 1;
      last = (last + 0.02 * white) / 1.02;      // brown-ish: weight to the low end
      d[i] = last * 3.5;
    }
    return buf;
  }

  function loop(buf, dest, filter) {
    const src = ctx.createBufferSource();
    src.buffer = buf; src.loop = true;
    src.connect(filter); filter.connect(dest);
    src.start();
    return src;
  }

  return {
    get ready() { return started; },
    /** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph
     *  got built — a suspended context is still silent, so the HUD reports this. */
    get state() { return ctx ? ctx.state : 'none'; },
    /** A's M key (gate 3.3). A flag, not just a gain write, so muting works
     *  before unlock() has built the graph and survives it. */
    setMute(on) {
      muted = !!on;
      if (master) master.gain.value = muted ? 0 : MASTER_GAIN;
    },
    get muted() { return muted; },
    /** Real bus gain, for asserts — null until unlock() builds the graph. */
    get masterGain() { return master ? master.gain.value : null; },
    /** Current layer gains — for the HUD and for asserting the bed tracks wind. */
    levels() {
      if (!started) return null;
      return {
        wind: +windGain.gain.value.toFixed(4),
        howl: +howlGain.gain.value.toFixed(4),
        rain: +rainGain.gain.value.toFixed(4),
        cutoff: Math.round(windFilter.frequency.value),
      };
    },

    /** Call from the first click/keydown. Safe to call repeatedly. */
    unlock() {
      if (started) return;
      const AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) return;
      ctx = new AC();
      if (ctx.state === 'suspended') ctx.resume();
      master = ctx.createGain();
      // honour a mute that landed BEFORE the first gesture built the graph —
      // M on the splash screen must not be forgotten by the unlock
      master.gain.value = muted ? 0 : MASTER_GAIN;
      master.connect(ctx.destination);
      noiseBuf = noiseBuffer(ctx);

      // wind bed: brown noise through a lowpass that opens as it blows harder
      windGain = ctx.createGain(); windGain.gain.value = 0;
      windFilter = ctx.createBiquadFilter();
      windFilter.type = 'lowpass'; windFilter.frequency.value = 400;
      windGain.connect(master);
      loop(noiseBuf, windGain, windFilter);

      // howl: a resonant band on top — this is the bit that sounds like a gale
      howlGain = ctx.createGain(); howlGain.gain.value = 0;
      windHowl = ctx.createBiquadFilter();
      windHowl.type = 'bandpass'; windHowl.frequency.value = 500; windHowl.Q.value = 6;
      howlGain.connect(master);
      loop(noiseBuf, howlGain, windHowl);

      rainGain = ctx.createGain(); rainGain.gain.value = 0;
      rainFilter = ctx.createBiquadFilter();
      rainFilter.type = 'highpass'; rainFilter.frequency.value = 1800;
      rainGain.connect(master);
      loop(noiseBuf, rainGain, rainFilter);

      gustGain = ctx.createGain(); gustGain.gain.value = 0;
      gustFilter = ctx.createBiquadFilter();
      gustFilter.type = 'bandpass'; gustFilter.frequency.value = 300; gustFilter.Q.value = 2.5;
      gustGain.connect(master);
      loop(noiseBuf, gustGain, gustFilter);

      // hail clatter: bright, hard highpass — ice on concrete
      hailGain = ctx.createGain(); hailGain.gain.value = 0;
      hailFilter = ctx.createBiquadFilter();
      hailFilter.type = 'highpass'; hailFilter.frequency.value = 3000;
      hailGain.connect(master);
      loop(noiseBuf, hailGain, hailFilter);

      // the DRUM: hail on taut cloth, a low resonant thrum. This is the "my sail
      // is earning its money" sound — it only speaks when the sail is actually
      // catching hail, so a rig over the bed sounds different from bare sky.
      drumGain = ctx.createGain(); drumGain.gain.value = 0;
      drumFilter = ctx.createBiquadFilter();
      drumFilter.type = 'bandpass'; drumFilter.frequency.value = 140; drumFilter.Q.value = 3;
      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;
    },

    /** One-shot filtered noise burst — the workhorse for creak/flog/thunder. */
    burst({ freq, q, gain, attack, decay, type = 'bandpass' }) {
      if (!started) return;
      const now = ctx.currentTime;
      const src = ctx.createBufferSource();
      src.buffer = noiseBuf;
      src.loop = true;
      const f = ctx.createBiquadFilter();
      f.type = type; f.frequency.value = freq; f.Q.value = q ?? 4;
      const g = ctx.createGain();
      g.gain.setValueAtTime(0.0001, now);
      g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), now + attack);
      g.gain.exponentialRampToValueAtTime(0.0001, now + attack + decay);
      src.connect(f); f.connect(g); g.connect(master);
      src.start(now);
      src.stop(now + attack + decay + 0.05);
    },

    /** @param {number} speed m/s @param {number} rain 0..1 */
    setLevels(speed, rain) {
      if (!started) return;
      const now = ctx.currentTime;
      const s = clamp01(speed / 32);
      // gain and brightness both climb — a 30 m/s wind isn't just a louder 5 m/s one
      windGain.gain.setTargetAtTime(0.05 + s * 0.5, now, 0.15);
      windFilter.frequency.setTargetAtTime(220 + s * 900, now, 0.2);
      howlGain.gain.setTargetAtTime(s * s * 0.28, now, 0.2);
      windHowl.frequency.setTargetAtTime(320 + s * 700, now, 0.25);
      rainGain.gain.setTargetAtTime(rain * 0.34, now, 0.3);
      rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3);
    },

    /**
     * @param {number} intensity 0..1 hail
     * @param {number} onCloth   0..1 of the hail the sail overhead is catching
     * @param {number} size      stone-size scalar — bigger stones drum lower
     */
    setHail(intensity, onCloth, size) {
      if (!started) return;
      const now = ctx.currentTime;
      // clatter fades as the cloth intercepts more of the storm — some ice still
      // reaches the ground past the sail, but the open-ground roar drops
      hailGain.gain.setTargetAtTime(intensity * (1 - onCloth * 0.7) * 0.3, now, 0.15);
      hailFilter.frequency.setTargetAtTime(2400 + (2 - size) * 700, now, 0.2);
      // the drum rises exactly as the sail catches hail — the payoff sound
      drumGain.gain.setTargetAtTime(intensity * onCloth * 0.5, now, 0.12);
      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;
      const now = ctx.currentTime;
      const p = clamp01(power / 18);
      gustGain.gain.cancelScheduledValues(now);
      gustGain.gain.setValueAtTime(gustGain.gain.value, now);
      gustGain.gain.linearRampToValueAtTime(0.05 + p * 0.3, now + Math.max(0.05, eta));
      gustGain.gain.linearRampToValueAtTime(0.0001, now + Math.max(0.05, eta) + 2.2);
      gustFilter.frequency.cancelScheduledValues(now);
      gustFilter.frequency.setValueAtTime(220, now);
      gustFilter.frequency.linearRampToValueAtTime(240 + p * 700, now + Math.max(0.05, eta) + 0.8);
    },

    /** Rope creak — rate and pitch both ride the worst corner. */
    creak(dt, loadFrac) {
      if (!started || loadFrac < 0.35) return;
      creakNext -= dt;
      if (creakNext > 0) return;
      creakNext = lerp(1.1, 0.16, clamp01((loadFrac - 0.35) / 0.65));
      this.burst({
        freq: 180 + loadFrac * 420, q: 9,
        gain: 0.05 + loadFrac * 0.22, attack: 0.012, decay: 0.16,
      });
    },

    /** Freed corner: canvas cracking itself to pieces. */
    flog(dt, speed) {
      if (!started) return;
      flogNext -= dt;
      if (flogNext > 0) return;
      flogNext = Math.max(0.09, 0.5 - speed * 0.011);
      this.burst({ freq: 900 + speed * 26, q: 1.2, gain: 0.1 + clamp01(speed / 30) * 0.3, attack: 0.005, decay: 0.1 });
    },

    thunder(power) {
      if (!started) return;
      this.burst({ type: 'lowpass', freq: 90 + power * 60, q: 0.7, gain: 0.25 + power * 0.5, attack: 0.06, decay: 2.6 + power * 1.6 });
    },

    dispose() { if (ctx) ctx.close(); started = false; },
  };
}

// ---------------------------------------------------------------- skyfx
/**
 * @param {object} o
 * @param {THREE.Scene} o.scene
 * @param {THREE.Camera} o.camera
 * @param {object} o.wind                 from weather.js
 * @param {THREE.Light} [o.sun]           Lane A's directional light — we dim it
 * @param {THREE.Light} [o.hemi]          Lane A's hemisphere light
 * @param {boolean} [o.night]             storm_02 is a wild NIGHT
 * @param {function} [o.onEvent]          (text) HUD ticker
 */
export function createSkyFx(o = {}) {
  const { scene, camera, wind } = o;
  const sun = o.sun || null;
  const hemi = o.hemi || null;
  const def = (wind && wind.def) || {};
  const skyDef = def.sky || {};
  const darkness = skyDef.darkness ?? 0.7;
  const scroll = skyDef.cloudScroll ?? 0.06;
  // The storm data gets a say: `sky.night` is the author's call, the darkness
  // threshold is only a fallback for storms that never state one.
  const target = (o.night ?? skyDef.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
  // Fire a flash on any gust this strong (m/s of gust power). Storms that don't
  // ask stay lit only by their authored strikes.
  const lightningGustPow = skyDef.lightningGustPow ?? Infinity;
  const firedGusts = new Set();

  const rain = createRain({ groundY: o.groundY ?? 0 });
  if (scene) scene.add(rain.mesh);
  const shadow = new RainShadow({ groundY: o.groundY ?? 0 });
  const rainDir = new THREE.Vector3();
  let shadowTick = 0;

  // hail rides its own steep shadow — that's the whole of decision 13: the sail
  // blocks steep hail (shadow ≈ its footprint) where it can't block slanted rain.
  const hail = createHail({ groundY: o.groundY ?? 0 });
  if (scene) scene.add(hail.mesh);
  const hailShadow = new RainShadow({ groundY: o.groundY ?? 0 });
  const hailDir = new THREE.Vector3();
  const hailWind = new THREE.Vector3();
  let hailTick = 0, hailAmt = 0;

  // hailAt lives behind the wind router (Lane A's allowlist). If a stale router
  // doesn't forward it, degrade to no hail rather than crashing — but it must be
  // forwarded or the garden score (decision 13) is inert. Flagged to A in THREADS.
  const hailIntensity = (t) => (wind && typeof wind.hailAt === 'function' ? wind.hailAt(t) : 0);
  // what the sail is made of, refreshed from step()'s {sail} — 0 (membrane) until told otherwise
  let sailPorosity = 0;
  const hailStone = () => (wind && wind.hailSize != null ? wind.hailSize : 1);

  const audio = createAudio((wind && wind.seed) || 1);

  // cloud dome rides the camera so it can't clip the far plane whatever Lane A set
  const domeTex = cloudTexture(256, ((wind && wind.seed) || 7) & 0xffff);
  const dome = new THREE.Mesh(
    new THREE.SphereGeometry(180, 24, 16),
    new THREE.MeshBasicMaterial({
      map: domeTex, side: THREE.BackSide, transparent: true,
      depthWrite: false, fog: false, opacity: 0,
    }),
  );
  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.6;      // radians of horizon the wall spans (~149°)
  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 ~12° BELOW 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 A.
  //
  // SPRINT13 gate 2.2 — why the band overshoots the equator: the old band
  // stopped AT the horizontal plane through the camera, but from in-yard eye
  // height the ground's true horizon sits ~0.6° BELOW that plane (the dip),
  // so a sliver of bright sky showed under the wall's base and the bank read
  // as a floating slab — the QA sighting, reproduced on dev_skyfx before this
  // fix. Overshooting to −12° buries the base behind the ground from any eye
  // height that matters (it survives scale.y's 0.3 floor: −37 m compressed to
  // −11 m is still −3.7° elevation, below the dip). The ground plane occludes
  // the overshoot by depth, so nothing is drawn twice.
  const frontGeo = new THREE.SphereGeometry(170, 24, 10, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.27);
  {
    // 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
    // (now sub-horizon) bottom edge.
    // The 1.6 exponent is gate 2.2's other half: 0.75 left the outer tenth of
    // the arc at ~0.36 alpha, which from in-yard eye height is the "hard
    // vertical seam" the QA saw at the bank's ends. 1.6 holds the same solid
    // core (the arc is wider to compensate) but takes the ends to <0.12.
    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)), 1.6);
      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
  // storm wrecking. Lane A caught it — sun and hemi came back exactly and the fog
  // stayed where the storm left it. Harmless today only because the next skyfx
  // immediately re-drives it, which is exactly the kind of bug that waits.
  const ownsFog = !!scene && !scene.fog;
  const original = {
    background: scene ? scene.background : null,
    fog: scene ? scene.fog : null,
    fogColor: scene && scene.fog ? scene.fog.color.clone() : null,
    fogNear: scene && scene.fog ? scene.fog.near : 0,
    fogFar: scene && scene.fog ? scene.fog.far : 0,
    sun: sun ? sun.intensity : 0,
    hemi: hemi ? hemi.intensity : 0,
    sunColor: sun ? sun.color.clone() : null,
    sunRadius: sun && sun.shadow ? sun.shadow.radius : 1,
  };
  const baseSky = (scene && scene.background && scene.background.isColor)
    ? scene.background.clone() : CALM_SKY.clone();

  const skyCol = baseSky.clone();
  if (scene) {
    scene.background = skyCol;
    if (!scene.fog) scene.fog = new THREE.Fog(skyCol.getHex(), 30, 140);
  }

  let flash = 0;              // decaying lightning brightness
  let gradeNow = 0;           // the storm grade, gate 2.1 — pure in t, see step()
  let flashQueue = [];        // {at, power} — double-strike
  let lastTelegraph = null;
  const camPos = new THREE.Vector3();
  const w = new THREE.Vector3();

  const fx = {
    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; },
    /** 0..1 — the storm grade driving sky/sun/shadow-softness (gate 2.1).
     *  storminess with a floor under the author's darkness dial; pure in t. */
    get stormGrade() { return gradeNow; },
    /** 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.
     *
     * Lane A: this is NOT `rig.coverageOver(bed, world.sunDir)`. That one is the
     * SUN shadow — the summer-afternoon question. This is the RAIN shadow, which
     * arrives along the wind, sits downwind of the cloth, and walks across the
     * yard when the wind swings. During a storm at night the sun shadow is a
     * number about nothing; this is the one that says whether the garden is
     * getting hit. Which of the two drives garden HP is a design call, not mine —
     * flagged in THREADS. Cheap either way: reads the grid we already built.
     */
    rainShadowOver(rect) { return shadow.fractionOver(rect); },

    /**
     * Decision 7's garden-HP drain term, 0..1, in one call — the combined helper
     * I offered Lane A. This is the whole of "is the bed getting hit right now":
     *
     *     hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt;
     *
     * 0 = bone dry (no rain, or the cloth is over it); 1 = full downpour on open
     * ground. Both terms matter and neither is enough alone: a sail over the bed
     * in a downpour is 0, and a clear sky with no sail is also 0. It uses the
     * grid we already rebuild for the rain, so it costs a few array reads.
     *
     * Note it moves on its own during storm_02: the rain shadow follows the wind,
     * so the southerly change walks the dry patch off the bed and the drain
     * starts climbing without a single corner having failed. That's the mechanic,
     * not a bug — worth not "fixing" if it looks surprising in the HUD.
     */
    gardenExposure(rect, t) {
      const rain = wind.rainAt(t);
      if (rain <= 0) return 0;
      return rain * (1 - shadow.fractionOver(rect));
    },

    /** 0..1 of a rect the sail is keeping hail off, this frame (steep shadow). */
    hailShadowOver(rect) { return hailShadow.fractionOver(rect); },

    /**
     * Decision 13's garden-damage feed, 0..1, the gardenExposure mold but for
     * HAIL — this is what makes the garden score respond to the rig. Steep stones
     * mean the sail's shadow ≈ its footprint, so a rig over the bed genuinely
     * shelters it even in a gale, unlike rain (which walks under and can't score
     * the sail — the whole reason a perfect rig used to tie with no rig at all).
     *
     *     hp -= sky.gardenHailExposure(bed, t) * HAIL_DAMAGE * dt
     *           + sky.gardenExposure(bed, t) * SMALL_RAIN_DRAIN * dt;   // A wires both
     *
     * 0 = no hail, or the cloth is catching it; 1 = full burst on the open bed.
     */
    gardenHailExposure(rect, t) {
      const h = hailIntensity(t);
      if (h <= 0) return 0;
      // The cloth only blocks what it can catch. hailBlockFor is Lane C's ruling
      // (weather.core): porosity is about AIR and WATER, not ice — a 2 cm stone
      // cannot pass a 2 mm weave, so porous and membrane stop the big hail
      // identically, and the ONE real difference is the finest pea hail
      // rattling through an open knit. Wired here because this is the only
      // place that knows both the stone size and what the sail is made of.
      // Measured: storm_02 (1.3) and the ice night (1.4) -> shade cloth blocks
      // 100%; the southerly's pea hail (0.7) -> it blocks 74%, and THAT is the
      // night membrane earns its keep. Lane B, SPRINT9 fabric landing.
      const size = (wind && wind.def && wind.def.hail && wind.def.hail.size) || 1;
      const block = hailBlockFor(size, sailPorosity);
      return h * (1 - hailShadow.fractionOver(rect) * block);
    },

    /** Wire to the first click/keydown — browsers won't start audio otherwise. */
    unlockAudio() { audio.unlock(); },

    /**
     * A's M key (gate 3.3): mute the whole audio bus. main.js feature-detects
     * this (`typeof sky.setMute === 'function'`) and only advertises M once it
     * exists — so this method appearing is what lights the key up. Safe at any
     * time: before unlock it sets a flag the unlock honours, after unlock it
     * writes the master gain directly.
     */
    setMute(on) { audio.setMute(on); },
    get muted() { return audio.muted; },

    /**
     * @param {number} dt
     * @param {number} t   storm time
     * @param {object} [world] {sail} — duck-typed, for creak/flog
     *
     * Runs headless. The camera is a RENDERING concern and must never gate the
     * shadow grids — see the note at the physics/view split below.
     */
    step(dt, t, world = {}) {
      // A camera means "there is a view to place things in", not "the weather is
      // real". This used to read `if (!camera) return;` at the top, which meant a
      // harness with no camera silently skipped the shadow rebuild and then
      // scored every rig as if the sail did not exist — hp 36 was the bare-bed
      // constant, and it cost gate 0 two sprints of four lanes' time before A
      // found it. The grids are physics; only the view and the audio are the
      // camera's business. Fall back to the yard centre so the wind still has a
      // place to be sampled.
      const rendering = !!camera;
      if (rendering) camera.getWorldPosition(camPos);
      else camPos.set(0, 1.7, 0);
      // Refresh what the sail is made of — gardenHailExposure needs it, and a
      // re-rig between phases can change the fabric. Read live rather than
      // cached at construction, for the same reason the shadow grids are rebuilt
      // every step: this must never quietly describe a sail that isn't there.
      if (world.sail && Number.isFinite(world.sail.porosity)) sailPorosity = world.sail.porosity;
      wind.sample(camPos, t, w);
      const speed = Math.hypot(w.x, w.z);
      const intensity = wind.rainAt(t);
      const storminess = clamp01(Math.max(intensity, speed / 26));
      // SPRINT13 gate 2.1 — the STORM GRADE. The QA pass played the southerly at
      // 65 km/h under a noon-blue sky, and the arithmetic says why: everything
      // below multiplied the weather by the author's palette (`darkness`), so a
      // darkness-0.5 storm at full blow only ever moved 0.35 toward slate — the
      // author's dial could zero the weather's say. The grade gives the weather
      // a floor: at full storminess even a darkness-0 sky goes half way to
      // slate, while the wild night (0.94) keeps reading as the night it is.
      // Same curve the rain already uses (storminess IS max(rain, wind)), pure
      // in t, computed above the render gate so a test can read it headless.
      gradeNow = storminess * lerp(0.5, 1, darkness);

      // --- events: lightning + the ticker ---
      for (const ev of wind.eventsBetween(t - dt, t)) {
        if (ev.type === 'lightning') {
          const p = ev.power ?? 0.7;
          flashQueue.push({ at: t, power: p });
          flashQueue.push({ at: t + 0.09 + p * 0.07, power: p * 0.55 });  // the stutter
          // thunder lags the flash — distance you can hear
          const delay = (ev.distance ?? 1.2) * 0.9;
          flashQueue.push({ at: t + delay, power: 0, thunder: p });
        } else if (ev.type === 'windchange' && ev.text && o.onEvent) {
          o.onEvent(ev.text);
        }
      }

      // --- lightning on the biggest gusts ---
      // The storm's worst moments should light up, not only the three authored
      // strikes. Driven off the telegraph (which is also what the HUD banner and
      // the audio whoosh read), so the flash lands WITH the gust that earned it.
      // Deterministic: the gust timeline is, and each gust fires at most once.
      const tgl = wind.gustTelegraph(t);
      if (tgl && tgl.power >= lightningGustPow) {
        // key on the ramp instant — stable across frames, unique per gust
        const key = Math.round((t + tgl.eta) * 100);
        if (!firedGusts.has(key)) {
          firedGusts.add(key);
          const p = Math.min(1, 0.45 + (tgl.power - lightningGustPow) * 0.06);
          flashQueue.push({ at: t + tgl.eta, power: p });
          flashQueue.push({ at: t + tgl.eta + 0.08 + p * 0.06, power: p * 0.5 });
          flashQueue.push({ at: t + tgl.eta + 1.1, power: 0, thunder: p });
        }
      }
      for (let i = flashQueue.length - 1; i >= 0; i--) {
        if (flashQueue[i].at <= t) {
          const f = flashQueue[i];
          if (f.thunder) audio.thunder(f.thunder);
          else flash = Math.max(flash, f.power);
          flashQueue.splice(i, 1);
        }
      }
      flash *= Math.max(0, 1 - dt * 7);
      if (flash < 0.004) flash = 0;

      // --- PHYSICS: the shadow grids. Camera or not, always. ---
      // Everything that scores a rig — gardenExposure, gardenHailExposure,
      // rainShadowOver, hailShadowOver — reads these grids. They are what the
      // sail DOES, so they rebuild before any view work and above the render
      // gate. Rebuilt a few times a second, not every frame: the cloth moves
      // slowly next to the weather, and this is the only part that costs.
      shadowTick -= dt;
      if (shadowTick <= 0) {
        shadowTick = 0.1;
        rainVelocity(w, intensity, rainDir);
        const rl = rainDir.length() || 1;
        shadow.update(world.sail, rainDir.x / rl, rainDir.y / rl, rainDir.z / rl);
      }
      hailAmt = hailIntensity(t);
      const stone = hailStone();
      // The hail shadow follows the STEEP hail vector, not the wind. It barely
      // moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
      hailTick -= dt;
      if (hailTick <= 0) {
        hailTick = 0.12;
        hailWind.set(w.x, 0, w.z);
        hailVelocity(hailWind, hailDir);
        const hl = hailDir.length() || 1;
        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.
      if (!rendering) return;

      // --- sky ---
      skyCol.copy(baseSky).lerp(target, gradeNow);
      if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
      if (scene) {
        if (scene.fog) {
          scene.fog.color.copy(skyCol);
          scene.fog.near = lerp(40, 8, storminess);
          scene.fog.far = lerp(160, 55, storminess);
        }
      }
      if (sun) {
        sun.intensity = lerp(original.sun, original.sun * 0.12, gradeNow) + flash * 2.2;
        // The sun loses its noon edge two ways as the grade builds: the warm
        // disc goes slate (colour), and the shadows go soft (radius — the
        // renderer is PCFSoft, so radius is real blur, not a no-op). Softness
        // keys on STORMINESS, not the graded value: overcast is overcast even
        // under a light-palette storm, and razor-sharp noon shadows at 65 km/h
        // were the QA sighting this whole block answers.
        if (original.sunColor) sun.color.copy(original.sunColor).lerp(SUN_STORM, gradeNow * 0.85);
        if (sun.shadow) sun.shadow.radius = lerp(original.sunRadius, 7, storminess);
      }
      if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, gradeNow) + flash * 1.2;

      dome.position.copy(camPos);
      dome.material.opacity = storminess * 0.85;
      // Tint the CLOUDS, not just the sky behind them. The dome covers the
      // background at ~0.85 opacity, so darkening `scene.background` alone did
      // nothing — a "wild night" still read as an overcast afternoon because the
      // grey cloud texture was what you were actually looking at.
      //
      // Both a lerp AND a brightness crush: the lerp alone lands ~#717273 because
      // it runs in linear space (84% toward night still reads mid-grey in sRGB)
      // and the cloud texture is baked near-white. The crush is what makes night
      // look like night. It stops at 0.78 on purpose — the yard has no lights in
      // it yet, and a storm you can't see isn't a storm, it's a black screen.
      const nightAmt = gradeNow;
      dome.material.color.copy(WHITE)
        .lerp(target, nightAmt * 0.92)
        .multiplyScalar(1 - 0.78 * nightAmt);
      // and lightning lights the cloud it's inside, which is the whole look
      if (flash > 0) dome.material.color.lerp(FLASH_COL, Math.min(0.9, flash));
      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);
      // how much of the hail the sail overhead is catching, for the drum sound —
      // sample right above the player/camera so it's "is it drumming over ME"
      const onCloth = hailAmt > 0 ? hailShadow.fractionOver({ x: camPos.x, z: camPos.z, w: 3, d: 3 }) : 0;

      // --- 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
        if (!lastTelegraph || Math.abs(tg.eta - (lastTelegraph.eta - dt)) > 0.05) {
          audio.whoosh(tg.power, tg.eta);
        }
      }
      lastTelegraph = tg;

      const sail = world.sail;
      if (sail && sail.corners) {
        let worst = 0, broken = false;
        for (const c of sail.corners) {
          if (c.broken) { broken = true; continue; }
          // Effective rating is hardware × anchor hint (SPRINT12, B's wiring):
          // a carport corner fails at 0.22× its shackle, and a creak keyed on
          // the shackle alone would go quiet exactly where the steel is worst.
          const hint = c.anchor && c.anchor.ratingHint != null ? c.anchor.ratingHint : 1;
          const rating = (c.hw && c.hw.rating ? c.hw.rating : 1) * hint;
          worst = Math.max(worst, c.load / rating);
        }
        audio.creak(dt, worst);
        if (broken) audio.flog(dt, speed);
      }
    },

    /** Hand Lane A's scene back exactly as we found it. */
    dispose() {
      if (scene) {
        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
        } else if (original.fog) {
          scene.fog = original.fog;
          original.fog.color.copy(original.fogColor);
          original.fog.near = original.fogNear;
          original.fog.far = original.fogFar;
        }
      }
      if (sun) {
        sun.intensity = original.sun;
        if (original.sunColor) sun.color.copy(original.sunColor);
        if (sun.shadow) sun.shadow.radius = original.sunRadius;
      }
      if (hemi) hemi.intensity = original.hemi;
      rain.dispose();
      hail.dispose();
      dome.geometry.dispose();
      dome.material.dispose();
      frontMesh.geometry.dispose();
      frontMesh.material.dispose();
      domeTex.dispose();
      audio.dispose();
    },
  };

  return fx;
}
