// ui/feedback.js (Lane E) — damage, death and re-acquire, rendered as the SCANNER FEED
// FAILING. ART_BIBLE §Screens is explicit and this file exists to obey it: "Damage = feed
// glitches (aberration, tearing), not red screen-edges." NO DAMAGE STATE IN THIS FILE IS AN
// EDGE TREATMENT: damage draws full-frame line patterns, static and tears, never a vignette,
// red or dark. Getting hurt does not tint the world, it degrades the transmission you are
// watching the world through.
// (Precision, because an earlier version of this comment overclaimed: there ARE two radial
// gradients in the file. `.fdbk-smear` is the boost effect — cyan, never driven by damage,
// and it is the DOM stand-in ART_BIBLE §Post asks for. `.fdbk-black` is the death blackout,
// whose radial is a *hole* punched for comms' subtitle box, not a rim. Neither is reachable
// from `hit` or `chronic`, which is the property that matters.)
//
// The scene-side half of this (real chromatic aberration in the post chain) is F/A's. We are
// the DOM half: layered absolutely-positioned divs, a low-res Canvas2D static plate, and
// transform/opacity writes only. That split is why the fringing here is a full-screen line
// pattern rather than an edge treatment — an edge treatment IS the banned vignette wearing
// a different hat, and it also fights whatever F does in the shader.
//
// Bus-only (House Law 1). Nothing here imports flight/ or combat/; every number arrives as
// an event payload whose field names were read off the emitters, not remembered.
//
// IDLE COST IS ZERO. While you are healthy the root is display:none, the rAF is cancelled,
// and the only work done is the handful of divides and compares in the player:state handler
// (which then does NOT wake us). A damage overlay that costs frames while nothing is wrong
// is a bug, not a feature.

// ART_BIBLE emissive code, duplicated locally — hud.js owns its own copy for the same reason
// (no shared helper module in web/js/ui/; each file stands alone).
const CY = '#39e6ff';     // neutral/interactive cyan — the feed's own colour
const CY_HI = '#7fdfff';
const WARM = '#ff5a2a';   // hostile. Legal as the WARM HALF OF A CHROMA PAIR (see FRINGE
                          // below); illegal as a screen edge. The distinction is the law.

// Static plate resolution. 128x72 upscaled with image-rendering:pixelated is chunky on
// purpose — datamosh, not film grain — and it is the whole reason this is affordable: a
// full-res per-pixel canvas at 60fps is roughly 500x the pixel work for a worse look.
const NW = 128, NH = 72, TILES = 6;

// Coat ratio at which the creeping corruption starts. comms.js uses 0.25 for its "coat low"
// line; this starts earlier so the picture is already degrading when adeyemi says it out
// loud. The voice confirms what you can already see. (At coatR 0.25 the raw ramp is 0.5 and
// chronicTgt lands at 0.4 after COAT_W — coat is deliberately weighted under hull loss.)
const COAT_START = 0.5;

// Damage amounts in the tree (combat/balance.js, flight/tuning.js) run 3.6 (aura tick) to 30
// (gate slam) with wall hits capped at 28. 30 is therefore "the worst single hit in the game"
// and makes a clean normaliser. The envelope below is scaled so 3.6 lands at ~0.19 — under
// TUNE.heavyAt, i.e. a flicker — while 28-30 pins at 1.0.
const HIT_NORM = 30;
// e-folds/s. Honest numbers, since the previous comment was wrong by ~3x: half-life 0.116s,
// so a full-strength hit is perceptually spent in ~0.3s (down to 0.16) and has crossed the
// 0.02 visibility floor by ~0.65s. "Gone in a third of a second" is the feel; 0.65s is the
// arithmetic. Do not quote the feel number as if it were the arithmetic one.
const HIT_DECAY = 6.0;

const DEATH_BURST = 0.30; // s of signal collapse before the picture is gone
// s of lock-on. Deliberately near audio/engine.js's reacquire(): its carrier/static ramps
// finish at 0.400 and its setTargetAtTime(tau 0.25) bed has settled by ~0.75. At 0.95 the
// ears were locked while the eyes were still stuttering. Both modules still invent this
// duration independently off player:spawn — see the integrator note in the round report.
const REACQ = 0.75;

// --- TUNE ---------------------------------------------------------------------------------
// Everything that decides the LOOK, in one place. These used to be ~30 literals buried in
// step(), which meant "make damage read harder but leave death alone" was a reverse-
// engineering job. Frozen so a typo'd key throws in dev instead of silently reading
// undefined and painting NaN. Read once per frame at most; no runtime cost.
const TUNE = Object.freeze({
  // master mix
  chronicW: 0.55,   // how much sustained rot counts toward the master corruption `c`
  coatW: 0.80,      // coat ramp is worth less than hull loss, which carries full weight
  tearChronicW: 0.18, // ...but tearing is nearly hit-only. See the note at `tearDrive`.

  // scanlines
  scanBase: 0.04, scanGain: 0.40, creepBase: 5, creepGain: 34,
  // roll bar (chronic only)
  rollGain: 0.85, rollBase: 7, rollDrift: 9,
  // chroma fringe
  fringeAt: 0.02, fringeA: 0.22, splitC: 7, splitHit: 5,
  // tears
  heavyAt: 0.30, tear2At: 0.45, tear3At: 0.75,
  tearBase: 0.18, tearGain: 0.75, tearMax: 0.90, tearStep: 0.07,
  // static plate
  noiseAt: 0.02, noiseBase: 0.06, noiseGain: 0.62, noiseMax: 0.85,
  dropoutAt: 0.30, dropout2At: 0.50, dropout3At: 0.75,
  // boost smear
  smearA: 0.90,
});

const CSS = `
/* z-index 4, not 5, ON PURPOSE: cards.js parks .crd at z-index:5 and both are inset:0
   siblings under #ui, so at equal z the winner is whoever boot constructs last — a coin
   flip deciding whether the medal card and the pause panel are read through corruption.
   4 puts us permanently underneath cards and above the HUD's chrome. */
.fdbk { position:absolute; inset:0; overflow:hidden; pointer-events:none; z-index:4;
  contain:layout paint style; display:none; }

/* --- scanlines. One fixed 3px-period gradient, crept by translateY only. Regenerating the
   gradient per frame would repaint the whole layer; translating it is a composite. --- */
.fdbk-scan { position:absolute; left:0; right:0; top:-6px; bottom:-6px; opacity:0;
  background:repeating-linear-gradient(180deg,
    rgba(0,0,0,.55) 0px, rgba(0,0,0,.55) 1px, rgba(0,0,0,0) 1px, rgba(0,0,0,0) 3px);
  will-change:transform,opacity; }

/* --- analog roll bar: the soft horizontal band that drifts down a picture losing sync.
   Chronic damage only — it is the slow, dreadful one. --- */
.fdbk-roll { position:absolute; left:0; right:0; top:0; height:22%; opacity:0;
  background:linear-gradient(180deg,
    rgba(127,223,255,0) 0%, rgba(127,223,255,.05) 35%,
    rgba(127,223,255,.12) 55%, rgba(0,0,0,.22) 88%, rgba(0,0,0,0) 100%);
  will-change:transform,opacity; }

/* --- CHROMA FRINGE. The aberration pair: the same hairline pattern in a warm and a cool
   copy with opposite X offsets. Uniform across the frame — no radial falloff, no edge
   weighting, because edge-weighted red is the thing ART_BIBLE bans by name. Offset is a
   transform, so widening the split costs nothing.

   WHAT mix-blend-mode ACTUALLY DOES HERE, because the naive reading is wrong and it cost us
   an orange grille: it does NOT blend with the WebGL canvas and it never can. #game and #ui
   are siblings, #ui is position:fixed (a stacking context on its own), and .fdbk adds two
   more (z-index + contain). Blending is isolated to .fdbk's subtree by construction — this
   is not something a contain-property tweak reaches, so do not go hunting for it.
   The blend is kept anyway because it earns its keep INSIDE the subtree: where the warm and
   cool copies overlap they screen to near-white, which is exactly the read of one picture
   separating rather than two grilles sliding. Real scene-space aberration is F/A's post
   chain; this is the DOM's cheap tell that it is happening.
   Consequence, and the reason for the alphas below: screen over transparent black is just
   the source colour, so at full opacity a solid ${WARM} stop would paint a 1-in-4 opaque
   orange grille over the whole frame — the exact hostile-warm-everywhere this file exists to
   refuse. The stops are therefore half-alpha and TUNE.fringeA caps the layer low; worst case
   is ~11% warm on a quarter of the rows. A fringe you notice, not a filter you wear. --- */
.fdbk-fr { position:absolute; left:-24px; right:-24px; top:0; bottom:0; opacity:0;
  mix-blend-mode:screen; will-change:transform,opacity; }
.fdbk-fr-w { background:repeating-linear-gradient(180deg,
    rgba(255,90,42,.5) 0px, rgba(255,90,42,.5) 1px,
    rgba(0,0,0,0) 1px, rgba(0,0,0,0) 4px); }
.fdbk-fr-c { background:repeating-linear-gradient(180deg,
    rgba(57,230,255,.5) 0px, rgba(57,230,255,.5) 1px,
    rgba(0,0,0,0) 1px, rgba(0,0,0,0) 4px); }

/* --- tear bands: displaced slabs of picture. A fixed 96px slab scaled with scaleY and slid
   with translate3d; height is never written (that is a layout pass). --- */
/* No will-change here, deliberately, and none on .fdbk-card either. Tears only move on the
   70ms step and only during heavy damage; the card changes twice a life. Promoting them
   would add 4 more full-viewport GPU surfaces (~30MB at 1440p) to the exact frames the
   renderer is already worst off on, to save a raster the compositor was going to do anyway.
   The layers that DO get will-change are the ones written every single frame. */
.fdbk-tear { position:absolute; left:-40px; right:-40px; top:0; height:96px; opacity:0;
  transform-origin:0 0;
  background:linear-gradient(180deg,
    rgba(127,223,255,.50) 0px, rgba(127,223,255,.50) 1px,
    rgba(57,230,255,.10) 2px, rgba(0,0,0,.30) 60%, rgba(0,0,0,0) 100%); }

/* --- the static plate --- */
.fdbk-noise { position:absolute; inset:0; width:100%; height:100%; opacity:0;
  image-rendering:pixelated; will-change:opacity; }

/* --- CRT collapse line: the picture folding into one bright scanline and going out.
   Scales on X from a fixed full-width bar, so it is one transform write. --- */
.fdbk-line { position:absolute; left:0; right:0; top:50%; height:2px; margin-top:-1px;
  opacity:0; background:${CY_HI}; box-shadow:0 0 18px ${CY}, 0 0 4px #fff;
  transform-origin:50% 50%; will-change:transform,opacity; }

/* --- boost smear. Cyan, and ART_BIBLE §Post asks for "subtle radial blur on boost" — this
   is the DOM stand-in for it. Cool, so it cannot be misread as a damage vignette. --- */
.fdbk-smear { position:absolute; inset:-6%; opacity:0; will-change:transform,opacity;
  background:radial-gradient(ellipse 58% 50% at 50% 50%,
    rgba(57,230,255,0) 52%, rgba(57,230,255,.10) 76%, rgba(127,223,255,.30) 100%); }

/* --- feed drop. The mask is a deliberate coupling with comms.js, which parks its box at
   left:18px/bottom:70px/width:330px: the one line that MUST stay legible while the screen is
   black is voss saying "we lost the feed. re-acquiring." So the blackout holds back to ~35%
   over that rectangle instead of burying it. Static declaration, zero per-frame cost.
   THIS IS AN UNDECLARED DEPENDENCY IN COMMS' DIRECTION and it fails silently: move the comms
   box and the mask stops protecting the line, with no error and nothing to grep. House Law 1
   is why it is a hardcoded rectangle rather than a measurement, but whoever owns comms.js
   should carry a matching comment. Flagged for the integrator. --- */
.fdbk-black { position:absolute; inset:0; opacity:0; background:#000;
  -webkit-mask-image:radial-gradient(240px 96px at 190px calc(100% - 100px),
    rgba(0,0,0,.34), #000 74%);
  mask-image:radial-gradient(240px 96px at 190px calc(100% - 100px),
    rgba(0,0,0,.34), #000 74%);
  will-change:opacity; }

/* --- the readout. House type: mono, small, uppercase, wide tracking.
   It carries a plate. "SIGNAL RE-ACQUIRED" is read at 19px/.42em against the live gut
   interior WITH stepped static running over it — a text-shadow alone loses that fight, and
   cards.js gives its equivalents a panel for the same reason. Thin cyan hairline + near-
   black fill is the house frame, not a new idiom. --- */
.fdbk-card { position:absolute; left:50%; top:44%; opacity:0;
  transform:translate(-50%,-50%); text-align:center; color:${CY};
  font:11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
  letter-spacing:.28em; text-transform:uppercase;
  padding:16px 26px 15px; background:rgba(2,16,13,.72);
  border:1px solid rgba(57,230,255,.28);
  box-shadow:0 0 0 1px rgba(0,0,0,.5), 0 0 26px rgba(0,0,0,.6);
  text-shadow:0 0 8px rgba(0,0,0,.95); }
.fdbk-card .fdbk-big { display:block; font-size:19px; letter-spacing:.42em; }
.fdbk-card .fdbk-sub { display:block; margin-top:9px; font-size:9px; opacity:.55;
  letter-spacing:.24em; }
.fdbk-card .fdbk-cause { display:block; margin-top:4px; font-size:9px; opacity:.38;
  letter-spacing:.24em; }
`;

const el = (tag, cls, parent) => {
  const n = document.createElement(tag);
  if (cls) n.className = cls;
  if (parent) parent.appendChild(n);
  return n;
};

const clamp01 = (v) => (v > 0 ? (v < 1 ? v : 1) : 0);

// Seeded, because this repo hashes and seeds everything (hud.js derives its data-noise from
// p.s rather than a wall-clock RNG for the same reason) — and because a bug you cannot reproduce
// in a full-screen glitch layer is a bug you never fix.
function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Bake the static ONCE into a filmstrip, then the hot path is a single drawImage per frame.
// Generating 128x72 pixels of noise every frame is affordable, but it is ~37k typed-array
// writes we simply do not have to make, and pre-baked grain is what film and video have
// always done. Six tiles is NOT enough to be invisible on its own — each tile's hot rows are
// baked in, so stepping 0..5 in order gives you a 10Hz metronome. step() random-walks the
// tile index instead, which is what actually buys the invisibility; see the note there.
function bakeStatic(seed) {
  const c = document.createElement('canvas');
  c.width = NW * TILES; c.height = NH;
  const g = c.getContext('2d');
  if (!g) return null;                       // ancient/blocked canvas — caller degrades
  const rnd = mulberry32(seed);
  for (let t = 0; t < TILES; t++) {
    const img = g.createImageData(NW, NH);
    const d = img.data;
    for (let y = 0; y < NH; y++) {
      // Feed static is ROW-CORRELATED — a handful of lines per frame carry a hot streak.
      // Uncorrelated per-pixel salt reads as film grain, which is the wrong medium entirely.
      const hot = rnd() < 0.09 ? 1 : 0.28;
      for (let x = 0; x < NW; x++) {
        const n = rnd();
        const i = (y * NW + x) * 4;
        d[i] = 150; d[i + 1] = 245; d[i + 2] = 255;   // cyan-white speck, on-palette
        d[i + 3] = (n * n * n * hot * 255) | 0;       // cubed => sparse, not a grey wash
      }
    }
    g.putImageData(img, t * NW, 0);
  }
  return c;
}

// Style writes are cached per node. Every one of these runs up to 60x/s; assigning an
// identical string still dirties the node in some engines, and the compare is free.
function styled(node) {
  let lo = -1, lt = '';
  return {
    node,
    o(v) { const n = Math.round(v * 1000) / 1000; if (n !== lo) { node.style.opacity = n; lo = n; } },
    t(s) { if (s !== lt) { node.style.transform = s; lt = s; } },
  };
}

// assets/level/world are accepted but unused in round 1: the factory shape is uniform across
// Lane E so boot can construct us the same way it constructs the HUD, and `world` is the door
// through which world.flowPulse would arrive if we ever want tear/squelch on the peristaltic
// beat (LANE_E_UIHUD calls that round 2+, and House Law 1 forbids reaching for it ourselves).
export function createFeedback({
  bus, flags = {}, assets = null, level = null, world = null, mount = null,
} = {}) {
  const host = mount ?? document.getElementById('ui');
  // ?shots=1 takes comms.js's route (build nothing) rather than hud.js's (build then hide):
  // this module's entire output is noise over a clean plate, and a static plate we will never
  // draw is pure waste. Both routes exist in-tree; this is the one that costs zero.
  if (!host || !bus || flags.shots) return { update() {}, dispose() {} };

  const style = el('style');
  style.textContent = CSS;
  document.head.appendChild(style);

  const root = el('div', 'fdbk', host);

  const scan = styled(el('div', 'fdbk-scan', root));
  const roll = styled(el('div', 'fdbk-roll', root));
  const frW = styled(el('div', 'fdbk-fr fdbk-fr-w', root));
  const frC = styled(el('div', 'fdbk-fr fdbk-fr-c', root));
  const tears = [styled(el('div', 'fdbk-tear', root)),
                 styled(el('div', 'fdbk-tear', root)),
                 styled(el('div', 'fdbk-tear', root))];
  const noiseEl = el('canvas', 'fdbk-noise', root);
  noiseEl.width = NW; noiseEl.height = NH;
  const noise = styled(noiseEl);
  const nctx = noiseEl.getContext('2d');
  const line = styled(el('div', 'fdbk-line', root));
  const smear = styled(el('div', 'fdbk-smear', root));
  const black = styled(el('div', 'fdbk-black', root));

  const cardEl = el('div', 'fdbk-card', root);
  cardEl.innerHTML = '<span class="fdbk-big"></span><span class="fdbk-sub"></span>' +
                     '<span class="fdbk-cause"></span>';
  const card = styled(cardEl);
  const cardBig = cardEl.querySelector('.fdbk-big');
  const cardSub = cardEl.querySelector('.fdbk-sub');
  const cardCause = cardEl.querySelector('.fdbk-cause');

  // Baked at construction, not on first damage: the first hit is exactly the frame you cannot
  // afford a 2ms hitch on. 55k pixels is a one-off cost boot never notices.
  const plate = bakeStatic(0x5f3a17);

  // --- state ---------------------------------------------------------------------------
  let hit = 0;             // impulse envelope, snap-on / exp decay
  let chronic = 0;         // sustained corruption from coat+hull, smoothed
  let chronicTgt = 0;
  let boost = 0;           // boost smear envelope
  let phase = 0;           // 0 live | 1 feed-drop (dead) | 2 re-acquire
  let phaseT0 = 0;
  let creep = 0, rollY = 0, tearT = 0, tile = 0;
  let shown = false, raf = 0, lastT = 0, extAt = -1e9;
  let disposed = false;
  // The run is over (level:complete). Latched, because zeroing the envelopes in the handler
  // is not enough on its own: boot calls player.update(dt) unconditionally after the gate
  // (run.done gates run.t, not the player), so player:state keeps arriving at 60Hz and the
  // very next frame would recompute chronicTgt from a half-empty coat and wake us straight
  // back up ON TOP OF the medal card. One boolean is the difference between the handler
  // working and the handler being decorative.
  let done = false;
  // Have we ever actually died? player.js emits player:spawn once at construction, before
  // any death. Running the lock-on stutter there used to be excused as "a decent cold-open";
  // with cards.js in the tree it lands "SIGNAL RE-ACQUIRED" at top:44% straight across the
  // title screen. A re-acquire with nothing to re-acquire is a lie, so the first spawn only
  // resets state.
  let everDied = false;
  let paused = false, pausedAt = 0;
  const rnd = mulberry32(0x27d4eb);
  const tearS = [{ y: 0, x: 0, h: 0.2 }, { y: 0, x: 0, h: 0.2 }, { y: 0, x: 0, h: 0.2 }];

  // prefers-reduced-motion. This is a FULL-SCREEN flashing layer — creeping scanlines,
  // 10Hz static, slamming tear bands and a stepped re-acquire ladder — which is exactly the
  // class of effect the media query exists for. comms.js and cards.js both honour it; this
  // file has more reason to than either. `calm` does not hide the corruption (you would lose
  // the damage read entirely, which is a gameplay signal); it removes the FLICKER and the
  // DRIFT and leaves the amplitude, so a heavy hit is still obviously a heavy hit.
  // Live-updating rather than sampled once: users toggle this in the OS mid-session, and the
  // listener is unsubscribed in dispose() like every other subscription.
  const mq = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
    ? window.matchMedia('(prefers-reduced-motion:reduce)') : null;
  let calm = !!(mq && mq.matches);

  // Wall-clock READ, never accumulated — the same law comms.js documents. It matters twice
  // here: a backgrounded tab throttles timers into fiction, and player:state (the only clock
  // the HUD has) stops emitting the instant you die (player.js early-returns while !alive),
  // which is precisely the window the feed-drop has to survive.
  const nowS = () => performance.now() / 1000;

  const idle = () => phase === 0 && hit < 0.004 && chronic < 0.004 && chronicTgt < 0.004 &&
                     boost < 0.004;

  function sleep() {
    if (raf) { cancelAnimationFrame(raf); raf = 0; }
    if (!shown) return;
    shown = false;
    root.style.display = 'none';               // display:none => no paint, no promoted layers
    if (nctx) nctx.clearRect(0, 0, NW, NH);
  }

  // True while an integrator is actively calling update(dt). Half a second of silence and we
  // take the clock back.
  const externallyDriven = () => nowS() - extAt < 0.5;

  function wake() {
    if (disposed || paused) return;
    if (!shown) { shown = true; root.style.display = 'block'; }
    if (raf) return;
    lastT = nowS();
    raf = requestAnimationFrame(loop);
  }

  // The rAF runs whenever we are visible — INCLUDING while an integrator drives update(dt).
  // It just does not step() then. That looks redundant and is not: the earlier design had
  // update() cancel the rAF outright, so an integrator that started driving and then stopped
  // (which is precisely what pausing, or a scene teardown, does) left this layer frozen
  // mid-glitch with static and tear bands nailed to the screen and nothing able to restart
  // it — visibly worse than either running or hidden. A self-healing watchdog costs one
  // callback and one subtraction per frame; the alternative costs a stuck screen.
  // Only ONE of the two ever calls step(), which is the part that actually mattered: two
  // clocks would decay every envelope at double rate and the glitch would read cheap.
  function loop() {
    raf = 0;
    if (disposed || paused) return;
    const t = nowS();
    const dt = Math.min(0.05, Math.max(0, t - lastT));   // clamp: tab-return must not teleport
    lastT = t;
    if (!externallyDriven()) step(dt);
    if (shown && !raf) raf = requestAnimationFrame(loop);
  }

  // --- the hot path --------------------------------------------------------------------
  function step(dt) {
    // envelopes
    hit -= hit * Math.min(1, HIT_DECAY * dt);
    boost -= boost * Math.min(1, 5.5 * dt);
    chronic += (chronicTgt - chronic) * Math.min(1, 2.6 * dt);

    const pT = nowS() - phaseT0;
    let drop = 0, burst = 0, collapse = -1, cardA = 0;

    if (phase === 1) {
      // FEED DROP. Collapse, static burst, then near-black with the readout. It holds until
      // player:spawn — boot sets a 2.0s respawn timer but that number is B's, not ours, and
      // hardcoding it here is how the two drift apart.
      burst = pT < DEATH_BURST ? 1 - (pT / DEATH_BURST) * 0.72
                               : 0.11 + 0.05 * Math.sin(pT * 6.3);   // residual carrier crawl
      drop = clamp01(pT / 0.22) * 0.9;
      // the picture folding into one line, then out
      if (pT < 0.46) collapse = pT < 0.18 ? 1 : 1 - clamp01((pT - 0.18) / 0.28);
      cardA = clamp01((pT - 0.42) / 0.3);
    } else if (phase === 2) {
      // RE-ACQUIRE. Stepped rather than smooth: a receiver locking on stutters, and a linear
      // fade out of static reads as a dissolve, which is a transition, not a lock.
      const u = pT / REACQ;
      // ...but the ladder IS a strobe, so reduced-motion gets the dissolve instead. Losing
      // the lock-on read is the correct trade against six hard luminance steps in 0.5s.
      burst = calm ? Math.max(0, 1 - u) * 0.55
            : u < 0.10 ? 1 : u < 0.20 ? 0.30 : u < 0.32 ? 0.82
            : u < 0.46 ? 0.16 : u < 0.56 ? 0.44 : Math.max(0, (1 - u) * 0.42);
      drop = 0.9 * (1 - clamp01(u / 0.26));
      if (u < 0.2) collapse = clamp01(u / 0.11);
      cardA = u < 0.62 ? 1 : 1 - clamp01((u - 0.62) / 0.34);
      if (pT >= REACQ) { phase = 0; cardA = 0; collapse = -1; burst = 0; drop = 0; }
    }

    // Master corruption. chronic is weighted below a fresh hit on purpose: dying slowly
    // should be a texture you live inside, not a wall you cannot see past.
    const c = clamp01(hit + chronic * TUNE.chronicW + burst);

    // TEARING GETS ITS OWN DRIVE, and this is the important line in the function. Tears are
    // the most aggressive, most sight-blocking effect here, and driving them off `c` meant
    // low hull alone (chronicTgt 0.8 => c ~0.44) produced three slabs re-rolling every 70ms
    // FOREVER while you flew. Low hull is exactly when you need to see, hud.js's hull bar and
    // comms' "you are bleeding structure" already say it twice, and a permanent tear storm is
    // a legibility death-spiral, not tension. The file already reasons this way about the
    // roll bar ("a hit does not knock a picture out of vertical sync; rot does") — tearing is
    // the same argument in the opposite direction: tearing is an EVENT, so it follows `hit`
    // and the death/re-acquire burst, and chronic only nudges it.
    const tearDrive = clamp01(hit + chronic * TUNE.tearChronicW + burst);
    // No tear bands at all under reduced-motion: a 96px slab teleporting every 70ms is the
    // single most vestibular thing in the file, and its information is carried by static,
    // scanline density and chroma split anyway.
    const heavy = !calm && tearDrive > TUNE.heavyAt;

    // scanlines — always on once anything is wrong, and they CREEP. Static scanlines are
    // decoration; drifting ones are a signal that is not locked. Under reduced-motion they
    // stop creeping and become the decoration; the density read survives, the strobe (up to
    // ~13 cycles/s of 1px lines over a 3px period at c=1) does not.
    if (!calm) {
      creep = (creep + dt * (TUNE.creepBase + c * TUNE.creepGain)) % 3;
      scan.t(`translate3d(0,${creep.toFixed(2)}px,0)`);
    }
    scan.o(TUNE.scanBase + c * TUNE.scanGain);

    // roll bar: chronic only. A hit does not knock a picture out of vertical sync; rot does.
    const rollA = chronic * TUNE.rollGain;
    if (rollA > 0.01) {
      if (!calm) {
        rollY = (rollY + dt * (TUNE.rollBase + chronic * TUNE.rollDrift)) % 130;
        roll.t(`translate3d(0,${(rollY - 22).toFixed(1)}%,0)`);
      }
      roll.o(rollA);
    } else roll.o(0);

    // chroma split. Widens with damage; the two copies always move in opposition, which is
    // what makes it read as one picture separating rather than two layers sliding.
    const split = c * TUNE.splitC + hit * TUNE.splitHit;
    if (c > TUNE.fringeAt) {
      frW.o(c * TUNE.fringeA); frW.t(`translate3d(${split.toFixed(2)}px,0,0)`);
      frC.o(c * TUNE.fringeA); frC.t(`translate3d(${(-split).toFixed(2)}px,0,0)`);
    } else { frW.o(0); frC.o(0); }

    // tearing. Positions re-roll on a ~70ms step, not per frame: smoothly-moving tears look
    // like a screensaver, stepped ones look like dropped lines.
    if (heavy) {
      tearT -= dt;
      if (tearT <= 0) {
        tearT = TUNE.tearStep;
        for (const s of tearS) {
          // 94, not 100: the slab is 96px tall pre-scale and ends up 3-67px, anchored at
          // top:0 with transform-origin 0 0, so anything past ~94vh is rolled entirely below
          // the viewport. ~7% of rolls were being spent drawing offscreen and the tear
          // density was quietly lower than every number here implies.
          s.y = rnd() * 94;
          s.x = (rnd() - 0.5) * 2 * (10 + tearDrive * 90);
          s.h = 0.03 + rnd() * rnd() * 0.5 * (0.4 + tearDrive);
        }
      }
      const tearA = Math.min(TUNE.tearMax, TUNE.tearBase + tearDrive * TUNE.tearGain);
      for (let i = 0; i < 3; i++) {
        const s = tearS[i];
        // Tear 3 only shows up when it is genuinely bad — the count itself is a readout.
        const on = i < 1 + (tearDrive > TUNE.tear2At ? 1 : 0) + (tearDrive > TUNE.tear3At ? 1 : 0);
        tears[i].o(on ? tearA : 0);
        if (on) tears[i].t(`translate3d(${s.x.toFixed(1)}px,${s.y.toFixed(1)}vh,0) scaleY(${s.h.toFixed(3)})`);
      }
    } else {
      // Indexed, not for-of: this branch runs on healthy-ish frames too and the iterator
      // protocol allocates a result object per element per frame for nothing.
      for (let i = 0; i < 3; i++) tears[i].o(0);
    }

    // static plate: one drawImage from the baked filmstrip + a few dropout bands. Dropout is
    // drawn INTO the same canvas (black bars, normal blend) so the whole layer stays one
    // composited element instead of a second full-screen div.
    if (nctx && plate && c > TUNE.noiseAt) {
      nctx.clearRect(0, 0, NW, NH);
      // Tile order is RANDOM-WALKED, not sequential. Sequential meant the 6-tile strip
      // repeated at a metronomic 10Hz, and since each tile's 9%-of-rows hot streaks are baked
      // in, that turned dropout into a rhythmic pulse you can tap along to — the one thing
      // static must never be. Stepping 1..TILES-1 also guarantees no tile repeats back to
      // back, which a plain random pick would do 1-in-6 frames and which reads as a freeze.
      // Frozen entirely under reduced-motion: a still noise plate still says "bad signal".
      if (!calm) tile = (tile + 1 + ((rnd() * (TILES - 1)) | 0)) % TILES;
      nctx.globalAlpha = 1;
      nctx.drawImage(plate, tile * NW, 0, NW, NH, 0, 0, NW, NH);
      if (c > TUNE.dropoutAt && !calm) {
        // dropout: whole rows of the transmission simply not arriving
        const bars = c > TUNE.dropout3At ? 3 : c > TUNE.dropout2At ? 2 : 1;
        nctx.fillStyle = '#000';
        nctx.globalAlpha = Math.min(0.95, (c - TUNE.dropoutAt) * 1.6);
        for (let i = 0; i < bars; i++) {
          nctx.fillRect(0, ((rnd() * NH) | 0), NW, 1 + ((rnd() * c * 6) | 0));
        }
        nctx.globalAlpha = 1;
      }
      noise.o(Math.min(TUNE.noiseMax, TUNE.noiseBase + c * TUNE.noiseGain));
    } else noise.o(0);

    // collapse line
    if (collapse >= 0) {
      line.o(collapse);
      // width collapses toward a point as the signal dies; expands as it locks back on
      const sx = phase === 1 ? Math.max(0.02, collapse) : collapse;
      line.t(`scale(${sx.toFixed(3)},${(0.6 + collapse * 1.8).toFixed(2)})`);
    } else line.o(0);

    smear.o(boost * TUNE.smearA);
    if (boost > 0.01) smear.t(`scale(${(1 + boost * 0.05).toFixed(3)},1)`);

    black.o(drop);
    card.o(cardA);

    if (idle()) sleep();
  }

  // --- bus ------------------------------------------------------------------------------
  const offs = [];

  // player:damage {amount, kind} — amount is applied coat-first by the emitter; kind is the
  // damage SOURCE ('acid'|'squeeze'|'gate'|'aura'|'contact'|'dart'|wall kind), NOT which layer
  // took it. (comms.js tests kind === 'hull_hit', which no emitter ever sets — do not copy
  // that.) Severity therefore comes off `amount` alone, and the hull-vs-coat truth comes off
  // the player:state hull latch below. The emitter early-returns during i-frames, so there is
  // no "suppress while invulnerable" case to handle here.
  // THE ENVELOPE IS SCALED OFF THE ACTUAL DAMAGE TABLE, not off a feel-good floor. The old
  // shape (hit*0.45 + 0.28 + amt/30*0.78) put a flat 0.28 under everything, so the lightest
  // damage in the game and a gate slam both landed in the top half of the scale: an amylase
  // aura tick (3.6) came out at 0.374, over the heavy threshold, and since the aura ticks
  // every 0.2s in radius the carry-over settled around 0.46 — near-death corruption for
  // brushing a soft obstacle, in flat contradiction of this file's own "an aura tick reads as
  // a flicker".
  // Now: floor 0.06, carry-over 0.35, and the amount term does nearly all the work.
  //   aura 3.6  -> 0.174 single, ~0.19 sustained at 5Hz   (under TUNE.heavyAt: a flicker)
  //   dart/contact mid-teens -> ~0.5                       (tears, briefly)
  //   wall 28   -> 0.95, gate slam 30 -> 1.0               (whites you out)
  // The carry-over is kept (a second hit during the ring-out must stack) but cut, because at
  // 0.45 it was the term deciding the steady state rather than the damage was.
  offs.push(bus.on('player:damage', (e) => {
    const amt = typeof e?.amount === 'number' ? e.amount : 8;
    hit = Math.min(1, hit * 0.35 + 0.06 + clamp01(amt / HIT_NORM) * 0.95);
    wake();
  }));

  // player:state — the sustained read. Emitted every frame while alive, and STOPS at death;
  // never treat it as a clock.
  let hullWas = 1;
  offs.push(bus.on('player:state', (p) => {
    // Optional-chained even though player.js:275 always passes a full object: this is the
    // one handler that runs 60x/s, so it is the one where a payload-less emit from some
    // future caller would throw 60 times a second inside somebody else's frame loop.
    const coatR = p?.coatMax ? p.coat / p.coatMax : 1;
    const hullR = p?.hullMax ? p.hull / p.hullMax : 1;
    // The gate has been passed — the medal card owns the screen now. Returning here (rather
    // than only zeroing in the level:complete handler) is the whole fix: boot keeps stepping
    // the player after run.done, so this handler is still being called, and without the latch
    // it re-derives chronicTgt from a half-empty coat and wakes us back over the card one
    // frame later.
    if (done) return;
    // Coat is the consumable — it creeps in. Hull is structure: any hull loss at all is the
    // feed already failing, so it carries full weight and no ramp-in.
    const coatTerm = coatR >= COAT_START ? 0 : (COAT_START - coatR) / COAT_START;
    chronicTgt = clamp01(Math.max(coatTerm * TUNE.coatW, 1 - hullR));
    // A hull delta is the one hit worth punching above its `amount` — this is the only place
    // the coat/hull split is actually knowable from the bus.
    if (hullR < hullWas - 0.001) hit = Math.min(1, hit + 0.3);
    hullWas = hullR;
    if (chronicTgt > 0.004 || hit > 0.004) wake();
  }));

  // player:death — {s} from hull depletion, {s, kind} from kill(). kind is optional; read it
  // defensively or the readout says "undefined" on the most-looked-at screen in the game.
  offs.push(bus.on('player:death', (e) => {
    everDied = true;
    phase = 1; phaseT0 = nowS(); hit = 1; boost = 0;
    cardBig.textContent = 'signal lost';
    cardSub.textContent = 'endo-1 telemetry — no carrier';
    cardCause.textContent = e?.kind ? `cause: ${String(e.kind).replace(/_/g, ' ')}` : '';
    wake();
  }));

  // player:spawn — the ONLY signal that the respawn completed (boot owns the 2.0s timer).
  // It also fires once at construction, before any death; see `everDied`.
  offs.push(bus.on('player:spawn', () => {
    chronic = 0; chronicTgt = 0; hullWas = 1; hit = 0; boost = 0;
    done = false;                    // a fresh run un-latches the level:complete freeze
    if (!everDied) { phase = 0; sleep(); return; }   // cold open: reset only, no lock-on
    phase = 2; phaseT0 = nowS();
    cardBig.textContent = 'signal re-acquired';
    cardSub.textContent = 'endo-1 telemetry — carrier locked';
    cardCause.textContent = '';
    wake();
  }));

  // player:boost {s} — a cool smear, never a damage colour.
  offs.push(bus.on('player:boost', () => { boost = 1; wake(); }));

  // level:complete — the run is over; drop everything so the medal card is not read through
  // a dying picture. `done` is what makes it stick (see the player:state handler).
  offs.push(bus.on('level:complete', () => {
    done = true;
    phase = 0; hit = 0; chronic = 0; chronicTgt = 0; boost = 0;
    step(0); sleep();
  }));

  // ui:pause {paused} — cards.js owns the pause panel and emits this. Nobody owned the
  // interaction, and the default was bad: the roll bar kept drifting, static kept tiling and
  // tear bands kept slamming underneath a backdrop-filtered panel, which both fights the blur
  // and makes a *stopped* game look like it is still taking damage.
  // We freeze rather than hide. Hiding would pop the death screen out from under a pause
  // taken while dead; a held frame under the panel reads correctly as "the feed is paused
  // too". phaseT0 is shifted by the paused duration on resume, because it is anchored to
  // wall-clock — without the shift, unpausing after 30s of menu would find pT past REACQ and
  // skip the entire lock-on (or, worse, snap the death card straight to gone).
  offs.push(bus.on('ui:pause', (e) => {
    const p = !!(e && e.paused);
    if (p === paused) return;
    paused = p;
    if (paused) {
      pausedAt = nowS();
      if (raf) { cancelAnimationFrame(raf); raf = 0; }
    } else {
      phaseT0 += nowS() - pausedAt;
      lastT = nowS();
      if (shown) wake();
    }
  }));

  const onMotionPref = (ev) => { calm = !!(ev && ev.matches); };
  if (mq) {
    if (mq.addEventListener) {
      mq.addEventListener('change', onMotionPref);
      offs.push(() => mq.removeEventListener('change', onMotionPref));
    } else if (mq.addListener) {            // Safari < 14 still ships the deprecated pair
      mq.addListener(onMotionPref);
      offs.push(() => mq.removeListener(onMotionPref));
    }
  }

  return {
    // Nothing in boot's step() drives Lane E today (the HUD is bus-clocked), so we self-clock
    // with rAF. If an integrator does start calling this, extAt stands our step() down and we
    // run on their dt instead — never both. The rAF keeps ticking as a watchdog so that an
    // integrator who stops calling us does not leave the layer frozen mid-glitch; see loop().
    update(dt) {
      // The disposed guard is not paranoia. dispose() only ever set a flag that loop() read;
      // an integrator driving update() would keep running the whole hot path against removed
      // nodes forever, and step()'s only self-heal is idle()->sleep(), which is unreachable
      // while phase===1 — i.e. exactly the "torn down while the player was dead" case that a
      // level teardown produces.
      if (disposed) return;
      // extAt is stamped even while paused, deliberately: it is the "somebody else owns the
      // clock" marker, and letting it go stale under a long pause would hand the first frame
      // after resume to BOTH our rAF and the integrator.
      extAt = nowS();
      if (paused || !shown) return;                         // idle: one boolean, nothing else
      step(typeof dt === 'number' && dt > 0 ? Math.min(dt, 0.05) : 1 / 60);
    },
    dispose() {
      disposed = true;
      sleep();                       // cancels the rAF and drops the promoted layers
      for (const off of offs) off();
      offs.length = 0;
      root.remove();
      style.remove();
    },
  };
}
