cRaCked

Cracking helper with calibrated probability estimates, a character-model fallback for unknown words, and self-measuring telemetry

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         cRaCked
// @namespace    modul.torn.cracking
// @version      2.2.8
// @description  Cracking helper with calibrated probability estimates, a character-model fallback for unknown words, and self-measuring telemetry
// @author       SirAua [3785905], MoDuL [4022159], TheDno [4443016]
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @match        https://www.torn.com/page.php?sid=crimes*
// @grant        GM_xmlhttpRequest
// @connect      pp-api.sokin.xyz
// @run-at       document-idle
// @license      mit
// ==/UserScript==

(function () {
  'use strict';

  if (window.CRACK_SCRIPT_BOOTSTRAPPED) return;
  window.CRACK_SCRIPT_BOOTSTRAPPED = true;

  /* ==================================================================== */
  /* Torn-specific selectors, all in one place so a UI change is one patch */
  /* ==================================================================== */

  const SEL = {
    currentCrime: '[class^="currentCrime"]',
    virtualList: '[class^="virtualList"]',
    crimeOption: '[class^="crimeOptionWrapper"]',
    charSlot: '[class^="charSlot"]:not([class*="charSlotDummy"])',
    discoveredChar: '[class^="discoveredChar"]',
    guessesSection: '[class^="guessesSection"]',
    typeAndService: '[class^="typeAndService"]',
    appHeader: '[class^="appHeaderDelimiter"]',
    rigStatus: '[class^="rigStatus___"], [class*=" rigStatus___"]',
    rigState: '[class^="status___"], [class*=" status___"]',
    bruteStrength: '[class^="strength___"], [class*=" strength___"]',
    statisticLabel: '[class^="label___"], [class*=" label___"]',
    statisticValue: '[class^="value___"], [class*=" value___"]',
    flashClass: 'incorrectGuessFlash',
    encryptionClass: 'hasEncryption',
    // CONFIRM: no verified selector for the crack level. readLevel() tries a
    // few shapes and returns null harmlessly if none match, which only means
    // the coverage estimate loses its strongest feature.
    level: '[class^="level"]',
  };

  /* ==================================================================== */
  /* Config                                                               */
  /* ==================================================================== */

  const DEBUG = false;
  const SAFETY_SCAN_MS = 2500;      // observers do the real work; this is a net
  const MIN_LENGTH = 4;
  const MAX_LENGTH = 10;
  const PENDING_TIMEOUT_MS = 6000;
  const IMPLIED_MIN_P = 0.45;      // confidence needed to fill a gap in the LIKELY readout
  const LETTERS_SHOWN = 4;      // cap the LETTERS chip so it cannot overflow the panel

  const COMMUNITY_POOL_ORIGIN = 'https://pp-api.sokin.xyz/torn-crack-pool';
  const BASE_MANIFEST_URL = COMMUNITY_POOL_ORIGIN + '/base/manifest.json';
  const BASE_SNAPSHOT_ID = 'sha256:04f79148d7354d284cf25361cb2c85f6e7d0ebdcf3cac089b501dc3dd298bab1';
  const COMMUNITY_SYNC_ENABLED = true;
  const CF_ORIGIN = COMMUNITY_SYNC_ENABLED ? COMMUNITY_POOL_ORIGIN.replace(/\/+$/, '') : '';
  const CF_ADD_WORD_URL = CF_ORIGIN ? CF_ORIGIN + '/submit' : '';
  const CF_REGISTER_CLIENT_URL = CF_ORIGIN ? CF_ORIGIN + '/clients/register' : '';
  const CONTEXT_SNAPSHOT_URL = CF_ORIGIN ? CF_ORIGIN + '/contexts/snapshot.json' : '';
  const CLIENT_ID_STORE_KEY = 'cf_client_id_v1';
  const CLIENT_ID_RE = /^[A-Za-z0-9_-]{43}$/;
  const CLIENT_VERSION = '2.2.8';
  const CLIENT_BUILD_ID = 'c2a799b53fd129d37b02cbb072c00651c51435646c470a582e85add8fd1a1ed1';
  const CF_STORAGE_BASE = CF_ORIGIN ? CF_ORIGIN + '/words' : '';
  const METADATA_URL = CF_STORAGE_BASE ? CF_STORAGE_BASE + '/metadata.json' : '';

  const DOWNLOAD_MIN_DELTA = 25;
  const SYNC_CHECK_INTERVAL_MS = 15 * 60 * 1000;
  const SYNC_MAX_WAIT_MS = 24 * 60 * 60 * 1000;

  // Outbox
  const OUTBOX_FLUSH_DELAY_MS = 5000;
  const OUTBOX_POST_INTERVAL_MS = 2000;
  const OUTBOX_BATCH_SIZE = 10;
  const OUTBOX_MAX_QUEUE = 500;
  const OUTBOX_MAX_CONSECUTIVE_FAILURES = 3;
  const OUTBOX_BACKOFF_MS = 60000;

  // Telemetry
  const TELEMETRY_MAX_CRACKS = 3000;
  const TELEMETRY_MAX_GUESSES = 20000;

  const DB_NAME = 'crack';
  const DB_VERSION = 2;
  const STORE = 'dictionary';

  // Preferences
  const PREF = {
    badge: 'crack_show_badge',
    theme: 'crack_theme',
    sugFont: 'crack_sug_font_px',
    sugText: 'crack_sug_text_color',
    sugBg: 'crack_sug_bg_color',
    uiText: 'crack_ui_text_color',
    uiBg: 'crack_ui_bg_color',
    uiBorder: 'crack_ui_border_color',
    boxBg: 'crack_ui_box_color',
    appearanceOpen: 'crack_settings_appearance_open',
    maxSug: 'crack_max_suggestions_size',
    // Upload is now a visible, revocable choice. Default true preserves 1.3.1
    // behaviour so the pool keeps growing; flip to false here if you would
    // rather make it strictly opt-in.
    upload: 'crack_upload_enabled',
    netHook: 'crack_net_hook',
    params: 'crack_params_json',
  };
  const DEFAULT_MAX_SUGGESTIONS = 8;
  const MIN_MAX_SUGGESTIONS = 1;
  const MAX_MAX_SUGGESTIONS = 20;

  const THEME_PRESETS = {
    dark: {
      uiBg: '#000',
      uiText: '#0f0',
      uiBorder: '#0f0',
      overlayBg: 'rgba(0,0,0,0.5)',
      boxBg: '#111',
      sugBg: '#000',
      sugText: '#0f0',
    },
    light: {
      uiBg: '#fff',
      uiText: '#03396c',
      uiBorder: '#39ace7',
      overlayBg: 'rgba(0,0,0,0.5)',
      boxBg: '#fff',
      sugBg: '#fff',
      sugText: '#03396c',
    },
  };

  /* ==================================================================== */
  /* Small utils                                                          */
  /* ==================================================================== */

  function log() { if (DEBUG) console.log.apply(console, ['[Crack]'].concat([].slice.call(arguments))); }

  function isCrackingPage() {
    return location.pathname === '/page.php'
      && new URLSearchParams(location.search).get('sid') === 'crimes'
      // startsWith, not ===, so a subroute or query suffix does not disable us
      && location.hash.startsWith('#/cracking');
  }

  function isPda() {
    return navigator.userAgent.toLowerCase().indexOf('com.manuito.tornpda') !== -1;
  }
  function isCompact() { return isPda() || window.innerWidth <= 700; }

  const getBool = (k, d) => { const v = localStorage.getItem(k); return v === null ? d : v === '1'; };
  const setBool = (k, v) => localStorage.setItem(k, v ? '1' : '0');
  const getStr = (k, d) => { const v = localStorage.getItem(k); return v === null ? d : String(v); };
  const setStr = (k, v) => localStorage.setItem(k, String(v));
  const getInt = (k, d) => { const n = parseInt(localStorage.getItem(k), 10); return Number.isFinite(n) ? n : d; };
  const setInt = (k, v) => localStorage.setItem(k, String(Math.trunc(Number(v) || 0)));
  const clampInt = (n, lo, hi, d) => {
    n = Math.trunc(Number(n));
    if (!Number.isFinite(n)) n = d;
    return Math.max(lo, Math.min(hi, n));
  };
  function maxSugPref() {
    return clampInt(
      getInt(PREF.maxSug, DEFAULT_MAX_SUGGESTIONS),
      MIN_MAX_SUGGESTIONS,
      MAX_MAX_SUGGESTIONS,
      DEFAULT_MAX_SUGGESTIONS,
    );
  }

  function themeName() { return getStr(PREF.theme, 'dark') === 'light' ? 'light' : 'dark'; }
  function preset() { return THEME_PRESETS[themeName()]; }
  function theme() {
    const p = preset();
    return {
      name: themeName(),
      uiBg: getStr(PREF.uiBg, p.uiBg),
      uiText: getStr(PREF.uiText, p.uiText),
      uiBorder: getStr(PREF.uiBorder, p.uiBorder),
      overlayBg: p.overlayBg,
      boxBg: getStr(PREF.boxBg, p.boxBg),
      sugBg: getStr(PREF.sugBg, p.sugBg),
      sugText: getStr(PREF.sugText, p.sugText),
      sugFontPx: getInt(PREF.sugFont, 10),
    };
  }

  function formatPct(p) {
    const v = Math.max(0, Number(p) || 0) * 100;
    if (v >= 10) return v.toFixed(0) + '%';
    if (v >= 1) return v.toFixed(1) + '%';
    if (v >= 0.1) return v.toFixed(2) + '%';
    return '<0.1%';
  }

  function formatDuration(ms) {
    if (ms <= 0) return 'now';
    const s = Math.floor(ms / 1000);
    const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
    const m = Math.floor((s % 3600) / 60), sec = s % 60;
    if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
    if (h > 0) return h + 'h ' + m + 'm ' + sec + 's';
    if (m > 0) return m + 'm ' + sec + 's';
    return sec + 's';
  }

  function formatProgressStatus(label, completed, total, startedAt, now = Date.now()) {
    const done = Math.max(0, Number(completed) || 0);
    const size = Math.max(0, Number(total) || 0);
    const elapsed = Math.max(0, now - startedAt);
    const percent = size > 0 ? Math.min(100, Math.floor(100 * done / size)) : 0;
    let remaining = 'time left: estimating…';
    if (done > 0 && size > done && elapsed > 0) {
      const estimate = Math.max(1000, elapsed * (size - done) / done);
      remaining = '~' + formatDuration(estimate) + ' left';
    } else if (size > 0 && done >= size) {
      remaining = '0s left';
    }
    return label + ' · ' + done.toLocaleString() + '/' + size.toLocaleString()
      + ' words (' + percent + '%) · elapsed ' + formatDuration(elapsed) + ' · ' + remaining;
  }

  function formatCompactNumber(value) {
    const number = Math.max(0, Math.floor(Number(value) || 0));
    if (number >= 1000000) return (Math.floor(number / 100000) / 10).toFixed(1) + 'm';
    if (number >= 1000) return (Math.floor(number / 100) / 10).toFixed(1) + 'k';
    return number.toLocaleString();
  }

  function gmRequest(opts) {
    return new Promise((resolve, reject) => {
      try {
        const o = Object.assign({ responseType: 'text' }, opts);
        o.headers = Object.assign({ Accept: 'application/json, text/plain, */*; q=0.1' }, opts.headers || {});
        GM_xmlhttpRequest(Object.assign({}, o, { onload: resolve, onerror: reject, ontimeout: reject }));
      } catch (e) { reject(e); }
    });
  }

  function headerOf(headers, name) {
    const m = headers && headers.match ? headers.match(new RegExp('^' + name + ':\\s*(.*)$', 'mi')) : null;
    return m ? m[1].trim() : null;
  }

  /* ==================================================================== */
  /* IndexedDB — one cached connection, shared with the worker            */
  /* ==================================================================== */

  let dbPromise = null;
  function openDB() {
    if (dbPromise) return dbPromise;
    dbPromise = new Promise((resolve, reject) => {
      const req = indexedDB.open(DB_NAME, DB_VERSION);
      req.onupgradeneeded = () => {
        const db = req.result;
        if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
      };
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
    return dbPromise;
  }

  async function idbGet(key) {
    const db = await openDB();
    return new Promise((resolve, reject) => {
      const r = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
      r.onsuccess = () => resolve(r.result);
      r.onerror = () => reject(r.error);
    });
  }

  async function idbSet(key, val) {
    const db = await openDB();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(STORE, 'readwrite');
      tx.objectStore(STORE).put(val, key);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async function idbDel(key) {
    const db = await openDB();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(STORE, 'readwrite');
      tx.objectStore(STORE).delete(key);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async function idbClearAll() {
    const db = await openDB();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(STORE, 'readwrite');
      tx.objectStore(STORE).clear();
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }
  /* ==================================================================== */
  /* Scoring core                                                         */
  /* ==================================================================== */

  /* __CRACK_CORE_FACTORY__ */
  /* ---------------------------------------------------------------------
   * Inlined verbatim from src/crack-core.js by build.mjs. Do not edit here.
   *
   * Wrapped in a factory so the exact same source can be used two ways: called
   * directly on this thread, and stringified with toString() to build the
   * Worker. One implementation, no duplication, readable in both.
   * ------------------------------------------------------------------- */
  function CrackCoreFactory() {
    /* ===========================================================================
     * crack-core — pure scoring core for cRaCked
     * ---------------------------------------------------------------------------
     * No DOM. No IndexedDB. No network. Everything here is deterministic and
     * testable in Node, which is the whole point: this file is the single source
     * of truth for the model, shared by the userscript's Worker and by the
     * offline replay harness. build.mjs inlines it into the userscript.
     *
     * Design notes for the reviewer:
     *
     *  - Words are stored PACKED: one Uint8Array per length bucket, fixed stride
     *    = word length. 272k eight-char words = 2.2 MB instead of ~16-20 MB of JS
     *    strings, and it transfers to a Worker zero-copy. No string allocation
     *    anywhere in the hot loop.
     *
     *  - Rank is stored EXPLICITLY (Uint32Array) alongside a source tier
     *    (base list / community pool / locally cracked). The old script inferred
     *    rank from array position, so every appended community word landed at
     *    index ~272,000 and was penalised ~104x against generic top-of-list
     *    passwords. Source tier now carries the prior instead.
     *
     *  - The model reserves explicit out-of-vocabulary mass (alpha). A sole
     *    surviving candidate can no longer report 100%; it reports the
     *    probability that the password is in the dictionary at all.
     *
     *  - When alpha is low the estimate falls back down a ladder:
     *    trigram -> bigram (both sides) -> positional frequency -> unigram.
     *    That is the only thing that helps on words the dictionary has never
     *    seen, which at high crack levels is most of them.
     * ======================================================================== */

    /* ---------------------------------------------------------------- alphabet */

    const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    const A = ALPHABET.length;          // 36
    const WILD = 255;                   // pattern placeholder for "hidden"

    const CODE = new Uint8Array(256).fill(255);
    for (let i = 0; i < A; i++) CODE[ALPHABET.charCodeAt(i)] = i;

    function codeOfChar(ch) {
      if (!ch) return 255;
      const c = ch.toUpperCase().charCodeAt(0);
      return c < 256 ? CODE[c] : 255;
    }

    /** Encode a word to codes. Returns null if it contains anything unsupported. */
    function encodeWord(word) {
      const w = String(word || '').toUpperCase();
      if (!w) return null;
      const out = new Uint8Array(w.length);
      for (let i = 0; i < w.length; i++) {
        const c = w.charCodeAt(i);
        const v = c < 256 ? CODE[c] : 255;
        if (v === 255) return null;
        out[i] = v;
      }
      return out;
    }

    function decodeCodes(codes, off, len) {
      let s = '';
      for (let i = 0; i < len; i++) s += ALPHABET[codes[off + i]];
      return s;
    }

    /* ------------------------------------------------------------ source tiers */

    const SRC_BASE = 0;   // the generic leaked-password wordlist
    const SRC_POOL = 1;   // community-confirmed Torn password
    const SRC_LOCAL = 2;  // password this client cracked itself

    /* ------------------------------------------------------------- tuneables */

    /**
     * Every constant the model depends on, in one object, so the replay harness
     * can sweep them and the settings UI can override them. None of these are
     * sacred — they are starting points to be tuned against logged cracks.
     */
    const DEFAULT_PARAMS = {
      // prior(rank, src) = SRC_W[src] / (rank + RANK_OFFSET) ^ RANK_EXP
      RANK_EXP: 0.85,
      RANK_OFFSET: 30,
      SRC_W: [1, 40, 80],

      // Observation evidence is ADDITIVE, not a multiplicative log boost. Additive
      // lets confirmed sightings genuinely dominate a bad prior and degrades
      // gracefully; the old multiplicative form needed ~670 sightings of one word
      // to overcome the rank penalty, i.e. it could never win.
      OBS_GLOBAL_W: 0.5,
      OBS_CTX_W: 3.0,

      // Pseudo-token weight of an observed word when building the n-gram tables,
      // so the character model learns Torn's vocabulary faster than the 869k
      // generic list can drown it.
      OBS_NGRAM_PSEUDO: 40,

      // Backoff ladder mixing weights. Renormalised over whichever tiers are
      // actually available at a given position.
      W_TRI: 0.42,
      W_BI: 0.20,
      W_POS: 0.28,
      W_UNI: 0.10,

      // Add-k smoothing for the n-gram tables.
      NGRAM_K: 0.35,

      // Dictionary-coverage prior by word length: P(password is in the wordlist).
      // Auto-calibrated from telemetry once enough cracks are logged; these are
      // only the cold-start values.
      COVERAGE: { 4: 0.85, 5: 0.80, 6: 0.70, 7: 0.60, 8: 0.50, 9: 0.40, 10: 0.30, 11: 0.22, 12: 0.18 },
      COVERAGE_FALLBACK: 0.15,

      // Coverage falls off with crack level. level 50 -> x0.80, level 100 -> x0.60.
      LEVEL_DECAY: 0.004,
      LEVEL_DECAY_FLOOR: 0.40,

      // If the surviving candidates are dominated by words we have actually seen
      // before, confidence that the answer is in-vocabulary should rise.
      OBS_CONF: 1.8,

      // Never display certainty. There is always a chance the word is out of
      // vocabulary, and XTRIDENT66 is a reminder of how often that happens.
      ALPHA_MAX: 0.92,

      // Shrinkage for the auto-calibrated coverage estimate.
      CALIB_PRIOR_N: 12,

      // Embedded-word matching. Torn passwords observed so far look like a
      // dictionary word with affixes: TERMINATOR, XTRIDENT66 (= X + TRIDENT + 66).
      // Exact whole-string matching fails on those, and no n-gram can recover the
      // inference, but aligning TRIDENT at offset 1 of _T_IDENT66 forces position 3
      // to R outright. Only runs when whole-word matching found nothing, so it costs
      // nothing in the cases the dictionary already handles.
      AFFIX_ENABLED: true,
      AFFIX_MIN_LEN: 4,          // shortest embedded word worth trusting
      AFFIX_MAX_PAD: 5,          // covers observed 5+5 combined-word passwords
      AFFIX_MIN_REVEALED: 2,     // revealed characters the window must cover
      AFFIX_PAD_PENALTY: 0.45,   // per padding character; prefers longer embeddings
      AFFIX_MAX: 0.9,            // never let it fully crowd out the character model

      // Ablation switch: ignore the dictionary entirely and score from the
      // character model alone. Needed as an explicit flag because zeroing the
      // COVERAGE table is not enough — measured calibration data legitimately
      // overrides the cold-start prior, which would leak the dictionary back in.
      DICT_DISABLED: false,

      // Brute-force accounting. The action decision itself does not depend on a
      // guessed nerve value: a manual guess is free, and after a miss the player
      // can still brute-force the job. These constants only turn the remaining
      // work units into an optional cost estimate for replay/tuning.
      BRUTE_NERVE: 7,
    };

    function mergeParams(over) {
      const p = { ...DEFAULT_PARAMS, ...(over || {}) };
      p.SRC_W = (over && over.SRC_W) || DEFAULT_PARAMS.SRC_W.slice();
      p.COVERAGE = { ...DEFAULT_PARAMS.COVERAGE, ...((over && over.COVERAGE) || {}) };
      return p;
    }

    function priorOf(rank, src, params) {
      const w = params.SRC_W[src] !== undefined ? params.SRC_W[src] : 1;
      return w / Math.pow(rank + params.RANK_OFFSET, params.RANK_EXP);
    }

    /* ------------------------------------------------------------------ bucket */

    /** A length bucket: append-only, so indices are stable and persistable. */
    function makeBucket(len, cap) {
      cap = Math.max(64, cap || 1024);
      return {
        len,
        count: 0,
        cap,
        codes: new Uint8Array(cap * len),
        rank: new Uint32Array(cap),
        src: new Uint8Array(cap),
      };
    }

    function grow(b, need) {
      if (need <= b.cap) return;
      let cap = b.cap;
      while (cap < need) cap *= 2;
      const codes = new Uint8Array(cap * b.len); codes.set(b.codes);
      const rank = new Uint32Array(cap); rank.set(b.rank);
      const src = new Uint8Array(cap); src.set(b.src);
      b.codes = codes; b.rank = rank; b.src = src; b.cap = cap;
    }

    /** Append. Returns the new index. Caller is responsible for dedup. */
    function bucketAdd(b, codes, rank, src) {
      grow(b, b.count + 1);
      b.codes.set(codes, b.count * b.len);
      b.rank[b.count] = rank >>> 0;
      b.src[b.count] = src;
      return b.count++;
    }

    /**
     * Linear scan for a word. On a packed byte array this is ~1 ms for 272k
     * eight-char entries and runs only when a crack completes, so there is no
     * reason to carry a parallel Set of 869k JS strings just for this.
     */
    function bucketFind(b, codes) {
      const { len, count } = b;
      const arr = b.codes;
      outer:
      for (let i = 0; i < count; i++) {
        const off = i * len;
        for (let p = 0; p < len; p++) if (arr[off + p] !== codes[p]) continue outer;
        return i;
      }
      return -1;
    }

    function bucketWord(b, i) {
      return decodeCodes(b.codes, i * b.len, b.len);
    }

    /** Trim to exact size for persistence. */
    function bucketExport(b) {
      return {
        len: b.len,
        count: b.count,
        codes: b.codes.slice(0, b.count * b.len),
        rank: b.rank.slice(0, b.count),
        src: b.src.slice(0, b.count),
      };
    }

    function bucketImport(rec) {
      const codes = rec.codes instanceof Uint8Array ? rec.codes : new Uint8Array(rec.codes);
      const rank = rec.rank instanceof Uint32Array ? rec.rank : new Uint32Array(rec.rank);
      const src = rec.src instanceof Uint8Array ? rec.src : new Uint8Array(rec.src);
      return { len: rec.len, count: rec.count, cap: Math.max(64, rec.count), codes, rank, src };
    }

    /* ------------------------------------------------------------ n-gram model */

    /**
     * Character models built once over all buckets, weighted by prior plus
     * observation pseudo-counts. These are what make an out-of-vocabulary guess
     * meaningfully better than random.
     *
     *   uni[c]                       global character frequency
     *   pos[len][p * A + c]          positional frequency for that word length
     *   biL[prev * A + c]            P(c | character to the left)
     *   biR[next * A + c]            P(c | character to the right)
     *   triL[(p2 * A + p1) * A + c]  P(c | two characters to the left)
     *   triR[(n2 * A + n1) * A + c]  P(c | two characters to the right)
     *
     * The right-hand trigram matters more than it might look. Revealed characters
     * cluster wherever the player has been guessing, so a gap frequently has its
     * context entirely on one side. On a pattern like _T_IDENT66 the left trigram
     * is unavailable (position 0 is still hidden) while the right one sees ?ID and
     * is strongly informative.
     */
    function buildModels(buckets, gobs, params, onProgress) {
      const uni = new Float64Array(A);
      const biL = new Float64Array(A * A);
      const biR = new Float64Array(A * A);
      const triL = new Float64Array(A * A * A);
      const triR = new Float64Array(A * A * A);
      const pos = {};
      let mass = 0;
      const keys = Object.keys(buckets);
      const progressTotal = keys.reduce((sum, key) => {
        const bucket = buckets[key];
        return sum + (bucket && bucket.count ? bucket.count : 0);
      }, 0);
      let progressDone = 0;
      let nextProgress = 25000;
      if (typeof onProgress === 'function') onProgress(0, progressTotal);

      for (const key of keys) {
        const b = buckets[key];
        if (!b || !b.count) continue;
        const len = b.len;
        const g = (gobs && gobs[len]) || null;
        const parr = new Float64Array(len * A);
        pos[len] = parr;

        for (let i = 0; i < b.count; i++) {
          const off = i * len;
          let w = priorOf(b.rank[i], b.src[i], params);
          if (g && g[i]) w += params.OBS_NGRAM_PSEUDO * g[i];
          mass += w;

          for (let p = 0; p < len; p++) {
            const c = b.codes[off + p];
            uni[c] += w;
            parr[p * A + c] += w;
            if (p >= 1) biL[b.codes[off + p - 1] * A + c] += w;
            if (p <= len - 2) biR[b.codes[off + p + 1] * A + c] += w;
            if (p >= 2) {
              triL[(b.codes[off + p - 2] * A + b.codes[off + p - 1]) * A + c] += w;
            }
            if (p <= len - 3) {
              triR[(b.codes[off + p + 2] * A + b.codes[off + p + 1]) * A + c] += w;
            }
          }
          progressDone++;
          if (typeof onProgress === 'function' && progressDone >= nextProgress) {
            onProgress(progressDone, progressTotal);
            nextProgress += 25000;
          }
        }
      }

      if (typeof onProgress === 'function' && progressDone !== 0) {
        onProgress(progressDone, progressTotal);
      }

      return {
        // An untrained model returns a flat distribution from add-k smoothing alone,
        // which looks identical to a confident uniform answer. Callers need to be
        // able to tell those apart, so say so explicitly.
        trained: mass > 0,
        mass,
        uni: rowNorm(uni, A, params.NGRAM_K),
        biL: rowNorm(biL, A, params.NGRAM_K),
        biR: rowNorm(biR, A, params.NGRAM_K),
        triL: rowNorm(triL, A, params.NGRAM_K),
        triR: rowNorm(triR, A, params.NGRAM_K),
        pos: Object.fromEntries(Object.entries(pos).map(([l, arr]) => [l, rowNorm(arr, A, params.NGRAM_K)])),
      };
    }

    /** Add-k normalise each contiguous run of A cells into a probability row. */
    function rowNorm(arr, width, k) {
      const rows = arr.length / width;
      const out = new Float32Array(arr.length);
      for (let r = 0; r < rows; r++) {
        const off = r * width;
        let sum = 0;
        for (let c = 0; c < width; c++) sum += arr[off + c];
        const denom = sum + k * width;
        for (let c = 0; c < width; c++) out[off + c] = (arr[off + c] + k) / denom;
      }
      return out;
    }

    /**
     * Backoff distribution over characters for one hidden position.
     * Writes A floats into `out` and returns it (already normalised).
     */
    function backoffDist(models, len, p, pat, params, out) {
      out = out || new Float32Array(A);
      out.fill(0);

      const posArr = models.pos[len] || models.pos[String(len)];
      const l1 = p >= 1 ? pat[p - 1] : WILD;
      const l2 = p >= 2 ? pat[p - 2] : WILD;
      const r1 = p <= len - 2 ? pat[p + 1] : WILD;
      const r2 = p <= len - 3 ? pat[p + 2] : WILD;

      const hasTriL = l1 !== WILD && l2 !== WILD;
      const hasTriR = r1 !== WILD && r2 !== WILD;
      const hasL = l1 !== WILD;
      const hasR = r1 !== WILD;

      // Renormalise the mixing weights over whichever tiers exist at this position.
      let wTriL = hasTriL ? params.W_TRI : 0;
      let wTriR = hasTriR ? params.W_TRI : 0;
      let wL = hasL ? params.W_BI : 0;
      let wR = hasR ? params.W_BI : 0;
      let wPos = posArr ? params.W_POS : 0;
      let wUni = params.W_UNI;
      const tot = wTriL + wTriR + wL + wR + wPos + wUni;
      wTriL /= tot; wTriR /= tot; wL /= tot; wR /= tot; wPos /= tot; wUni /= tot;

      const triLOff = hasTriL ? (l2 * A + l1) * A : 0;
      const triROff = hasTriR ? (r2 * A + r1) * A : 0;
      const lOff = hasL ? l1 * A : 0;
      const rOff = hasR ? r1 * A : 0;
      const pOff = posArr ? p * A : 0;

      for (let c = 0; c < A; c++) {
        let v = wUni * models.uni[c];
        if (hasTriL) v += wTriL * models.triL[triLOff + c];
        if (hasTriR) v += wTriR * models.triR[triROff + c];
        if (hasL) v += wL * models.biL[lOff + c];
        if (hasR) v += wR * models.biR[rOff + c];
        if (posArr) v += wPos * posArr[pOff + c];
        out[c] = v;
      }
      return out;
    }

    /* ----------------------------------------------------- embedded-word vote */

    /**
     * Slide every dictionary word of length m <= len across the pattern and keep the
     * alignments that are consistent with what has been revealed. Each surviving
     * alignment votes for the characters it implies at the still-hidden positions
     * inside its window.
     *
     * This is the tier that solves the XTRIDENT66 shape. Whole-string lookup finds
     * nothing, the character model can only muster a weak preference, but TRIDENT
     * aligned at offset 1 explains six revealed characters at once and pins the
     * remaining gap to R.
     *
     * Returns accumulated weight per (position, character) plus the total mass, so
     * the caller can decide how much to trust it.
     */
    function affixVote(buckets, pat, allow, params) {
      const len = pat.length;
      const letterW = new Float64Array(len * A);
      let mass = 0;
      let alignments = 0;
      let scanned = 0;
      let bestK = 0;

      const minLen = Math.max(2, params.AFFIX_MIN_LEN);

      for (let m = len; m >= minLen; m--) {
        const bucket = buckets[m] || buckets[String(m)];
        if (!bucket || !bucket.count) continue;

        const pads = len - m;
        if (pads > params.AFFIX_MAX_PAD) continue;
        const padPenalty = Math.pow(params.AFFIX_PAD_PENALTY, pads);
        const codes = bucket.codes;

        for (let offset = 0; offset <= pads; offset++) {
          // Split the window into revealed positions (the constraints that make
          // this cheap) and hidden positions (what we are trying to learn).
          const constraints = [];
          const hidden = [];
          for (let j = 0; j < m; j++) {
            if (pat[offset + j] === WILD) hidden.push(j);
            else constraints.push(j);
          }
          if (constraints.length < params.AFFIX_MIN_REVEALED) continue;
          if (!hidden.length) continue;

          // An alignment explaining k revealed characters would happen by chance
          // with probability about A^-k, so weight by A^k. An embedding that
          // accounts for six revealed characters at once is overwhelmingly more
          // credible than one scraping past on two, and this is what lets the real
          // explanation outvote the noise.
          const evidence = Math.pow(A, Math.min(constraints.length, 8));
          scanned += bucket.count;

          outer:
          for (let i = 0; i < bucket.count; i++) {
            const off = i * m;
            for (let k = 0; k < constraints.length; k++) {
              const j = constraints[k];
              if (codes[off + j] !== pat[offset + j]) continue outer;
            }
            for (let k = 0; k < hidden.length; k++) {
              const j = hidden[k];
              if (!allow[(offset + j) * A + codes[off + j]]) continue outer;
            }

            const w = priorOf(bucket.rank[i], bucket.src[i], params) * padPenalty * evidence;
            mass += w;
            alignments++;
            if (constraints.length > bestK) bestK = constraints.length;
            for (let k = 0; k < hidden.length; k++) {
              const j = hidden[k];
              letterW[(offset + j) * A + codes[off + j]] += w;
            }
          }
        }
      }

      /* How far to trust this, on a scale that does not depend on the absolute size
       * of the priors. Expected number of alignments arising purely by chance is
       * scanned * A^-bestK; when that is far below one, the alignment found is
       * almost certainly the real explanation. */
      const expectedByChance = alignments ? scanned * Math.pow(A, -bestK) : Infinity;
      const trust = alignments ? params.AFFIX_MAX / (1 + expectedByChance) : 0;

      return { letterW, mass, alignments, scanned, bestK, expectedByChance, trust };
    }

    /* ----------------------------------------------------------------- alpha */

    /**
     * P(the true password is present in our dictionary at all).
     *
     * `calib` is optional measured data: { hits, n } for this (length, level)
     * cell, shrunk toward the cold-start prior. This is the loop that closes
     * itself — the more cracks get logged, the less the hand-picked COVERAGE
     * table matters.
     */
    function coveragePrior(len, level, params, calib) {
      if (params.DICT_DISABLED) return 0;
      let base = params.COVERAGE[len];
      if (base === undefined) base = params.COVERAGE_FALLBACK;
      if (Number.isFinite(level) && level > 0) {
        const f = Math.max(params.LEVEL_DECAY_FLOOR, 1 - params.LEVEL_DECAY * level);
        base *= f;
      }
      if (calib && calib.n > 0) {
        const k = params.CALIB_PRIOR_N;
        base = (calib.hits + k * base) / (calib.n + k);
      }
      return Math.min(params.ALPHA_MAX, Math.max(0, base));
    }

    /* ----------------------------------------------------------------- query */

    /**
     * Build the per-position allow mask: allow[p * A + c] is 1 when character c
     * is still possible at position p. Folding the pattern and the exclusions
     * into one table turns the inner loop into a single array lookup per
     * character with no branching.
     */
    function buildAllow(pat, exclusions, params) {
      const len = pat.length;
      const allow = new Uint8Array(len * A).fill(1);

      for (let p = 0; p < len; p++) {
        const off = p * A;
        if (pat[p] !== WILD) {
          allow.fill(0, off, off + A);
          allow[off + pat[p]] = 1;
          continue;
        }
        // Accept either codes or characters. The worker hands us codes, the replay
        // harness hands us characters, and silently ignoring one of them means
        // exclusions stop working and the scorer re-guesses forever.
        const ex = exclusions && exclusions[p];
        if (ex) {
          for (const raw of ex) {
            const c = typeof raw === 'number' ? raw : codeOfChar(raw);
            if (c >= 0 && c < A) allow[off + c] = 0;
          }
        }
      }
      return allow;
    }

    function insertTop(top, cand, max) {
      let lo = 0, hi = top.length;
      while (lo < hi) {
        const mid = (lo + hi) >> 1;
        const cur = top[mid];
        if (cur.w > cand.w || (cur.w === cand.w && cur.i < cand.i)) lo = mid + 1;
        else hi = mid;
      }
      if (lo >= max) return;
      top.splice(lo, 0, cand);
      if (top.length > max) top.pop();
    }

    /**
     * The whole model in one call.
     *
     * opts:
     *   bucket           packed bucket for this word length
     *   models           output of buildModels
     *   pat              Uint8Array(len), WILD where hidden
     *   allow            output of buildAllow
     *   gobs             Float32Array(bucket.count) global observation counts
     *   ctxObs           Map<index, count> for the current target/service
     *   guessPositions   positions the player can actually type into
     *   level            crack level or null
     *   calib            { hits, n } measured coverage for this cell, or null
     *   params, maxSug
     *
     * Returns per-position character distributions that already blend dictionary
     * evidence with the backoff ladder, plus the single best guess.
     */
    function query(opts) {
      const {
        bucket, buckets, models, pat, allow, gobs, ctxObs,
        guessPositions, level, calib, maxSug,
      } = opts;
      const params = opts.params || DEFAULT_PARAMS;
      const len = pat.length;

      const open = [];
      for (let p = 0; p < len; p++) if (pat[p] === WILD) open.push(p);

      const empty = {
        matchCount: 0, S: 0, alpha: 0, words: [], slots: [], best: null,
        obsShare: 0, coverage: coveragePrior(len, level, params, calib),
        modelTrained: !!(models && models.trained), affixMass: 0, affixAlignments: 0,
      };
      if (!bucket || !bucket.count || !open.length || params.DICT_DISABLED) {
        // No dictionary for this length (or nothing hidden): still emit backoff
        // estimates so the panel is never silent.
        if (!open.length) return empty;
        return finishBackoffOnly(opts, open, empty);
      }

      const { count, codes } = bucket;
      const letterW = new Float64Array(len * A);
      const top = [];
      let S = 0, matchCount = 0, obsW = 0;
      const max = Math.max(1, maxSug || 8);

      // Check the revealed positions first: they eliminate ~97% of candidates on
      // the first comparison, so ordering the checks this way is most of the
      // reason a full linear scan is fast enough to keep the code simple.
      const order = [];
      for (let p = 0; p < len; p++) if (pat[p] !== WILD) order.push(p);
      for (let p = 0; p < len; p++) if (pat[p] === WILD) order.push(p);

      outer:
      for (let i = 0; i < count; i++) {
        const off = i * len;
        for (let k = 0; k < order.length; k++) {
          const p = order[k];
          if (!allow[p * A + codes[off + p]]) continue outer;
        }

        const g = gobs ? gobs[i] : 0;
        const c = ctxObs ? (ctxObs.get(i) || 0) : 0;
        const w = priorOf(bucket.rank[i], bucket.src[i], params)
          + params.OBS_GLOBAL_W * g
          + params.OBS_CTX_W * c;

        S += w;
        matchCount++;
        if (g > 0 || c > 0) obsW += w;
        for (let k = 0; k < open.length; k++) {
          const p = open[k];
          letterW[p * A + codes[off + p]] += w;
        }
        insertTop(top, { i, w, g, c }, max);
      }

      if (matchCount === 0) return finishBackoffOnly(opts, open, empty);

      // --- alpha: how much do we trust the dictionary here at all -------------
      // The observation boost is scaled by how constrained the pattern actually is.
      // A previously-seen word turning up among 200,000 candidates says almost
      // nothing; the same word surviving among three says a great deal. Without this
      // damping a single observed word pinned every row to the ALPHA_MAX ceiling,
      // so a 6-character pattern with nothing revealed claimed 97% confidence.
      const coverage = coveragePrior(len, level, params, calib);
      const obsShare = S > 0 ? obsW / S : 0;
      const revealed = len - open.length;
      const constraint = len > 0 ? revealed / len : 0;
      const boost = Math.min(1, obsShare * params.OBS_CONF) * constraint;
      let alpha = coverage + (1 - coverage) * boost;
      alpha = Math.min(params.ALPHA_MAX, Math.max(0, alpha));
      const alphaCapped = alpha >= params.ALPHA_MAX - 1e-9;

      // --- blend dictionary marginals with the backoff ladder -----------------
      const scratch = new Float32Array(A);
      const slots = [];
      for (const p of open) {
        const bo = backoffDist(models, len, p, pat, params, scratch);

        // Renormalise the backoff over characters still allowed at this position.
        let boSum = 0;
        for (let c = 0; c < A; c++) if (allow[p * A + c]) boSum += bo[c];

        const letters = [];
        for (let c = 0; c < A; c++) {
          if (!allow[p * A + c]) continue;
          const pDict = S > 0 ? letterW[p * A + c] / S : 0;
          const pBack = boSum > 0 ? bo[c] / boSum : 1 / A;
          const prob = alpha * pDict + (1 - alpha) * pBack;
          if (prob > 0) letters.push({ char: ALPHABET[c], code: c, p: prob, pDict, pBack });
        }
        letters.sort((x, y) => y.p - x.p || x.code - y.code);
        slots.push({ position: p, letters });
      }

      // --- best single guess, restricted to slots the player can actually use --
      const best = pickBest(slots, open, guessPositions, true, (slot, top1) => (
        top1.pDict * alpha >= (1 - alpha) * top1.pBack ? 'dict' : 'ngram'
      ));

      const words = top.map(t => ({
        word: bucketWord(bucket, t.i),
        // Scaled by alpha: this is P(word), not P(word | word is in the list).
        // That distinction is the whole reason a lone survivor used to read 100%.
        p: S > 0 ? alpha * (t.w / S) : 0,
        rank: bucket.rank[t.i],
        src: bucket.src[t.i],
        obs: t.g,
        ctxObs: t.c,
      }));

      return {
        matchCount, S, alpha, alphaCapped, coverage, obsShare, words, slots, best,
        modelTrained: !!(models && models.trained), affixMass: 0, affixAlignments: 0,
      };
    }

    /**
     * No whole-word dictionary support: estimates come from embedded-word
     * alignments where any exist, otherwise from the character model. 1.3.1 printed
     * "(no matches)" and gave up here, which is exactly where the player most needs
     * a hint.
     */
    function finishBackoffOnly(opts, open, empty) {
      const { pat, allow, models, buckets, guessPositions, level, calib } = opts;
      const params = opts.params || DEFAULT_PARAMS;
      const len = pat.length;
      const trained = !!(models && models.trained);

      // DICT_DISABLED has to switch this off too: embedded-word alignment is
      // dictionary-derived evidence, so leaving it on would let the dictionary back
      // into an ablation meant to exclude it. Same class of leak as the COVERAGE one.
      const affixOn = params.AFFIX_ENABLED && !params.DICT_DISABLED && buckets && trained;
      const affix = affixOn
        ? affixVote(buckets, pat, allow, params)
        : { letterW: null, mass: 0, alignments: 0, trust: 0 };
      const beta = affix.trust || 0;

      const scratch = new Float32Array(A);
      const slots = [];

      for (const p of open) {
        const bo = backoffDist(models, len, p, pat, params, scratch);
        let boSum = 0;
        let affixSum = 0;
        for (let c = 0; c < A; c++) {
          if (!allow[p * A + c]) continue;
          boSum += bo[c];
          if (affix.letterW) affixSum += affix.letterW[p * A + c];
        }

        // Alignments that do not cover this position say nothing about it, so trust
        // is scaled by the share of alignment mass that actually reaches here.
        // On _T_IDENT66 both STRIDENT and TRIDENT vote for R at position 3, but only
        // STRIDENT reaches position 1 — without this, position 1 claimed 90%
        // confidence in S on the strength of a single alignment.
        const share = affix.mass > 0 ? Math.min(1, affixSum / affix.mass) : 0;
        const betaHere = beta * share;

        const letters = [];
        for (let c = 0; c < A; c++) {
          if (!allow[p * A + c]) continue;
          const pBack = boSum > 0 ? bo[c] / boSum : 1 / A;
          const pAffix = affixSum > 0 ? affix.letterW[p * A + c] / affixSum : null;
          const useBeta = pAffix === null ? 0 : betaHere;
          const prob = useBeta * pAffix + (1 - useBeta) * pBack;
          letters.push({
            char: ALPHABET[c], code: c, p: prob,
            pDict: 0, pBack, pAffix: pAffix === null ? 0 : pAffix,
          });
        }
        letters.sort((x, y) => y.p - x.p || x.code - y.code);
        slots.push({ position: p, letters, hasAffix: affixSum > 0 });
      }

      return {
        ...empty,
        alpha: 0,
        coverage: coveragePrior(len, level, params, calib),
        modelTrained: trained,
        affixMass: affix.mass,
        affixAlignments: affix.alignments,
        affixBestK: affix.bestK || 0,
        affixTrust: beta,
        slots,
        best: pickBest(slots, open, guessPositions, trained, (slot) => (
          slot.hasAffix && beta >= 0.5 ? 'affix' : 'ngram'
        )),
      };
    }

    /**
     * Highest-probability (position, character) among the slots the player can
     * actually type into.
     *
     * `guessPositions` being an empty ARRAY means there is nowhere to guess, and the
     * honest answer is no recommendation. Only a missing/undefined value means "the
     * caller did not say, use every open slot". 1.3.1 and the first 2.0 build
     * conflated the two, which is why a row reporting NO OPEN SLOTS still showed a
     * confident TRY chip for a slot that could not be typed into.
     */
    function pickBest(slots, open, guessPositions, trained, sourceOf) {
      if (!trained) return null;
      const usable = Array.isArray(guessPositions) ? new Set(guessPositions) : new Set(open);
      if (!usable.size) return null;

      let best = null;
      for (const slot of slots) {
        if (!usable.has(slot.position)) continue;
        const top = slot.letters[0];
        if (!top) continue;
        if (!best || top.p > best.p) {
          best = {
            position: slot.position,
            char: top.char,
            p: top.p,
            source: sourceOf(slot, top),
          };
        }
      }
      return best;
    }

    /* ------------------------------------------------------------- decisions */

    /**
     * Probability of resolving every currently typeable slot before the remaining
     * miss allowance is exhausted.
     *
     * A truth at rank k needs k misses followed by the correct guess. With G misses
     * left, the total number of misses across the open slots must therefore be less
     * than G: the Gth miss locks guessing before another correct character can be
     * entered. Slot marginals are treated as independent here. Live play updates
     * them after every result, so this deliberately stays a small, inspectable
     * approximation rather than pretending to be a full password POMDP.
     */
    function openClearChance(slots, guessesLeft) {
      const usable = (slots || []).filter(slot => slot && slot.letters && slot.letters.length);
      if (!usable.length) return 1;
      if (!Number.isFinite(guessesLeft)) return null;

      const budget = Math.max(0, Math.floor(guessesLeft));
      if (budget === 0) return 0;
      const maxMisses = budget - 1;
      let missDist = new Float64Array(maxMisses + 1);
      missDist[0] = 1;

      for (const slot of usable) {
        let total = 0;
        for (const letter of slot.letters) total += Math.max(0, Number(letter.p) || 0);
        if (total <= 0) return 0;

        const next = new Float64Array(maxMisses + 1);
        for (let already = 0; already <= maxMisses; already++) {
          if (missDist[already] <= 0) continue;
          const room = maxMisses - already;
          for (let rank = 0; rank <= room && rank < slot.letters.length; rank++) {
            const p = Math.max(0, Number(slot.letters[rank].p) || 0) / total;
            next[already + rank] += missDist[already] * p;
          }
        }
        missDist = next;
      }

      let chance = 0;
      for (const p of missDist) chance += p;
      return Math.max(0, Math.min(1, chance));
    }

    /**
     * Expected brute-force actions needed to finish a number of work cycles.
     *
     * Torn stochastically rounds fractional brute-force strength on every action:
     * strength 2.4 performs two cycles plus a 40% chance of a third. This
     * dynamic program keeps that fractional value instead of rounding the user's
     * rig down or pretending that every action always performs the mean exactly.
     */
    function expectedBruteActions(workCycles, bruteStrength) {
      const work = Number.isFinite(workCycles) ? Math.max(0, Math.floor(workCycles)) : 0;
      if (work === 0) return 0;

      let strength = Number(bruteStrength);
      if (!Number.isFinite(strength) || strength <= 0) strength = 1;
      const guaranteed = Math.floor(strength);
      const extraP = Math.max(0, Math.min(1, strength - guaranteed));

      // Below one cycle, an action is a Bernoulli trial. Each required success
      // therefore takes 1/p actions on average.
      if (guaranteed === 0) return work / extraP;

      const expected = new Float64Array(work + 1);
      for (let remaining = 1; remaining <= work; remaining++) {
        const afterGuaranteed = Math.max(0, remaining - guaranteed);
        if (extraP === 0 || afterGuaranteed === 0) {
          expected[remaining] = 1 + expected[afterGuaranteed];
          continue;
        }
        const afterExtra = Math.max(0, remaining - guaranteed - 1);
        expected[remaining] = 1
          + (1 - extraP) * expected[afterGuaranteed]
          + extraP * expected[afterExtra];
      }
      return expected[work];
    }

    /**
     * Whole-row action policy for Torn's actual cost model.
     *
     * Manual guesses cost no nerve. Three misses only lock further guessing; they
     * do not remove brute force. Consequently the highest-P(hit) guess has free
     * upside and leaves brute force available on a miss, so GUESS dominates
     * BRUTE_FORCE whenever a modelled, typeable guess remains. Brute force is the
     * action only when guessing is locked or every hidden slot is encrypted.
     */
    function decisionPolicy(opts) {
      opts = opts || {};
      const positions = Array.isArray(opts.guessPositions)
        ? new Set(opts.guessPositions.map(Number))
        : null;
      const usable = (opts.slots || []).filter(slot => (
        slot && slot.letters && slot.letters.length
          && (!positions || positions.has(slot.position))
      ));

      let best = opts.best || null;
      if (!best) {
        for (const slot of usable) {
          const top = slot.letters[0];
          if (!top) continue;
          if (!best || top.p > best.p) {
            best = { position: slot.position, char: top.char, p: top.p };
          }
        }
      }

      const hiddenCharacters = Number.isFinite(opts.hiddenCharacters)
        ? Math.max(0, Math.floor(opts.hiddenCharacters))
        : (opts.slots || []).length;
      const encryptionLayers = Number.isFinite(opts.encryptionLayers)
        ? Math.max(0, Math.floor(opts.encryptionLayers))
        : 0;
      const workCycles = hiddenCharacters + encryptionLayers;
      const guessesKnown = Number.isFinite(opts.guessesLeft);
      const guessesLeft = guessesKnown ? Math.max(0, Math.floor(opts.guessesLeft)) : null;
      const suppliedStrength = Number(opts.bruteStrength ?? opts.bruteCyclesPerAction);
      const bruteStrengthKnown = Number.isFinite(suppliedStrength) && suppliedStrength > 0;
      const bruteStrength = bruteStrengthKnown ? suppliedStrength : 1;
      const bruteNerve = Math.max(0, Number(opts.bruteNerve) || 0);
      const bruteActions = expectedBruteActions(workCycles, bruteStrength);

      let action = 'WAIT';
      let reason = 'No modelled action is available yet.';
      if (workCycles <= 0) {
        action = 'COMPLETE';
        reason = 'No hidden characters or encryption layers remain.';
      } else if ((guessesKnown && guessesLeft === 0) || !usable.length) {
        action = 'BRUTE_FORCE';
        reason = guessesKnown && guessesLeft === 0
          ? 'Guessing is locked; brute force remains available.'
          : 'Every remaining hidden character is encrypted or not typeable.';
      } else if (best) {
        action = 'GUESS';
        reason = 'The guess is free; a hit saves one brute-force cycle and a miss still leaves brute force available.';
      }

      return {
        version: 2,
        action,
        reason,
        best,
        guessesLeft,
        openSlots: usable.length,
        openClearP: openClearChance(usable, guessesLeft),
        nextHitP: best ? Math.max(0, Math.min(1, Number(best.p) || 0)) : null,
        expectedCyclesSaved: action === 'GUESS' && best
          ? Math.max(0, Math.min(1, Number(best.p) || 0))
          : 0,
        hiddenCharacters,
        encryptionLayers,
        workCycles,
        bruteStrength: bruteStrengthKnown ? bruteStrength : null,
        bruteStrengthKnown,
        // Backward-compatible name for exported telemetry written by 2.2.1.
        bruteCyclesPerAction: bruteStrengthKnown ? bruteStrength : null,
        expectedBruteActions: bruteActions,
        bruteActions,
        expectedBruteNerve: bruteActions * bruteNerve,
        bruteNerve: bruteActions * bruteNerve,
      };
    }

    /* ------------------------------------------------------------- indexing */

    /**
     * Parse a wordlist into packed buckets. Rank = line order, which for the
     * base list is genuine popularity order and is now stored rather than
     * inferred from where the word happens to sit in an array.
     */
    function indexWordlist(text, opts) {
      const minLen = opts.minLen, maxLen = opts.maxLen;
      const src = opts.src === undefined ? SRC_BASE : opts.src;
      const buckets = opts.buckets || {};
      const seen = opts.seen || {};
      let processed = 0, added = 0;
      let rank = opts.startRank || 0;

      const lines = String(text || '').split(/\r?\n/);
      for (const raw of lines) {
        processed++;
        const word = raw.trim().toUpperCase();
        if (!word) continue;
        const len = word.length;
        if (len < minLen || len > maxLen) continue;
        const codes = encodeWord(word);
        if (!codes) continue;

        if (!seen[len]) seen[len] = new Set();
        if (seen[len].has(word)) continue;
        seen[len].add(word);

        if (!buckets[len]) buckets[len] = makeBucket(len);
        bucketAdd(buckets[len], codes, src === SRC_BASE ? rank : 0, src);
        rank++;
        added++;
      }
      return { buckets, seen, processed, added, endRank: rank };
    }

    /* ------------------------------------------------------------- telemetry */

    /**
     * Reliability curve: bucket predictions by stated probability and compare to
     * the observed hit rate. If the model is honest these should track. This is
     * the check that would have flagged the "100% but wrong" problem
     * automatically instead of it being noticed by hand months later.
     */
    function calibrationReport(guesses, bins) {
      bins = bins || 10;
      const out = [];
      for (let b = 0; b < bins; b++) out.push({ lo: b / bins, hi: (b + 1) / bins, n: 0, hits: 0 });
      for (const g of guesses || []) {
        const p = Math.max(0, Math.min(0.999999, Number(g.p) || 0));
        const b = out[Math.floor(p * bins)];
        b.n++;
        if (g.hit) b.hits++;
        b.sumP = (b.sumP || 0) + p;
      }
      return out.map(b => ({
        range: [b.lo, b.hi],
        n: b.n,
        predicted: b.n ? (b.sumP || 0) / b.n : 0,
        actual: b.n ? b.hits / b.n : 0,
      }));
    }

    /** Measured dictionary coverage per (length, level bucket). Feeds coveragePrior. */
    function coverageReport(cracks, levelBucket) {
      const bucketOf = levelBucket || (lv => (Number.isFinite(lv) ? Math.floor(lv / 10) * 10 : 'na'));
      const cells = {};
      for (const c of cracks || []) {
        const key = `${c.len}|${bucketOf(c.level)}`;
        if (!cells[key]) cells[key] = { len: c.len, level: bucketOf(c.level), n: 0, hits: 0 };
        cells[key].n++;
        if (c.wasInDict) cells[key].hits++;
      }
      return cells;
    }

    /** Distinct vs total observations, and the repeat histogram. */
    function repeatReport(obs) {
      const counts = Object.values(obs || {}).map(o => (typeof o === 'number' ? o : o.n) || 0);
      const total = counts.reduce((a, b) => a + b, 0);
      const hist = {};
      for (const n of counts) hist[n] = (hist[n] || 0) + 1;
      return {
        distinct: counts.length,
        total,
        seenMoreThanOnce: counts.filter(n => n > 1).length,
        avgPerWord: counts.length ? total / counts.length : 0,
        hist,
      };
    }

    return {
      ALPHABET, A, WILD, codeOfChar, encodeWord, decodeCodes,
      SRC_BASE, SRC_POOL, SRC_LOCAL, DEFAULT_PARAMS, mergeParams, priorOf,
      makeBucket, bucketAdd, bucketFind, bucketWord, bucketExport, bucketImport,
      buildModels, backoffDist, affixVote, coveragePrior, buildAllow, query,
      openClearChance, expectedBruteActions, decisionPolicy, indexWordlist, calibrationReport, coverageReport,
      repeatReport,
    };
  }
  /* __CRACK_CORE_FACTORY_END__ */

  /* ==================================================================== */
  /* Engine — owns the packed dictionary, the character models, the        */
  /* observation counts, and their IndexedDB persistence.                  */
  /*                                                                       */
  /* Written as a factory taking the core as an argument so that the exact  */
  /* same function can run in two places: inside a Worker (assembled with   */
  /* toString(), see makeWorkerSource) or directly on this thread when a    */
  /* Worker is unavailable. One implementation, no duplicated logic.        */
  /* ==================================================================== */

  function CrackEngineFactory(Core, config, onNote) {
    var DB_NAME = config.dbName;
    var DB_VERSION = config.dbVersion;
    var STORE = config.store;
    var MIN_LENGTH = config.minLength;
    var MAX_LENGTH = config.maxLength;

    var SRC_BASE = Core.SRC_BASE;
    var SRC_POOL = Core.SRC_POOL;
    var SRC_LOCAL = Core.SRC_LOCAL;

    /* ---------------------------------------------------------------- state */

    var buckets = {};          // length -> packed bucket
    var models = null;         // character models, rebuilt lazily
    var modelsDirty = true;
    var observations = {};     // word -> { n, len, idx }
    var contextObs = {};       // contextKey -> { word -> count }
    var poolContextObs = {};   // contextKey -> { word -> shared support record }
    var globalObs = {};        // length -> Float32Array aligned to the bucket
    var calibration = {};      // "len|levelBucket" -> { n, hits }
    var params = Core.mergeParams(null);
    var revision = 0;

    function note(msg) {
      if (typeof onNote === 'function') onNote(msg);
    }

    function engineDuration(ms) {
      var seconds = Math.max(0, Math.floor(ms / 1000));
      var minutes = Math.floor(seconds / 60);
      var rest = seconds % 60;
      return minutes > 0 ? minutes + 'm ' + rest + 's' : rest + 's';
    }

    function engineProgress(label, completed, total, startedAt) {
      var elapsed = Math.max(0, Date.now() - startedAt);
      var percent = total > 0 ? Math.min(100, Math.floor(100 * completed / total)) : 0;
      var remaining = 'time left: estimating…';
      if (completed > 0 && total > completed && elapsed > 0) {
        var estimate = Math.max(1000, elapsed * (total - completed) / completed);
        remaining = '~' + engineDuration(estimate) + ' left';
      } else if (total > 0 && completed >= total) {
        remaining = '0s left';
      }
      return label + ' · ' + percent + '% · elapsed ' + engineDuration(elapsed) + ' · ' + remaining;
    }

    /* ------------------------------------------------------------ IndexedDB */

    var dbPromise = null;

    function openDatabase() {
      if (dbPromise) return dbPromise;
      dbPromise = new Promise(function (resolve, reject) {
        var request = indexedDB.open(DB_NAME, DB_VERSION);
        request.onupgradeneeded = function () {
          var db = request.result;
          if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
        };
        request.onsuccess = function () { resolve(request.result); };
        request.onerror = function () { reject(request.error); };
      });
      return dbPromise;
    }

    function dbGet(key) {
      return openDatabase().then(function (db) {
        return new Promise(function (resolve, reject) {
          var req = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
          req.onsuccess = function () { resolve(req.result); };
          req.onerror = function () { reject(req.error); };
        });
      });
    }

    function dbPut(key, value) {
      return openDatabase().then(function (db) {
        return new Promise(function (resolve, reject) {
          var tx = db.transaction(STORE, 'readwrite');
          tx.objectStore(STORE).put(value, key);
          tx.oncomplete = function () { resolve(); };
          tx.onerror = function () { reject(tx.error); };
        });
      });
    }

    function dbDelete(key) {
      return openDatabase().then(function (db) {
        return new Promise(function (resolve, reject) {
          var tx = db.transaction(STORE, 'readwrite');
          tx.objectStore(STORE).delete(key);
          tx.oncomplete = function () { resolve(); };
          tx.onerror = function () { reject(tx.error); };
        });
      });
    }

    /* --------------------------------------------------------- bucket index */

    /* Membership checks are deliberately linear over packed bytes. A lookup
     * runs only for a completed crack or a small community snapshot, while a
     * permanent Map would retain ~868k decoded JS strings and is enough to
     * exhaust a mobile userscript process. */
    function findIndex(word, knownCodes) {
      var bucket = buckets[word.length];
      if (!bucket) return -1;
      var codes = knownCodes || Core.encodeWord(word);
      return codes ? Core.bucketFind(bucket, codes) : -1;
    }

    function ensureObsArray(len) {
      var bucket = buckets[len];
      if (!bucket) return;
      var existing = globalObs[len];
      if (!existing || existing.length < bucket.cap) {
        var grown = new Float32Array(bucket.cap);
        if (existing) grown.set(existing);
        globalObs[len] = grown;
      }
    }

    /* Rebuild the aligned observation arrays from the durable word->count map.
     * Indices are validated because a bucket rebuild could shift them. */
    function rebuildObsArrays() {
      globalObs = {};
      var len;
      for (len in buckets) globalObs[len] = new Float32Array(buckets[len].cap);

      for (var word in observations) {
        var rec = observations[word];
        var bucket = buckets[rec.len];
        var arr = globalObs[rec.len];
        if (!bucket || !arr) continue;
        var idx = rec.idx;
        if (idx == null || idx < 0 || idx >= bucket.count || Core.bucketWord(bucket, idx) !== word) {
          idx = findIndex(word);
          rec.idx = idx;
        }
        if (idx >= 0) arr[idx] += rec.n;
      }
    }

    function ensureModels(force) {
      if (models && !modelsDirty && !force) return;
      var startedAt = Date.now();
      models = Core.buildModels(buckets, globalObs, params, function (completed, total) {
        note(engineProgress('Building character model (first run)', completed, total, startedAt));
      });
      modelsDirty = false;
    }

    /* --------------------------------------------------------- persistence */

    function savePack(len) {
      if (!buckets[len]) return Promise.resolve();
      return dbPut('pack_' + len, Core.bucketExport(buckets[len]));
    }

    /** Commit a set of changed buckets together. If the tab is refreshed
     * during the write, IndexedDB aborts the transaction instead of leaving a
     * half-updated community snapshot for the next startup. */
    function savePacks(lengths) {
      var listed = Array.from(new Set((lengths || []).map(Number)))
        .filter(function (len) { return buckets[len]; });
      if (!listed.length) return Promise.resolve();
      return openDatabase().then(function (db) {
        return new Promise(function (resolve, reject) {
          var tx = db.transaction(STORE, 'readwrite');
          var store = tx.objectStore(STORE);
          for (var i = 0; i < listed.length; i++) {
            var len = listed[i];
            store.put(Core.bucketExport(buckets[len]), 'pack_' + len);
          }
          tx.oncomplete = function () { resolve(); };
          tx.onerror = function () { reject(tx.error); };
          tx.onabort = function () { reject(tx.error || new Error('dictionary save aborted')); };
        });
      });
    }

    function saveAllPacks() {
      var pending = [];
      for (var len in buckets) pending.push(savePack(len));
      return Promise.all(pending);
    }

    function replaceAllPacks() {
      return openDatabase().then(function (db) {
        return new Promise(function (resolve, reject) {
          var tx = db.transaction(STORE, 'readwrite');
          var store = tx.objectStore(STORE);
          for (var len = MIN_LENGTH; len <= MAX_LENGTH; len++) {
            store.delete('pack_' + len);
          }
          for (var key in buckets) {
            store.put(Core.bucketExport(buckets[key]), 'pack_' + key);
          }
          tx.oncomplete = function () { resolve(); };
          tx.onerror = function () { reject(tx.error); };
          tx.onabort = function () { reject(tx.error || new Error('dictionary save aborted')); };
        });
      });
    }

    function saveObservations() {
      return Promise.all([dbPut('obs', observations), dbPut('ctxObs', contextObs)]);
    }

    function savePoolContexts() {
      return dbPut('poolCtxObs', poolContextObs);
    }

    /* ------------------------------------------------------ adding words */

    /* Upgrade in place when the word is already known. Migration cannot tell a
     * community-pool word from a base-list word, so a later sync has to be able
     * to repair the tier rather than skip the word as a duplicate. */
    function addOrUpgrade(word, src) {
      var len = word.length;
      if (len < MIN_LENGTH || len > MAX_LENGTH) return { added: false, idx: -1 };
      var codes = Core.encodeWord(word);
      if (!codes) return { added: false, idx: -1 };

      if (!buckets[len]) buckets[len] = Core.makeBucket(len);
      var bucket = buckets[len];
      var idx = Core.bucketFind(bucket, codes);

      if (idx < 0) {
        idx = Core.bucketAdd(bucket, codes, 0, src);
        ensureObsArray(len);
        revision++;
        return { added: true, idx: idx };
      }

      if (src > bucket.src[idx]) {
        bucket.src[idx] = src;
        bucket.rank[idx] = 0;
        revision++;
      }
      return { added: false, idx: idx };
    }

    function hashCodes(codes, offset, len) {
      var hash = 2166136261;
      for (var i = 0; i < len; i++) {
        hash ^= codes[offset + i];
        hash = Math.imul(hash, 16777619);
      }
      return hash >>> 0;
    }

    /**
     * Merge a small remote word set into the large packed base dictionary.
     *
     * The former code called bucketFind once (and context sync twice) per
     * community word. With ~1,400 remote words that meant hundreds of complete
     * base-bucket scans. Here the remote words form a tiny hash table and each
     * affected packed bucket is scanned exactly once, without retaining a Map
     * of the 868k-word base list.
     */
    function mergePoolTargets(targets) {
      var byLength = {};
      var unique = Object.create(null);
      for (var i = 0; i < targets.length; i++) {
        var target = targets[i];
        if (!target || !target.codes) continue;
        var word = String(target.word || '');
        var len = word.length;
        var uniqueKey = len + '|' + word;
        if (unique[uniqueKey]) continue;
        unique[uniqueKey] = true;
        target.found = false;
        if (!byLength[len]) byLength[len] = [];
        byLength[len].push(target);
      }

      var added = 0;
      var updated = 0;
      var changedLengths = {};
      var lengths = Object.keys(byLength).map(Number).sort(function (a, b) { return a - b; });
      var totalScan = 0;
      var completedScan = 0;
      var scanStartedAt = Date.now();
      for (var wi = 0; wi < lengths.length; wi++) {
        var workBucket = buckets[lengths[wi]];
        if (workBucket) totalScan += workBucket.count;
      }

      for (var li = 0; li < lengths.length; li++) {
        var length = lengths[li];
        var listed = byLength[length];
        if (!buckets[length]) buckets[length] = Core.makeBucket(length);
        var bucket = buckets[length];
        var hashes = new Map();

        for (var ti = 0; ti < listed.length; ti++) {
          var targetHash = hashCodes(listed[ti].codes, 0, length);
          var collision = hashes.get(targetHash);
          if (!collision) { collision = []; hashes.set(targetHash, collision); }
          collision.push(listed[ti]);
        }

        var progressLabel = 'Indexing community pool (' + (li + 1) + '/' + lengths.length + ')';
        note(engineProgress(progressLabel, completedScan, totalScan, scanStartedAt));
        var nextProgress = 25000;
        for (var bi = 0; bi < bucket.count; bi++) {
          if (bi >= nextProgress) {
            note(engineProgress(progressLabel, completedScan + bi, totalScan, scanStartedAt));
            nextProgress += 25000;
          }
          var offset = bi * length;
          var candidates = hashes.get(hashCodes(bucket.codes, offset, length));
          if (!candidates) continue;

          for (var ci = 0; ci < candidates.length; ci++) {
            var candidate = candidates[ci];
            if (candidate.found) continue;
            var same = true;
            for (var p = 0; p < length; p++) {
              if (bucket.codes[offset + p] !== candidate.codes[p]) { same = false; break; }
            }
            if (!same) continue;
            candidate.found = true;
            if (bucket.src[bi] < SRC_POOL) {
              bucket.src[bi] = SRC_POOL;
              bucket.rank[bi] = 0;
              updated++;
              changedLengths[length] = true;
            }
            break;
          }
        }
        completedScan += bucket.count;

        for (ti = 0; ti < listed.length; ti++) {
          if (listed[ti].found) continue;
          Core.bucketAdd(bucket, listed[ti].codes, 0, SRC_POOL);
          added++;
          changedLengths[length] = true;
        }
        ensureObsArray(length);
      }
      if (totalScan > 0) {
        note(engineProgress('Indexing community pool', completedScan, totalScan, scanStartedAt));
      }

      if (added || updated) revision += added + updated;
      return { added: added, updated: updated, changedLengths: changedLengths };
    }

    function indexPoolText(text) {
      var lines = String(text || '').split(/\r?\n/);
      var targets = [];
      var seen = Object.create(null);
      for (var i = 0; i < lines.length; i++) {
        var word = lines[i].trim().toUpperCase();
        if (!word || seen[word]) continue;
        seen[word] = true;
        if (word.length < MIN_LENGTH || word.length > MAX_LENGTH) continue;
        var codes = Core.encodeWord(word);
        if (!codes) continue;
        targets.push({ word: word, codes: codes });
      }

      var merged = mergePoolTargets(targets);
      if (!models) modelsDirty = true;
      return savePacks(Object.keys(merged.changedLengths)).then(function () {
        return { added: merged.added, updated: merged.updated };
      });
    }

    /* Parse a wordlist. For the base list, line order is genuine popularity
     * order, so it is stored as the rank rather than inferred later from where
     * the word happens to sit in an array. */
    function indexText(text, src) {
      if (src === SRC_POOL) return indexPoolText(text);
      var lines = String(text || '').split(/\r?\n/);
      var rank = 0;
      var added = 0;
      var updated = 0;

      for (var i = 0; i < lines.length; i++) {
        var word = lines[i].trim().toUpperCase();
        if (!word) continue;
        var len = word.length;
        if (len < MIN_LENGTH || len > MAX_LENGTH) continue;
        var codes = Core.encodeWord(word);
        if (!codes) continue;

        if (!buckets[len]) buckets[len] = Core.makeBucket(len);
        var bucket = buckets[len];
        var known = Core.bucketFind(bucket, codes);

        if (known >= 0) {
          if (src > bucket.src[known]) {
            bucket.src[known] = src;
            bucket.rank[known] = 0;
            updated++;
          }
          rank++;
          continue;
        }

        var idx = Core.bucketAdd(bucket, codes, src === SRC_BASE ? rank : 0, src);
        rank++;
        added++;
      }

      for (var key in buckets) ensureObsArray(key);
      revision++;
      if (src === SRC_BASE || !models) modelsDirty = true;
      rebuildObsArrays();

      return saveAllPacks().then(function () { return { added: added, updated: updated }; });
    }

    /* Replace only the generic source-tier-0 dictionary. Community-confirmed
     * and locally observed words survive, along with every telemetry map. */
    function replaceBase(text) {
      var retained = {};
      var len;

      for (len in buckets) {
        var oldBucket = buckets[len];
        for (var i = 0; i < oldBucket.count; i++) {
          var src = oldBucket.src[i];
          if (src > SRC_BASE) {
            var word = Core.bucketWord(oldBucket, i);
            if (!retained[len]) retained[len] = [];
            retained[len].push({ word: word, src: src });
          }
        }
      }

      buckets = {};
      globalObs = {};

      for (len in retained) {
        for (var j = 0; j < retained[len].length; j++) {
          addOrUpgrade(retained[len][j].word, retained[len][j].src);
        }
      }

      rebuildObsArrays();
      var deletes = [];
      for (len = MIN_LENGTH; len <= MAX_LENGTH; len++) deletes.push(dbDelete('pack_' + len));
      return Promise.all(deletes).then(function () { return indexText(text, SRC_BASE); });
    }

    /* A mobile install must not materialise the complete base snapshot as one
     * response string. The host calls these three operations with bounded text
     * chunks. Persistence is committed only after every chunk validates, so a
     * failed attempt cannot replace a previously working on-disk dictionary. */
    var baseLoad = null;

    function beginBaseReplace() {
      var retained = [];
      for (var len in buckets) {
        var oldBucket = buckets[len];
        for (var i = 0; i < oldBucket.count; i++) {
          var src = oldBucket.src[i];
          if (src > SRC_BASE) {
            var word = Core.bucketWord(oldBucket, i);
            retained.push({
              word: word,
              src: src,
            });
          }
        }
      }

      buckets = {};
      globalObs = {};
      var retainedSeen = {};
      for (var r = 0; r < retained.length; r++) {
        var rec = retained[r];
        var codes = Core.encodeWord(rec.word);
        if (!codes) continue;
        var recLen = rec.word.length;
        if (!buckets[recLen]) buckets[recLen] = Core.makeBucket(recLen);
        if (!retainedSeen[recLen]) retainedSeen[recLen] = new Map();
        if (!retainedSeen[recLen].has(rec.word)) {
          var retainedIdx = Core.bucketAdd(buckets[recLen], codes, 0, rec.src);
          retainedSeen[recLen].set(rec.word, retainedIdx);
        }
      }

      baseLoad = {
        nextRank: 0,
        added: 0,
        retained: retained.length,
        retainedSeen: retainedSeen,
      };
      modelsDirty = true;
      return Promise.resolve({ retained: retained.length, nextRank: 0 });
    }

    function appendBaseChunk(text, startRank) {
      if (!baseLoad) return Promise.reject(new Error('base install was not started'));
      startRank = Number(startRank);
      if (!isFinite(startRank) || startRank !== baseLoad.nextRank) {
        return Promise.reject(new Error('base chunk rank mismatch'));
      }

      var lines = String(text || '').split(/\r?\n/);
      var valid = 0;
      var added = 0;
      for (var i = 0; i < lines.length; i++) {
        var word = lines[i].trim().toUpperCase();
        if (!word) continue;
        var len = word.length;
        if (len < MIN_LENGTH || len > MAX_LENGTH) {
          return Promise.reject(new Error('invalid base word length'));
        }
        var codes = Core.encodeWord(word);
        if (!codes) return Promise.reject(new Error('invalid base word'));
        if (!buckets[len]) buckets[len] = Core.makeBucket(len);
        var bucket = buckets[len];

        // The published base snapshot is already de-duplicated. The only
        // expected match is a retained pool/local word, whose stronger tier
        // must survive the base replacement.
        var retainedMap = baseLoad.retainedSeen[len];
        if (!retainedMap || !retainedMap.has(word)) {
          Core.bucketAdd(bucket, codes, startRank + valid, SRC_BASE);
          added++;
        }
        valid++;
      }

      baseLoad.nextRank += valid;
      baseLoad.added += added;
      return Promise.resolve({ valid: valid, added: added, nextRank: baseLoad.nextRank });
    }

    function finishBaseReplace(expectedCount) {
      if (!baseLoad) return Promise.reject(new Error('base install was not started'));
      expectedCount = Number(expectedCount);
      if (!isFinite(expectedCount) || baseLoad.nextRank !== expectedCount) {
        return Promise.reject(new Error('base word count mismatch'));
      }

      var result = { added: baseLoad.added, updated: 0 };
      baseLoad = null;
      rebuildObsArrays();
      revision++;
      modelsDirty = true;
      return replaceAllPacks().then(function () {
        return Object.assign(result, stats());
      });
    }

    function recordObservation(word, contextKey) {
      var len = word.length;
      var result = addOrUpgrade(word, SRC_LOCAL);
      if (result.idx < 0) return Promise.resolve({ n: 0 });

      var rec = observations[word] || { n: 0, len: len, idx: result.idx };
      rec.n++;
      rec.len = len;
      rec.idx = result.idx;
      observations[word] = rec;

      ensureObsArray(len);
      if (globalObs[len]) globalObs[len][result.idx] = rec.n;

      if (contextKey) {
        if (!contextObs[contextKey]) contextObs[contextKey] = {};
        contextObs[contextKey][word] = (contextObs[contextKey][word] || 0) + 1;
      }

      return Promise.all([savePack(len), saveObservations()]).then(function () {
        return { n: rec.n, repeat: Core.repeatReport(observations) };
      });
    }

    function normalizeContextPart(value) {
      return String(value || '').trim().replace(/\s+/g, ' ').toUpperCase().slice(0, 80);
    }

    /* Replace the complete remote association snapshot. Remote associations
     * are kept separate from this browser's own sightings and each gets the
     * same fixed weight, so duplicate reports cannot inflate a suggestion. */
    function replacePoolContexts(associations) {
      var next = {};
      var poolTargets = [];
      var poolTargetSeen = Object.create(null);
      var imported = 0;
      var listed = Array.isArray(associations) ? associations : [];

      for (var i = 0; i < listed.length; i++) {
        var item = listed[i] || {};
        var word = String(item.word || '').trim().toUpperCase();
        var codes = Core.encodeWord(word);
        if (!codes || word.length < MIN_LENGTH || word.length > MAX_LENGTH) continue;
        var title = normalizeContextPart(item.title);
        var service = normalizeContextPart(item.service);
        if (!title && !service) continue;
        var support = 1;
        var levelMin = Number(item.level_min);
        var levelMax = Number(item.level_max);
        if (!isFinite(levelMin) || levelMin < 1 || levelMin > 100) levelMin = null;
        if (!isFinite(levelMax) || levelMax < 1 || levelMax > 100) levelMax = null;

        if (!poolTargetSeen[word]) {
          poolTargetSeen[word] = true;
          poolTargets.push({ word: word, codes: codes });
        }

        var contextKey = title + '||' + service;
        if (!next[contextKey]) next[contextKey] = {};
        var previous = next[contextKey][word];
        if (!previous || support > previous.support) {
          next[contextKey][word] = {
            support: support,
            levelMin: levelMin,
            levelMax: levelMax,
          };
        }
        imported++;
      }

      poolContextObs = next;
      var merged = mergePoolTargets(poolTargets);
      return Promise.all([
        savePoolContexts(),
        savePacks(Object.keys(merged.changedLengths)),
      ]).then(function () {
        return { imported: imported, contexts: Object.keys(poolContextObs).length };
      });
    }

    function lookup(word) {
      var len = word.length;
      if (!buckets[len]) return { found: false };
      var idx = findIndex(word);
      if (idx < 0) return { found: false };
      return {
        found: true,
        src: buckets[len].src[idx],
        rank: buckets[len].rank[idx],
        obs: (observations[word] && observations[word].n) || 0,
      };
    }

    /* ------------------------------------------------- migration from 1.3.1 */

    /* Converts the old string arrays into packed buckets in place, so the ~869k
     * words already cached are not downloaded again. Every locally observed
     * word is promoted to the LOCAL tier at the same time, which lifts every
     * password this client has cracked out of the bottom of the rank order. */
    function migrateLegacy() {
      return dbGet('migrated_v2').then(function (alreadyDone) {
        if (alreadyDone) return false;
        note('Migrating cached dictionary…');

        var chain = Promise.resolve();
        var foundAny = false;

        for (var len = 3; len <= 24; len++) {
          chain = chain.then(makeLegacyStep(len));
        }

        function makeLegacyStep(len) {
          return function () {
            return dbGet('len_' + len).then(function (arr) {
              if (!arr || !arr.length) return null;
              foundAny = true;
              if (!buckets[len]) buckets[len] = Core.makeBucket(len, arr.length);
              var bucket = buckets[len];
              var seen = new Set();
              for (var i = 0; i < arr.length; i++) {
                var word = String(arr[i] || '').toUpperCase();
                if (word.length !== len || seen.has(word)) continue;
                var codes = Core.encodeWord(word);
                if (!codes) continue;
                seen.add(word);
                Core.bucketAdd(bucket, codes, i, SRC_BASE);
              }
              return dbDelete('len_' + len);
            });
          };
        }

        return chain
          .then(function () {
            return Promise.all([dbGet('word_observations'), dbGet('context_word_observations')]);
          })
          .then(function (legacy) {
            var words = legacy[0] || {};
            contextObs = legacy[1] || {};

            for (var word in words) {
              var n = Number(words[word]) || 0;
              if (n <= 0) continue;
              var len = word.length;
              if (len < MIN_LENGTH || len > MAX_LENGTH) continue;

              var codes = Core.encodeWord(word);
              if (!codes) continue;
              var idx = buckets[len] ? Core.bucketFind(buckets[len], codes) : -1;
              if (idx < 0) {
                if (!buckets[len]) buckets[len] = Core.makeBucket(len);
                idx = Core.bucketAdd(buckets[len], codes, 0, SRC_LOCAL);
              } else {
                buckets[len].src[idx] = SRC_LOCAL;
                buckets[len].rank[idx] = 0;
              }
              observations[word] = { n: n, len: len, idx: idx };
            }

            return Promise.all([
              dbDelete('word_observations'),
              dbDelete('context_word_observations'),
            ]);
          })
          .then(saveAllPacks)
          .then(saveObservations)
          .then(function () { return dbPut('migrated_v2', true); })
          .then(function () { return foundAny; });
      });
    }

    /* ------------------------------------------------------------ loading */

    function loadEverything() {
      return migrateLegacy()
        .then(function () {
          var chain = Promise.resolve();
          for (var len = MIN_LENGTH; len <= MAX_LENGTH; len++) {
            chain = chain.then(makeLoadStep(len));
          }
          return chain;
        })
        .then(function () {
          return Promise.all([dbGet('obs'), dbGet('ctxObs'), dbGet('poolCtxObs'), dbGet('calib')]);
        })
        .then(function (stored) {
          if (stored[0]) observations = stored[0];
          if (stored[1]) contextObs = stored[1];
          if (stored[2]) poolContextObs = stored[2];
          if (stored[3]) calibration = stored[3];
          rebuildObsArrays();
          modelsDirty = true;
          return stats();
        });

      function makeLoadStep(len) {
        return function () {
          if (buckets[len]) return null;
          return dbGet('pack_' + len).then(function (rec) {
            if (rec && rec.count) buckets[len] = Core.bucketImport(rec);
          });
        };
      }
    }

    function stats() {
      var perLength = {};
      var total = 0;
      for (var len in buckets) {
        perLength[len] = buckets[len].count;
        total += buckets[len].count;
      }
      return {
        perLength: perLength,
        total: total,
        repeat: Core.repeatReport(observations),
        contexts: Object.keys(contextObs).length,
        poolContexts: Object.keys(poolContextObs).length,
        revision: revision,
      };
    }

    /* ------------------------------------------------------------- querying */

    function levelBucketOf(level) {
      return (typeof level === 'number' && isFinite(level)) ? Math.floor(level / 10) * 10 : 'na';
    }

    function runQuery(request) {
      var patternText = String(request.pattern || '');
      var len = patternText.length;
      if (len < MIN_LENGTH || len > MAX_LENGTH) return { unsupported: true, len: len };

      ensureModels();

      var pattern = new Uint8Array(len);
      for (var i = 0; i < len; i++) {
        pattern[i] = patternText[i] === '*' ? Core.WILD : Core.codeOfChar(patternText[i]);
      }

      var exclusions = [];
      for (var p = 0; p < len; p++) {
        var set = new Set();
        var listed = (request.exclusions && request.exclusions[p]) || [];
        for (var k = 0; k < listed.length; k++) {
          var code = Core.codeOfChar(listed[k]);
          if (code !== 255) set.add(code);
        }
        exclusions[p] = set;
      }

      var allow = Core.buildAllow(pattern, exclusions, params);
      var bucket = buckets[len] || null;

      /* Contextual counts are sparse, so they travel as a small index->count
       * map rather than an array the size of the bucket. */
      var contextMap = null;
      var counts = request.contextKey ? contextObs[request.contextKey] : null;
      var poolCounts = request.contextKey ? poolContextObs[request.contextKey] : null;
      if ((counts || poolCounts) && bucket) {
        contextMap = new Map();
        if (counts) {
          for (var localWord in counts) {
            var localIdx = findIndex(localWord);
            if (localIdx >= 0) contextMap.set(localIdx, counts[localWord]);
          }
        }
        if (poolCounts) {
          for (var poolWord in poolCounts) {
            var poolIdx = findIndex(poolWord);
            if (poolIdx < 0) continue;
            // Every unique remote association contributes the same cautious
            // half-sighting, regardless of how often it was submitted.
            var pseudoCount = 0.5;
            contextMap.set(poolIdx, (contextMap.get(poolIdx) || 0) + pseudoCount);
          }
        }
      }

      var cell = calibration[len + '|' + levelBucketOf(request.level)] || null;

      var result = Core.query({
        bucket: bucket,
        buckets: buckets,
        models: models,
        pat: pattern,
        allow: allow,
        gobs: globalObs[len] || null,
        ctxObs: contextMap,
        guessPositions: request.guessPositions || [],
        level: request.level,
        calib: cell,
        params: params,
        maxSug: request.maxSug || 8,
      });

      result.policy = Core.decisionPolicy({
        slots: result.slots,
        best: result.best,
        guessPositions: request.guessPositions || [],
        guessesLeft: request.guessesLeft,
        hiddenCharacters: request.hiddenCharacters,
        encryptionLayers: request.encryptionLayers,
        bruteStrength: request.bruteStrength,
        bruteNerve: params.BRUTE_NERVE,
      });

      result.revision = revision;
      result.dictSize = bucket ? bucket.count : 0;
      return result;
    }

    /* -------------------------------------------------------------- handler */

    function handle(message) {
      switch (message.type) {
        case 'init':
          return loadEverything();

        case 'indexText':
          return indexText(message.text, message.src).then(function (r) {
            return Object.assign(r, stats());
          });

        case 'replaceBase':
          return replaceBase(message.text).then(function (r) {
            rebuildObsArrays();
            return Object.assign(r, stats());
          });

        case 'beginBaseReplace':
          return beginBaseReplace();

        case 'appendBaseChunk':
          return appendBaseChunk(message.text, message.startRank);

        case 'finishBaseReplace':
          return finishBaseReplace(message.expectedCount);

        case 'addWord': {
          var word = String(message.word || '').toUpperCase();
          var added = addOrUpgrade(word, message.src);
          return savePack(word.length).then(function () { return added; });
        }

        case 'recordObs':
          return recordObservation(String(message.word || '').toUpperCase(), message.contextKey || '');

        case 'replacePoolContexts':
          return replacePoolContexts(message.associations || []);

        case 'has':
          return Promise.resolve(lookup(String(message.word || '').toUpperCase()));

        case 'query':
          return Promise.resolve(runQuery(message));

        case 'stats':
          return Promise.resolve(stats());

        case 'setParams':
          params = Core.mergeParams(message.params || null);
          modelsDirty = true;
          return Promise.resolve({ ok: true });

        case 'setCalib':
          calibration = message.calib || {};
          return dbPut('calib', calibration).then(function () { return { ok: true }; });

        case 'dumpObs':
          return Promise.resolve({ obs: observations, ctxObs: contextObs, poolCtxObs: poolContextObs });

        case 'resetObs':
          observations = {};
          contextObs = {};
          rebuildObsArrays();
          modelsDirty = true;
          return saveObservations().then(stats);

        default:
          return Promise.reject(new Error('unknown message type: ' + message.type));
      }
    }

    return { handle: handle };
  }

  /* ==================================================================== */
  /* Worker bootstrap                                                     */
  /*                                                                       */
  /* Runs inside the Worker. Stringified with toString() and concatenated   */
  /* with the two factories above to form the Worker source, so the code    */
  /* that runs in the Worker is the same readable code you see here.        */
  /* ==================================================================== */

  function CrackWorkerBootstrap() {
    var config = self.CRACK_CONFIG;
    var core = CrackCoreFactory();
    var engine = CrackEngineFactory(core, config, function (message) {
      self.postMessage({ note: message });
    });

    self.onmessage = function (event) {
      var message = event.data || {};
      Promise.resolve()
        .then(function () { return engine.handle(message); })
        .then(function (data) {
          self.postMessage({ id: message.id, ok: true, data: data });
        })
        .catch(function (error) {
          self.postMessage({
            id: message.id,
            ok: false,
            error: String((error && error.message) || error),
          });
        });
    };
  }

  /* ==================================================================== */
  /* Engine host — a Worker when one can be created, otherwise the same    */
  /* engine on this thread.                                               */
  /* ==================================================================== */

  const ENGINE_CONFIG = {
    dbName: DB_NAME,
    dbVersion: DB_VERSION,
    store: STORE,
    minLength: MIN_LENGTH,
    maxLength: MAX_LENGTH,
  };

  function makeWorkerSource() {
    return [
      'self.CRACK_CONFIG = ' + JSON.stringify(ENGINE_CONFIG) + ';',
      CrackCoreFactory.toString(),
      CrackEngineFactory.toString(),
      CrackWorkerBootstrap.toString(),
      'CrackWorkerBootstrap();',
    ].join('\n\n');
  }

  const Engine = (function () {
    let worker = null;
    let local = null;
    let mode = 'idle';
    let seq = 0;
    const pending = new Map();

    function startWorker() {
      const blob = new Blob([makeWorkerSource()], { type: 'application/javascript' });
      const url = URL.createObjectURL(blob);
      const w = new Worker(url);
      URL.revokeObjectURL(url);

      w.onmessage = (event) => {
        const data = event.data || {};
        if (data.note) { setEngineStatus(data.note); return; }
        const entry = pending.get(data.id);
        if (!entry) return;
        pending.delete(data.id);
        if (data.ok) entry.resolve(data.data);
        else entry.reject(new Error(data.error || 'engine error'));
      };

      w.onerror = (event) => {
        log('worker error, falling back to this thread', event);
        for (const entry of pending.values()) entry.reject(new Error('worker crashed'));
        pending.clear();
        worker = null;
        mode = 'local';
        setStatus('Worker unavailable — running on the page thread');
      };

      return w;
    }

    function startLocal() {
      // A blocked Blob worker used to be a dead end. The engine is written as a
      // plain factory, so the identical code just runs here instead — slower on
      // the initial index, otherwise equivalent.
      return CrackEngineFactory(CrackCoreFactory(), ENGINE_CONFIG, setEngineStatus);
    }

    function call(type, payload) {
      if (mode === 'idle') {
        try {
          worker = startWorker();
          mode = 'worker';
        } catch (e) {
          log('worker unavailable, using the page thread', e);
          mode = 'local';
        }
      }

      if (mode === 'worker' && worker) {
        const id = ++seq;
        const message = Object.assign({ id, type }, payload || {});
        return new Promise((resolve, reject) => {
          pending.set(id, { resolve, reject });
          worker.postMessage(message);
        });
      }

      if (!local) local = startLocal();
      return Promise.resolve()
        .then(() => local.handle(Object.assign({ type }, payload || {})));
    }

    return { call, get mode() { return mode; } };
  })();

  /* ==================================================================== */
  /* Status badge + injected stylesheet                                   */
  /* ==================================================================== */

  let statusEl = null;
  const statusSinks = new Set();
  let lastStatus = 'Idle';
  let statusResetTimer = null;

  /* One stylesheet, injected once. 1.3.1 rewrote dozens of inline styles per
   * row on every tick; here a theme change is a handful of CSS variable writes.
   * Kept as a readable template literal rather than packed strings. */
  const STYLESHEET = `
    :root {
      --crk-bg: #000;
      --crk-fg: #0f0;
      --crk-bd: #0f0;
      --crk-sug-bg: #000;
      --crk-sug-fg: #0f0;
      --crk-box: #111;
      --crk-font: 10px;
    }

    .__crk_panel {
      position: absolute;
      z-index: 9999;
      box-sizing: border-box;
      text-align: center;
      overflow: visible;
      background: transparent;
      color: var(--crk-sug-fg);
      font-size: var(--crk-font);
      border: 0;
      pointer-events: none;
    }

    .__crk_list {
      display: flex;
      align-items: center;
      justify-content: flex-start;
      padding: 0;
    }

    .__crk_launcher {
      min-height: 34px;
      max-width: 190px;
      display: inline-flex;
      align-items: center;
      gap: 7px;
      padding: 6px 9px;
      border: 1px solid var(--crk-bd);
      border-radius: 8px;
      background: var(--crk-sug-bg);
      color: var(--crk-sug-fg);
      box-shadow: 0 2px 8px rgba(0, 0, 0, .45);
      font: 700 11px/1.05 monospace;
      white-space: nowrap;
      cursor: pointer;
      touch-action: manipulation;
      -webkit-tap-highlight-color: transparent;
      pointer-events: auto;
    }

    .__crk_launcher:hover,
    .__crk_launcher:focus-visible {
      outline: 2px solid var(--crk-bd);
      outline-offset: 2px;
    }

    .__crk_launcher[disabled] { cursor: wait; opacity: .72; }
    .__crk_launcher[data-state="locked"] { color: #ffd75a; }
    .__crk_launcher[data-state="complete"] { color: #82c91e; }

    .__crk_launcher_hint {
      opacity: .7;
      font-size: 9px;
    }

    .__crk_guess_ov {
      position: fixed;
      inset: 0;
      z-index: 2147483647;
      display: flex;
      align-items: center;
      justify-content: center;
      box-sizing: border-box;
      padding: 10px;
      background: rgba(0, 0, 0, .72);
      overscroll-behavior: contain;
    }

    .__crk_guess_box {
      width: min(720px, 96vw);
      max-height: calc(100dvh - 20px);
      overflow-x: hidden;
      overflow-y: auto;
      box-sizing: border-box;
      padding: 14px;
      border: 1px solid var(--crk-bd);
      border-radius: 12px;
      background: var(--crk-box);
      color: var(--crk-fg);
      box-shadow: 0 12px 36px rgba(0, 0, 0, .72);
      font-family: monospace;
      -webkit-overflow-scrolling: touch;
      touch-action: pan-y;
    }

    .__crk_guess_head {
      display: flex;
      align-items: flex-start;
      gap: 10px;
      margin-bottom: 10px;
    }

    .__crk_guess_title {
      flex: 1 1 auto;
      min-width: 0;
      font-size: 17px;
      font-weight: 800;
    }

    .__crk_guess_subtitle {
      margin-top: 3px;
      opacity: .75;
      font-size: 11px;
      overflow-wrap: anywhere;
    }

    .__crk_guess_close {
      flex: 0 0 42px;
      width: 42px;
      height: 42px;
      padding: 0;
      border: 1px solid var(--crk-bd);
      border-radius: 9px;
      background: var(--crk-bg);
      color: var(--crk-fg);
      font: 800 22px/1 monospace;
      cursor: pointer;
    }

    .__crk_guess_meta {
      display: flex;
      flex-wrap: wrap;
      gap: 5px;
      margin: 0 0 10px;
    }

    .__crk_guess_badge {
      padding: 4px 7px;
      border: 1px solid var(--crk-bd);
      border-radius: 999px;
      font-size: 10px;
    }

    .__crk_clone {
      --crk-slot-count: 8;
      display: grid;
      grid-template-columns: repeat(var(--crk-slot-count), minmax(0, 1fr));
      gap: 4px;
      margin: 8px 0 12px;
    }

    .__crk_clone_slot {
      position: relative;
      min-width: 0;
      min-height: clamp(58px, 13vw, 82px);
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      box-sizing: border-box;
      padding: 13px 2px 4px;
      border: 1px solid var(--crk-bd);
      border-radius: 7px;
      background: var(--crk-bg);
      color: var(--crk-fg);
      font-family: monospace;
      overflow: hidden;
    }

    button.__crk_clone_slot {
      cursor: pointer;
      touch-action: manipulation;
      -webkit-tap-highlight-color: transparent;
    }

    button.__crk_clone_slot:hover,
    button.__crk_clone_slot:focus-visible {
      outline: 2px solid var(--crk-bd);
      outline-offset: 1px;
    }

    .__crk_clone_slot[data-best="true"] {
      border-width: 2px;
      box-shadow: 0 0 10px var(--crk-bd);
    }

    .__crk_clone_slot[data-state="locked"] { color: #ffd75a; opacity: .86; }
    .__crk_clone_slot[data-state="revealed"] { opacity: .75; }

    .__crk_clone_num {
      position: absolute;
      top: 3px;
      left: 4px;
      font-size: 8px;
      opacity: .65;
    }

    .__crk_clone_char {
      max-width: 100%;
      font-size: clamp(18px, 5vw, 28px);
      font-weight: 900;
      line-height: 1;
    }

    .__crk_clone_pct {
      max-width: 100%;
      margin-top: 3px;
      font-size: clamp(7px, 2.2vw, 10px);
      line-height: 1;
      white-space: nowrap;
    }

    .__crk_guess_help {
      margin: 6px 0 10px;
      font-size: 11px;
      line-height: 1.35;
      opacity: .8;
    }

    .__crk_slot_tabs,
    .__crk_choice_grid,
    .__crk_word_grid,
    .__crk_attempt_grid {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
    }

    .__crk_slot_tab,
    .__crk_choice {
      min-width: 46px;
      min-height: 42px;
      padding: 5px 8px;
      border: 1px solid var(--crk-bd);
      border-radius: 8px;
      background: var(--crk-bg);
      color: var(--crk-fg);
      font-family: monospace;
      cursor: pointer;
      touch-action: manipulation;
    }

    .__crk_slot_tab[aria-pressed="true"] {
      background: var(--crk-fg);
      color: var(--crk-bg);
    }

    .__crk_choice {
      min-width: 62px;
      min-height: 50px;
      font-size: 15px;
      font-weight: 900;
    }

    .__crk_choice small {
      display: block;
      margin-top: 2px;
      font-size: 9px;
      font-weight: 500;
      opacity: .75;
    }

    .__crk_modal_section {
      margin-top: 12px;
      font-size: 12px;
      font-weight: 800;
    }

    .__crk_word {
      padding: 4px 6px;
      border-radius: 5px;
      background: var(--crk-bg);
      font-size: 10px;
      opacity: .86;
    }

    .__crk_attempt {
      padding: 5px 7px;
      border: 1px solid #ff6b6b;
      border-radius: 6px;
      background: var(--crk-bg);
      color: #ff8a8a;
      font-size: 10px;
      font-weight: 800;
    }

    .__crk_guess_toast {
      position: fixed;
      left: 50%;
      bottom: max(18px, env(safe-area-inset-bottom));
      z-index: 2147483647;
      transform: translateX(-50%);
      max-width: min(90vw, 420px);
      padding: 9px 12px;
      border: 1px solid var(--crk-bd);
      border-radius: 999px;
      background: var(--crk-bg);
      color: var(--crk-fg);
      box-shadow: 0 4px 16px rgba(0, 0, 0, .6);
      font: 700 12px/1.2 monospace;
      text-align: center;
    }

    .__crk_chip {
      padding: 2px 4px;
      margin: 0 2px;
      display: inline-block;
      border-radius: 3px;
      color: var(--crk-sug-fg);
    }

    .__crk_chip[data-msg] {
      background: transparent;
      border: none;
    }

    .__crk_chip[data-msg="no-match"] { color: #ff5555; }
    .__crk_chip[data-msg="warn"] { color: #ffd75a; }
    .__crk_chip[data-msg="loading"] { color: #ffd75a; }

    .__crk_chip[data-msg="complete"] {
      color: #82c91e;
      font-weight: bold;
    }

    #__crack_status {
      position: fixed;
      right: 10px;
      bottom: 40px;
      z-index: 10000;
      padding: 6px 8px;
      font-size: 11px;
      font-family: monospace;
      opacity: 0.9;
      border-radius: 6px;
      background: var(--crk-bg);
      color: var(--crk-fg);
      border: 1px solid var(--crk-bd);
      cursor: pointer;
      user-select: none;
    }

    #__crack_status:hover,
    #__crack_status:focus-visible {
      outline: 2px solid var(--crk-bd);
      outline-offset: 2px;
      opacity: 1;
    }

    #__crack_menu_btn {
      font-size: 10px;
      text-align: left;
      z-index: 9999;
      cursor: pointer;
      padding: 4px 6px;
      background: var(--crk-bg);
      color: var(--crk-fg);
      border: 1px solid var(--crk-bd);
      border-radius: 4px;
    }

    .__crk_ov {
      position: fixed;
      inset: 0;
      width: 100%;
      height: 100%;
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 2147483647;
      padding: 12px;
      box-sizing: border-box;
      overscroll-behavior: contain;
    }

    .__crk_box {
      background: var(--crk-box);
      color: var(--crk-fg);
      padding: 18px;
      border: 1px solid var(--crk-bd);
      border-radius: 10px;
      text-align: left;
      width: min(560px, 92vw);
      box-sizing: border-box;
      max-height: calc(100vh - 24px);
      overflow-x: hidden;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      touch-action: pan-y;
      scrollbar-width: thin;
      scrollbar-color: var(--crk-bd) var(--crk-bg);
    }

    .__crk_box::-webkit-scrollbar { width: 10px; }
    .__crk_box::-webkit-scrollbar-track {
      background: var(--crk-bg);
      border-left: 1px solid var(--crk-bd);
    }
    .__crk_box::-webkit-scrollbar-thumb {
      background: var(--crk-bd);
      border: 2px solid var(--crk-bg);
      border-radius: 999px;
    }
    .__crk_box::-webkit-scrollbar-thumb:hover { filter: brightness(1.35); }

    .__crk_settings_head {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      margin-bottom: 10px;
    }

    .__crk_settings_title {
      flex: 1 1 auto;
      min-width: 0;
      font-size: 19px;
      text-align: left;
    }

    .__crk_tools {
      display: flex;
      align-items: center;
      gap: 5px;
      flex: 0 0 auto;
    }

    .__crk_box .__crk_tool {
      width: 32px;
      height: 30px;
      padding: 0;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      font: 700 17px/1 monospace;
    }

    .__crk_tool[aria-pressed="false"] { opacity: 0.5; }
    .__crk_tool[data-busy="true"] { cursor: wait; opacity: 0.65; }

    .__crk_confirm {
      display: none;
      margin: 0 0 10px;
      padding: 10px;
      border: 1px solid #d33;
      border-radius: 7px;
      background: color-mix(in srgb, #a00 24%, var(--crk-box));
    }

    .__crk_confirm[data-open="true"] { display: block; }
    .__crk_confirm_msg { font-size: 12px; margin-bottom: 8px; }
    .__crk_confirm_actions {
      display: flex;
      justify-content: flex-end;
      gap: 7px;
    }

    .__crk_row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      margin: 6px 0;
      flex-wrap: wrap;
    }

    .__crk_settings_group {
      margin: 8px 0;
      border: 1px solid color-mix(in srgb, var(--crk-bd) 55%, transparent);
      border-radius: 7px;
      overflow: hidden;
    }

    .__crk_settings_group > summary {
      padding: 8px 10px;
      background: color-mix(in srgb, var(--crk-bg) 72%, transparent);
      font-size: 12px;
      font-weight: 800;
      cursor: pointer;
      user-select: none;
    }

    .__crk_settings_group_body { padding: 4px 10px 7px; }
    .__crk_settings_group_body .__crk_row { margin: 4px 0; }

    .__crk_pool_toggle_row {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
      margin: 7px 0;
      font: 700 11px/1 monospace;
      text-transform: uppercase;
    }

    .__crk_switch {
      position: relative;
      width: 42px;
      height: 22px;
      display: inline-block;
      flex: 0 0 42px;
    }

    .__crk_switch input {
      position: absolute;
      width: 1px;
      height: 1px;
      opacity: 0;
      pointer-events: none;
    }

    .__crk_switch_slider {
      position: absolute;
      inset: 0;
      border: 1px solid var(--crk-bd);
      border-radius: 999px;
      background: var(--crk-bg);
      cursor: pointer;
    }

    .__crk_switch_slider::after {
      content: '';
      position: absolute;
      width: 16px;
      height: 16px;
      left: 2px;
      top: 2px;
      border-radius: 50%;
      background: var(--crk-fg);
      transition: transform .16s ease;
    }

    .__crk_switch input:checked + .__crk_switch_slider {
      background: color-mix(in srgb, #00d43b 30%, var(--crk-bg));
    }
    .__crk_switch input:checked + .__crk_switch_slider::after { transform: translateX(20px); }
    .__crk_switch input:focus-visible + .__crk_switch_slider { outline: 2px solid var(--crk-bd); }

    .__crk_stats { line-height: 1.45; }
    .__crk_stat_line {
      display: grid;
      grid-template-columns: minmax(145px, 42%) minmax(0, 1fr);
      gap: 8px;
    }
    .__crk_stat_line[data-sub="true"] > span:first-child { padding-left: 12px; }
    .__crk_stat_per_length {
      display: flex;
      flex-wrap: wrap;
      gap: 2px 10px;
    }
    .__crk_stat_compact {
      border-bottom: 1px dotted currentColor;
      cursor: help;
    }
    .__crk_stat_gap { height: 7px; }
    .__crk_stat_note { margin-top: 8px; opacity: .86; }

    .__crk_row label,
    .__crk_row > div:first-child {
      font-size: 12px;
      opacity: 0.95;
      flex: 1 1 auto;
      min-width: 0;
    }

    .__crk_box input,
    .__crk_box select,
    .__crk_box textarea {
      background: var(--crk-bg);
      color: var(--crk-fg);
      border: 1px solid var(--crk-bd);
      border-radius: 4px;
      padding: 4px 6px;
      font-size: 12px;
    }

    .__crk_box button {
      background: var(--crk-bg);
      color: var(--crk-fg);
      border: 1px solid var(--crk-bd);
      border-radius: 6px;
      padding: 6px 10px;
      font-size: 12px;
      cursor: pointer;
    }

    .__crk_box button.danger {
      background: #a00;
      color: #fff;
      border: none;
    }

    .__crk_box hr {
      border: none;
      border-top: 1px solid var(--crk-bd);
      margin: 12px 0;
    }

    .__crk_mono {
      font-family: monospace;
      font-size: 11px;
      white-space: pre-wrap;
      word-break: break-word;
    }

    @media (max-width: 700px) {
      .__crk_ov {
        align-items: flex-start;
        font-size: 13px;
        padding: 10px;
      }

      .__crk_guess_ov {
        align-items: flex-start;
        padding: 6px;
      }

      .__crk_guess_box {
        width: 100%;
        max-height: calc(100dvh - 12px);
        padding: 11px;
        border-radius: 10px;
      }

      .__crk_guess_title { font-size: 15px; }
      .__crk_launcher { max-width: 43vw; padding: 5px 7px; }
      .__crk_launcher_hint { display: none; }

      #__crack_status {
        left: 10px;
        right: 10px;
        bottom: max(10px, env(safe-area-inset-bottom));
        box-sizing: border-box;
        font-size: 10px;
        text-align: center;
      }

      .__crk_row {
        flex-direction: column;
        align-items: stretch;
        gap: 6px;
      }

      .__crk_box input,
      .__crk_box select,
      .__crk_box textarea,
      .__crk_box button {
        width: 100%;
        box-sizing: border-box;
      }

      .__crk_box .__crk_switch input { width: 1px; }

      .__crk_settings_head { align-items: flex-start; }
      .__crk_settings_title { font-size: 16px; }
      .__crk_box .__crk_tool { width: 30px; }
      .__crk_tools .__crk_tool { flex: 0 0 30px; }
    }
  `;

  function injectStyle() {
    if (document.getElementById('__crack_style')) {
      refreshStyleVars();
      return;
    }
    const style = document.createElement('style');
    style.id = '__crack_style';
    style.textContent = STYLESHEET;
    document.head.appendChild(style);
    refreshStyleVars();
  }

  function refreshStyleVars() {
    const t = theme();
    const r = document.documentElement.style;
    r.setProperty('--crk-bg', t.uiBg);
    r.setProperty('--crk-fg', t.uiText);
    r.setProperty('--crk-bd', t.uiBorder);
    r.setProperty('--crk-sug-bg', t.sugBg);
    r.setProperty('--crk-sug-fg', t.sugText);
    r.setProperty('--crk-box', t.boxBg);
    r.setProperty('--crk-font', (isCompact() ? Math.min(t.sugFontPx, 9) : t.sugFontPx) + 'px');
  }

  function ensureBadge() {
    if (statusEl && statusEl.isConnected) return statusEl;
    statusEl = document.createElement('div');
    statusEl.id = '__crack_status';
    statusEl.textContent = 'Dictionary: ' + lastStatus;
    statusEl.tabIndex = 0;
    statusEl.setAttribute('role', 'button');
    statusEl.setAttribute('aria-label', 'Open cRaCked settings');
    statusEl.title = 'Open cRaCked settings';
    statusEl.onclick = () => showSettings();
    statusEl.onkeydown = event => {
      if (event.key !== 'Enter' && event.key !== ' ') return;
      event.preventDefault();
      showSettings();
    };
    document.body.appendChild(statusEl);
    statusEl.style.display = getBool(PREF.badge, true) ? 'block' : 'none';
    return statusEl;
  }

  function setStatus(msg) {
    if (statusResetTimer) {
      clearTimeout(statusResetTimer);
      statusResetTimer = null;
    }
    lastStatus = msg;
    const text = 'Dictionary: ' + msg;
    const b = ensureBadge();
    if (b.textContent !== text) b.textContent = text;
    statusSinks.forEach(el => { if (el.textContent !== text) el.textContent = text; });
    log('STATUS', msg);
  }

  function setTransientStatus(msg, duration = 10000) {
    setStatus(msg);
    statusResetTimer = setTimeout(() => {
      statusResetTimer = null;
      restoreReadyStatus(msg).catch(e => log('ready status refresh failed', e));
    }, duration);
  }

  function setEngineStatus(msg) {
    if (/^Indexing community pool\b.*100%/.test(msg)) setTransientStatus(msg);
    else setStatus(msg);
  }

  /* ==================================================================== */
  /* DOM reading                                                          */
  /* ==================================================================== */

  const VALID_CHAR = /^[A-Z0-9]$/;
  const RIG_STATUS_CACHE_MS = 2000;
  const RIG_STRENGTH_SESSION_KEY = 'crack_rig_strength_v1';
  let rigStatusCache = { at: 0, strength: readStoredBruteStrength() };
  let rigObserver = null;
  let rigCaptureTimer = null;

  function readStoredBruteStrength() {
    try {
      const parsed = Number(sessionStorage.getItem(RIG_STRENGTH_SESSION_KEY));
      return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
    } catch (e) { return null; }
  }

  function storeBruteStrength(strength) {
    try {
      if (Number.isFinite(strength) && strength > 0) {
        sessionStorage.setItem(RIG_STRENGTH_SESSION_KEY, String(strength));
      } else {
        sessionStorage.removeItem(RIG_STRENGTH_SESSION_KEY);
      }
    } catch (e) { /* per-tab memory still works when storage is unavailable */ }
  }

  /** Read Torn's CSS-module statistic from the supplied rig DOM. */
  function inspectRigBruteStrength(root) {
    const scope = root && typeof root.querySelectorAll === 'function' ? root : document;
    const statistics = [];
    if (scope.nodeType === 1 && scope.matches(SEL.bruteStrength)) statistics.push(scope);
    for (const element of scope.querySelectorAll(SEL.bruteStrength)) statistics.push(element);

    for (const statistic of statistics) {
      const label = statistic.querySelector(SEL.statisticLabel);
      if (!/^Brute\s*forc(?:e|ing)\s*strength\s*:?/i.test(String(label && label.textContent || '').trim())) {
        continue;
      }

      const rig = statistic.closest(SEL.rigStatus);
      const state = rig && rig.querySelector(SEL.rigState);
      const stateText = String(state && state.textContent || '').trim();
      if (/offline/i.test(stateText)) return { found: true, strength: null };

      const value = statistic.querySelector(SEL.statisticValue);
      const match = String(value && value.textContent || '').match(/([0-9]+(?:\.[0-9]+)?)/);
      const strength = match ? Number(match[1]) : NaN;
      if (Number.isFinite(strength) && strength > 0) return { found: true, strength };
      return { found: true, strength: null, pending: true };
    }
    return { found: false, strength: null };
  }

  function captureRigBruteStrength() {
    const detected = inspectRigBruteStrength(document);
    if (!detected.found || detected.pending) return false;

    const changed = detected.strength !== rigStatusCache.strength;
    rigStatusCache = { at: Date.now(), strength: detected.strength };
    storeBruteStrength(detected.strength);
    if (changed && runtimeStarted && isCrackingPage()) scanAll('rig-strength');
    return true;
  }

  function nodeIsRigOrInside(node) {
    const element = node && (node.nodeType === 1 ? node : node.parentElement);
    if (!element) return false;
    return element.matches(SEL.rigStatus) || element.matches(SEL.bruteStrength)
      || !!element.closest(SEL.rigStatus);
  }

  function nodeContainsRig(node) {
    const element = node && (node.nodeType === 1 ? node : node.parentElement);
    if (!element) return false;
    return nodeIsRigOrInside(element)
      || !!element.querySelector(SEL.rigStatus)
      || !!element.querySelector(SEL.bruteStrength);
  }

  function scheduleRigCapture() {
    if (rigCaptureTimer) return;
    rigCaptureTimer = setTimeout(() => {
      rigCaptureTimer = null;
      captureRigBruteStrength();
    }, 50);
  }

  function startRigStrengthObserver() {
    captureRigBruteStrength();
    if (rigObserver || !document.body) return;
    rigObserver = new MutationObserver(mutations => {
      for (const mutation of mutations) {
        // Do not query every mutation target's complete subtree: when the
        // target is <body>, that turns unrelated Torn updates into repeated
        // whole-page scans. Only added subtrees need a descendant search.
        if (nodeIsRigOrInside(mutation.target)
          || Array.from(mutation.addedNodes || []).some(nodeContainsRig)) {
          scheduleRigCapture();
          break;
        }
      }
    });
    rigObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
  }

  /**
   * Return the value most recently observed in this tab. The short throttle
   * avoids repeating DOM scans once per visible crack row.
   */
  function readBruteStrength() {
    const now = Date.now();
    if (now - rigStatusCache.at < RIG_STATUS_CACHE_MS) return rigStatusCache.strength;
    if (!captureRigBruteStrength()) rigStatusCache.at = now;
    return rigStatusCache.strength;
  }

  function readSlot(slot, index) {
    const disc = slot.querySelector(SEL.discoveredChar);
    let ch = String((disc && disc.textContent) || '').trim().toUpperCase();
    if (!VALID_CHAR.test(ch)) {
      // A revealed character we cannot represent would silently corrupt the
      // pattern, so make some noise rather than quietly writing '*'.
      if (ch) log('unsupported revealed character', JSON.stringify(ch));
      ch = '*';
    }
    const cls = typeof slot.className === 'string' ? slot.className : String(slot.getAttribute('class') || '');
    const aria = String(slot.getAttribute('aria-label') || '');
    const m = aria.match(/\b(\d+)\s+encryption\b/i);
    const layers = m ? Math.max(0, Number(m[1]) || 0) : (cls.indexOf(SEL.encryptionClass) !== -1 ? 1 : 0);
    const encrypted = layers > 0 || cls.indexOf(SEL.encryptionClass) !== -1;
    const hidden = ch === '*';
    const hasInput = !!slot.querySelector('input[type="text"]');
    return {
      index,
      char: ch,
      hidden,
      encrypted,
      layers,
      hasInput,
      open: hidden && !encrypted,
      guessable: hidden && !encrypted && hasInput,
    };
  }

  function readGuessState(row) {
    const s = row.querySelector(SEL.guessesSection);
    if (!s) return { left: null, pattern: null };
    const label = String(s.getAttribute('aria-label') || '').trim();
    let left = null;
    if (/^No guesses\b/i.test(label)) left = 0;
    const m = label.match(/^(\d+)\s+guess(?:es)?\b/i);
    if (m) left = Number(m[1]);

    // Torn's accessible summary carries the guess count and password pattern
    // together. Comparing it with the slot DOM tells persistence whether both
    // halves of the row have finished the same React hydration pass.
    let pattern = null;
    const summary = label.match(/,\s*(\d+)\s+slots?,\s*(.*?)\s*,\s*\d+\s+total encryption\b/i);
    if (summary) {
      const expectedLength = Number(summary[1]);
      const tokens = summary[2].trim().split(/\s+/).filter(Boolean);
      if (expectedLength === tokens.length) {
        const chars = tokens.map(token => {
          const ch = String(token).toUpperCase();
          return token === '.' ? '*' : (VALID_CHAR.test(ch) ? ch : '');
        });
        if (!chars.includes('')) pattern = chars.join('');
      }
    }
    return { left, pattern };
  }

  // CONFIRM: level selector unverified. Every strategy here fails soft.
  function readLevel(row) {
    const el = row.querySelector(SEL.level);
    if (el) {
      const n = parseInt(String(el.textContent || '').replace(/[^\d]/g, ''), 10);
      if (Number.isFinite(n) && n > 0) return n;
    }
    const aria = String(row.getAttribute('aria-label') || '');
    let m = aria.match(/level\s*[:\s]\s*(\d+)/i);
    if (m) return Number(m[1]);
    m = String(row.textContent || '').match(/\blevel\s*(\d+)\b/i);
    if (m) return Number(m[1]);
    return null;
  }

  function readContext(row) {
    const wrap = row.querySelector(SEL.typeAndService);
    const spans = wrap ? Array.from(wrap.querySelectorAll('span')) : [];
    const title = String(((spans[0]) || {}).textContent || '').replace(/\s+/g, ' ').trim();
    const service = String(((spans[1]) || {}).textContent || '').replace(/\s+/g, ' ').trim();
    const norm = s => s.replace(/\s+/g, ' ').trim().toUpperCase();
    const key = (norm(title) || norm(service)) ? norm(title) + '||' + norm(service) : '';
    return { title, service, key };
  }

  /* ==================================================================== */
  /* Row state — WeakMap keyed on the DOM node, so virtual-list recycling  */
  /* collects it for us. No rowKeys, no sessionStorage growth.             */
  /* ==================================================================== */

  const rows = new WeakMap();

  function stateOf(row, len) {
    let st = rows.get(row);
    if (!st) {
      st = freshState(len);
      rows.set(row, st);
    }
    return st;
  }

  function freshState(len) {
    return {
      len,
      chars: new Array(len).fill('*'),
      slots: [],
      exclusions: Array.from({ length: len }, () => new Set()),
      guessesLeft: null,
      bruteStrength: null,
      contextKey: '',
      level: null,
      pending: null,
      everHadHidden: false,     // the fix for inflated observation counts
      completedWord: null,
      lastResult: null,
      wrongCount: 0,
      guessLog: [],
      maxEncryptionLayersSeen: 0,
      panel: null,
      observer: null,
      queryToken: 0,
      signature: '',
      updateTimer: null,
      identityReady: false,
      memorySignature: '',
      memoryCleared: false,
    };
  }

  function resetState(row, len, ctxKey, level) {
    if (activeSuggestionModal && activeSuggestionModal.row === row) closeSuggestionModal(false);
    const st = freshState(len);
    st.contextKey = ctxKey;
    st.level = level;
    const old = rows.get(row);
    if (old) {
      if (old.observer) old.observer.disconnect();
      if (old.updateTimer) clearTimeout(old.updateTimer);
      if (old.panel && old.panel.parentNode) old.panel.parentNode.removeChild(old.panel);
    }
    rows.set(row, st);
    return st;
  }

  /* ==================================================================== */
  /* Crack-memory persistence — one bounded entry per (context, length).   */
  /* It preserves exclusions and their original probabilities across a     */
  /* refresh/revisit, then expires or clears them when the crack changes.   */
  /* ==================================================================== */

  const CRACK_MEMORY_KEY = 'crack_attempts_v3';
  const LEGACY_EXCL_KEY = 'crack_excl_v2';
  const CRACK_MEMORY_TTL_MS = 48 * 60 * 60 * 1000;
  const CRACK_MEMORY_MAX = 60;

  function crackMemoryId(ctxKey, len) { return ctxKey + '|' + len; }

  function readCrackMemoryStore() {
    try {
      const parsed = JSON.parse(localStorage.getItem(CRACK_MEMORY_KEY) || '{}');
      return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
    }
    catch (e) { return {}; }
  }

  function writeCrackMemoryStore(all) {
    try { localStorage.setItem(CRACK_MEMORY_KEY, JSON.stringify(all)); }
    catch (e) { /* storage full or disabled; in-memory state still works */ }
  }

  function forgetCrackMemory(ctxKey, len) {
    if (!ctxKey) return;
    const id = crackMemoryId(ctxKey, len);
    const all = readCrackMemoryStore();
    if (all[id]) {
      delete all[id];
      writeCrackMemoryStore(all);
    }
    try {
      const legacy = JSON.parse(sessionStorage.getItem(LEGACY_EXCL_KEY) || '{}');
      if (legacy[id]) {
        delete legacy[id];
        sessionStorage.setItem(LEGACY_EXCL_KEY, JSON.stringify(legacy));
      }
    } catch (e) { /* ignore */ }
  }

  function crackMemoryPatternState(previous, current) {
    if (previous.length !== current.length) return 'conflict';
    let incomplete = false;
    for (let i = 0; i < previous.length; i++) {
      // During a refresh Torn can mount every slot as hidden, then hydrate the
      // already-revealed characters a moment later. A hidden value on either
      // side is therefore unknown, not evidence that the password changed.
      if (previous[i] === '*') continue;
      if (current[i] === '*') incomplete = true;
      else if (previous[i] !== current[i]) return 'conflict';
    }
    return incomplete ? 'incomplete' : 'match';
  }

  function patternMatchesMemory(previous, current) {
    return crackMemoryPatternState(previous, current) !== 'conflict';
  }

  function loadCrackMemory(ctxKey, len, pattern, guessesLeft, identitySettled = true) {
    if (!ctxKey) return null;
    const id = crackMemoryId(ctxKey, len);
    const all = readCrackMemoryStore();
    let rec = all[id];

    // Bring forward exclusions stored by 2.1.9. They did not yet have the
    // probabilities required for the incorrect-attempt history.
    if (!rec) {
      try {
        const legacy = JSON.parse(sessionStorage.getItem(LEGACY_EXCL_KEY) || '{}');
        if (legacy[id]) rec = { ...legacy[id], attempts: [], updatedAt: Date.now() };
      } catch (e) { /* ignore */ }
    }
    if (!rec) return null;

    const expired = Number.isFinite(rec.updatedAt)
      && Date.now() - rec.updatedAt > CRACK_MEMORY_TTL_MS;
    const resetGuessCount = Number.isFinite(guessesLeft)
      && Number.isFinite(rec.guessesLeft) && guessesLeft > rec.guessesLeft;
    const patternState = crackMemoryPatternState(String(rec.pattern || ''), pattern);
    if (expired) {
      forgetCrackMemory(ctxKey, len);
      return null;
    }
    // A reset guess count or conflicting revealed character identifies a
    // different crack once Torn has finished hydrating. Do not delete here:
    // both values are briefly incomplete/defaulted while a refreshed row is
    // mounting. processRow retries this lookup whenever that identity changes.
    // Once every formerly revealed position is visible, either condition is
    // definitive enough to retire the old password's record.
    if (identitySettled
      && (patternState === 'conflict' || (resetGuessCount && patternState === 'match'))) {
      forgetCrackMemory(ctxKey, len);
      return null;
    }
    // Do not restore while one or more characters that fingerprinted the
    // saved password are absent from the current DOM. A fresh password with
    // the same target and length must not inherit an old exclusion merely
    // because it has now reached the same remaining-guess count.
    if (!identitySettled || resetGuessCount || patternState !== 'match') return null;

    const exclusions = Array.from({ length: len }, (_, pos) => {
      const listed = Array.isArray(rec.excl && rec.excl[pos]) ? rec.excl[pos] : [];
      return new Set(listed.filter(ch => VALID_CHAR.test(String(ch || '').toUpperCase())));
    });
    const attempts = (Array.isArray(rec.attempts) ? rec.attempts : []).filter(item => (
      item && Number.isInteger(item.pos) && item.pos >= 0 && item.pos < len
      && VALID_CHAR.test(String(item.char || '').toUpperCase()) && item.hit === false
    )).map(item => ({
      ...item,
      char: String(item.char).toUpperCase(),
      p: Number.isFinite(item.p) ? item.p : null,
      hit: false,
    }));
    return { exclusions, attempts };
  }

  function saveCrackMemory(st) {
    if (!st.contextKey) return;
    const attempts = st.guessLog.filter(item => item && item.hit === false).slice(-12);
    if (!attempts.length) return;
    const all = readCrackMemoryStore();
    const id = crackMemoryId(st.contextKey, st.len);
    all[id] = {
      pattern: st.chars.join(''),
      excl: st.exclusions.map(set => Array.from(set)),
      attempts,
      guessesLeft: st.guessesLeft,
      updatedAt: Date.now(),
    };
    const keys = Object.keys(all).sort((a, b) => (
      Number((all[b] && all[b].updatedAt) || 0) - Number((all[a] && all[a].updatedAt) || 0)
    ));
    for (const key of keys.slice(CRACK_MEMORY_MAX)) delete all[key];
    writeCrackMemoryStore(all);
  }

  /* ==================================================================== */
  /* Telemetry                                                            */
  /* ==================================================================== */

  let telemetry = { cracks: [], guesses: [] };
  let telemetryLoaded = false;
  let telemetrySaveTimer = null;

  async function loadTelemetry() {
    if (telemetryLoaded) return telemetry;
    const t = await idbGet('telemetry');
    if (t && Array.isArray(t.cracks)) telemetry = t;
    telemetryLoaded = true;
    return telemetry;
  }

  function saveTelemetrySoon() {
    if (telemetrySaveTimer) return;
    telemetrySaveTimer = setTimeout(async () => {
      telemetrySaveTimer = null;
      if (telemetry.cracks.length > TELEMETRY_MAX_CRACKS) {
        telemetry.cracks = telemetry.cracks.slice(-TELEMETRY_MAX_CRACKS);
      }
      if (telemetry.guesses.length > TELEMETRY_MAX_GUESSES) {
        telemetry.guesses = telemetry.guesses.slice(-TELEMETRY_MAX_GUESSES);
      }
      try { await idbSet('telemetry', telemetry); } catch (e) { log('telemetry save failed', e); }
      pushCalibration();
    }, 3000);
  }

  /** Recompute measured coverage and hand it to the worker. Closes the loop. */
  async function pushCalibration() {
    try {
      await loadTelemetry();
      const cells = {};
      for (const c of telemetry.cracks) {
        const lb = Number.isFinite(c.level) ? Math.floor(c.level / 10) * 10 : 'na';
        const key = c.len + '|' + lb;
        if (!cells[key]) cells[key] = { n: 0, hits: 0 };
        cells[key].n++;
        if (c.foundSrc !== null && c.foundSrc !== undefined) cells[key].hits++;
      }
      await Engine.call('setCalib', { calib: cells });
    } catch (e) { log('calibration push failed', e); }
  }

  function recordGuess(st, pos, ch, hit) {
    const r = st.lastResult;
    let p = null, source = null;
    if (r && r.slots) {
      const slot = r.slots.find(s => s.position === pos);
      if (slot) {
        const L = slot.letters.find(x => x.char === ch);
        if (L) { p = L.p; source = (r.alpha * L.pDict >= (1 - r.alpha) * L.pBack) ? 'dict' : 'ngram'; }
      }
    }
    const rec = {
      t: Date.now(), ctxKey: st.contextKey, level: st.level, len: st.len,
      pos, char: ch, hit: !!hit, p, source,
      alpha: r ? r.alpha : null, matchCount: r ? r.matchCount : null,
      revealed: st.chars.filter(c => c !== '*').length,
      guessesLeft: r && r.policy && Number.isFinite(r.policy.guessesLeft)
        ? r.policy.guessesLeft : st.guessesLeft,
      policyAction: r && r.policy ? r.policy.action : null,
      openClearP: r && r.policy ? r.policy.openClearP : null,
      workCycles: r && r.policy ? r.policy.workCycles : null,
      expectedCyclesSaved: r && r.policy ? r.policy.expectedCyclesSaved : null,
      bruteStrength: r && r.policy ? r.policy.bruteStrength : null,
      expectedBruteActions: r && r.policy ? r.policy.expectedBruteActions : null,
      expectedBruteNerve: r && r.policy ? r.policy.expectedBruteNerve : null,
    };
    st.guessLog.push(rec);
    telemetry.guesses.push(rec);
    if (!hit) {
      st.wrongCount++;
      saveCrackMemory(st);
    }
    saveTelemetrySoon();
  }

  function recordCrack(st, word, foundSrc) {
    telemetry.cracks.push({
      t: Date.now(), ctxKey: st.contextKey, level: st.level, len: st.len,
      word, foundSrc: foundSrc === undefined ? null : foundSrc,
      wrong: st.wrongCount, guesses: st.guessLog.length,
      manualHits: st.guessLog.filter(g => g && g.hit).length,
      lockedOut: st.guessesLeft === 0,
      encryptionLayers: st.maxEncryptionLayersSeen || 0,
      bruteStrength: st.lastResult && st.lastResult.policy
        ? st.lastResult.policy.bruteStrength : null,
    });
    saveTelemetrySoon();
  }

  /* ==================================================================== */
  /* Optional network verdict hook                                        */
  /* ==================================================================== */

  // CONFIRM: the crimes ajax endpoint and response shape are unverified, so
  // this is off by default and the MutationObserver path is authoritative.
  // Once the shape is known this becomes the race-free way to read a verdict.
  const AJAX_MATCH = /sid=crimes|crimes\.php|crackData/i;
  const netVerdicts = [];

  function parseGuessResponse(url, text) {
    try {
      const j = JSON.parse(text);
      // Shape unknown. Look for anything that plainly reads as a verdict.
      const correct = j.correct !== undefined ? !!j.correct
        : j.success !== undefined ? !!j.success
          : j.isCorrect !== undefined ? !!j.isCorrect : null;
      if (correct === null) return null;
      return { correct, at: Date.now() };
    } catch (e) { return null; }
  }

  function installNetHook() {
    if (!getBool(PREF.netHook, false) || window.__crackNetHooked) return;
    window.__crackNetHooked = true;

    const origFetch = window.fetch;
    if (origFetch) {
      window.fetch = function (...args) {
        const p = origFetch.apply(this, args);
        try {
          const url = String((args[0] && args[0].url) || args[0] || '');
          if (AJAX_MATCH.test(url)) {
            p.then(res => res.clone().text()).then(txt => {
              const v = parseGuessResponse(url, txt);
              if (v) netVerdicts.push(v);
            }).catch(() => {});
          }
        } catch (e) { /* never let instrumentation break the page */ }
        return p;
      };
    }

    const origSend = XMLHttpRequest.prototype.send;
    const origOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function (method, url) {
      this.__crackUrl = String(url || '');
      return origOpen.apply(this, arguments);
    };
    XMLHttpRequest.prototype.send = function () {
      try {
        if (AJAX_MATCH.test(this.__crackUrl || '')) {
          this.addEventListener('load', () => {
            const v = parseGuessResponse(this.__crackUrl, this.responseText);
            if (v) netVerdicts.push(v);
          });
        }
      } catch (e) { /* ignore */ }
      return origSend.apply(this, arguments);
    };
  }

  function takeNetVerdict(sinceMs) {
    for (let i = netVerdicts.length - 1; i >= 0; i--) {
      if (netVerdicts[i].at >= sinceMs) {
        const v = netVerdicts[i];
        netVerdicts.length = 0;
        return v;
      }
    }
    return null;
  }

  /* ==================================================================== */
  /* Panel                                                                */
  /* ==================================================================== */

  function ensurePanel(row, st) {
    if (st.panel && st.panel.isConnected) return st.panel;
    const panel = document.createElement('div');
    panel.className = '__crk_panel';
    const list = document.createElement('div');
    list.className = '__crk_list';
    panel.appendChild(list);
    st.panel = panel;
    row.prepend(panel);
    return panel;
  }

  function chip(list, text, kind, title, attrs) {
    const sp = document.createElement('span');
    sp.className = '__crk_chip';
    sp.dataset.kind = kind || 'sug';
    if (attrs) for (const k in attrs) if (attrs[k] != null) sp.dataset[k] = attrs[k];
    sp.textContent = text;
    if (title) sp.title = title;
    list.appendChild(sp);
    return sp;
  }

  function positionPanel(row, panel) {
    const first = row.querySelector(SEL.charSlot);
    if (!first) return;
    const rr = row.getBoundingClientRect();
    const sr = first.getBoundingClientRect();
    if (!rr.width || !rr.height || !sr.width) return;
    if (getComputedStyle(row).position === 'static') row.style.position = 'relative';

    const compact = isCompact();
    const gutter = Math.floor(sr.left - rr.left - 8);
    panel.dataset.layout = compact ? 'compact' : 'desktop';

    if (compact) {
      Object.assign(panel.style, {
        left: '4px', right: 'auto', top: '4px', bottom: 'auto',
        width: 'auto', maxWidth: '43vw', maxHeight: 'none',
      });
      return;
    }

    Object.assign(panel.style, {
      left: '4px', right: 'auto', top: '4px', bottom: 'auto',
      width: 'fit-content', maxWidth: Math.min(190, Math.max(100, gutter - 4)) + 'px',
      maxHeight: Math.max(30, Math.floor(rr.height - 8)) + 'px',
    });
  }

  function tierName(src) { return src === 2 ? 'local' : src === 1 ? 'pool' : 'base'; }

  let activeSuggestionModal = null;
  let guessToastTimer = null;

  function modalNode(tag, className, text) {
    const node = document.createElement(tag);
    if (className) node.className = className;
    if (text !== undefined && text !== null) node.textContent = text;
    return node;
  }

  function showGuessToast(message) {
    let toast = document.querySelector('.__crk_guess_toast');
    if (!toast) {
      toast = modalNode('div', '__crk_guess_toast');
      toast.setAttribute('role', 'status');
      document.body.appendChild(toast);
    }
    toast.textContent = message;
    if (guessToastTimer) clearTimeout(guessToastTimer);
    guessToastTimer = setTimeout(() => {
      guessToastTimer = null;
      if (toast.parentNode) toast.remove();
    }, 2600);
  }

  function closeSuggestionModal(restoreFocus) {
    const active = activeSuggestionModal;
    activeSuggestionModal = null;
    if (!active) return;
    if (active.overlay && active.overlay.parentNode) active.overlay.remove();
    if (restoreFocus && active.trigger && active.trigger.isConnected) {
      try { active.trigger.focus({ preventScroll: true }); } catch (e) { /* ignore */ }
    }
  }

  async function copySuggestedCharacter(ch) {
    try {
      if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
        await navigator.clipboard.writeText(ch);
        return true;
      }
    } catch (e) { /* fall through to the selection-copy path */ }

    const ta = document.createElement('textarea');
    ta.value = ch;
    ta.setAttribute('readonly', '');
    ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
    document.body.appendChild(ta);
    ta.select();
    let copied = false;
    try { copied = document.execCommand('copy'); } catch (e) { /* ignore */ }
    ta.remove();
    return copied;
  }

  async function placeSuggestedCharacter(row, position, character) {
    const ch = String(character || '').toUpperCase();
    if (!VALID_CHAR.test(ch) || !row || !row.isConnected) {
      showGuessToast('That crack row is no longer available');
      closeSuggestionModal(false);
      return false;
    }

    const slots = Array.from(row.querySelectorAll(SEL.charSlot));
    const slot = slots[position];
    if (!slot) {
      showGuessToast('Slot ' + (position + 1) + ' is no longer available');
      closeSuggestionModal(false);
      return false;
    }

    const current = readSlot(slot, position);
    const input = slot.querySelector('input[type="text"]');
    if (!current.open || !input || input.disabled || input.readOnly) {
      const copied = await copySuggestedCharacter(ch);
      showGuessToast((copied ? 'Copied ' : 'Could not place ') + ch + ' for slot ' + (position + 1));
      closeSuggestionModal(false);
      return copied;
    }

    try { input.focus({ preventScroll: true }); } catch (e) { input.focus(); }

    // Torn uses controlled inputs. Calling the native setter and dispatching a
    // real input-shaped event reaches React's change handler while preserving
    // this script's existing pending-guess/exclusion capture path.
    const previous = input.value;
    const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
    if (descriptor && descriptor.set) descriptor.set.call(input, ch);
    else input.value = ch;
    if (input._valueTracker && typeof input._valueTracker.setValue === 'function') {
      input._valueTracker.setValue(previous);
    }

    let inputEvent;
    try {
      inputEvent = new InputEvent('input', {
        bubbles: true,
        composed: true,
        inputType: 'insertText',
        data: ch,
      });
    } catch (e) {
      inputEvent = new Event('input', { bubbles: true, composed: true });
    }
    input.dispatchEvent(inputEvent);

    showGuessToast('Sent ' + ch + ' to slot ' + (position + 1));
    closeSuggestionModal(false);
    return true;
  }

  function renderLauncher(list, row, st, res, positions, options) {
    const opts = options || {};
    const btn = modalNode('button', '__crk_launcher');
    btn.type = 'button';
    btn.dataset.state = opts.state || 'ready';
    btn.disabled = !!opts.disabled;
    btn.setAttribute('aria-label', opts.aria || opts.label || 'Open crack suggestions');
    if (opts.title) btn.title = opts.title;

    btn.appendChild(modalNode('span', '__crk_launcher_main', opts.label || 'CRACK'));
    if (!opts.disabled) btn.appendChild(modalNode('span', '__crk_launcher_hint', 'OPEN'));
    btn.addEventListener('pointerdown', event => event.stopPropagation());
    btn.addEventListener('click', event => {
      event.preventDefault();
      event.stopPropagation();
      if (!res) return;
      openSuggestionModal(row, st, res, positions || [], btn);
    });
    list.appendChild(btn);
    return btn;
  }

  function openSuggestionModal(row, st, res, positions, trigger) {
    closeSuggestionModal(false);

    const overlay = modalNode('div', '__crk_guess_ov');
    const box = modalNode('div', '__crk_guess_box');
    box.setAttribute('role', 'dialog');
    box.setAttribute('aria-modal', 'true');
    box.setAttribute('aria-label', 'Crack character suggestions');
    overlay.appendChild(box);

    const head = modalNode('div', '__crk_guess_head');
    const headText = modalNode('div', '__crk_guess_title', 'Password suggestions');
    const context = [st.title, st.service].filter(Boolean).join(' · ');
    headText.appendChild(modalNode('div', '__crk_guess_subtitle', context || 'Current cracking job'));
    const close = modalNode('button', '__crk_guess_close', '×');
    close.type = 'button';
    close.setAttribute('aria-label', 'Close suggestions');
    close.onclick = () => closeSuggestionModal(true);
    head.appendChild(headText);
    head.appendChild(close);
    box.appendChild(head);

    const meta = modalNode('div', '__crk_guess_meta');
    if (Number.isFinite(st.guessesLeft)) {
      meta.appendChild(modalNode('span', '__crk_guess_badge', st.guessesLeft
        + ' guess' + (st.guessesLeft === 1 ? '' : 'es') + ' left'));
    }
    if (res.matchCount > 0) {
      meta.appendChild(modalNode('span', '__crk_guess_badge', 'In dictionary ' + formatPct(res.alpha)));
      meta.appendChild(modalNode('span', '__crk_guess_badge', res.matchCount.toLocaleString() + ' matches'));
    } else if (res.best && res.best.source === 'affix') {
      meta.appendChild(modalNode('span', '__crk_guess_badge', 'Embedded word ' + formatPct(res.affixTrust)));
    } else {
      meta.appendChild(modalNode('span', '__crk_guess_badge', 'Character model'));
    }
    if (res.policy) {
      const actionLabel = res.policy.action === 'GUESS' ? 'Guess first'
        : res.policy.action === 'BRUTE_FORCE' ? 'Brute force'
          : res.policy.action.toLowerCase();
      meta.appendChild(modalNode('span', '__crk_guess_badge', actionLabel));
      if (Number.isFinite(res.policy.openClearP) && res.policy.openSlots > 0) {
        meta.appendChild(modalNode('span', '__crk_guess_badge',
          'Open clear ' + formatPct(res.policy.openClearP)));
      }
      if (res.policy.workCycles > 0) {
        meta.appendChild(modalNode('span', '__crk_guess_badge',
          'BF work ' + res.policy.workCycles + ' cycle' + (res.policy.workCycles === 1 ? '' : 's')));
      }
      if (Number.isFinite(res.policy.bruteStrength) && res.policy.bruteStrength > 0) {
        meta.appendChild(modalNode('span', '__crk_guess_badge',
          'BFS ' + String(Number(res.policy.bruteStrength.toFixed(4)))));
      } else {
        meta.appendChild(modalNode('span', '__crk_guess_badge', 'BFS unavailable'));
      }
      if (res.policy.workCycles > 0 && Number.isFinite(res.policy.bruteStrength)
        && Number.isFinite(res.policy.expectedBruteActions)) {
        const actions = res.policy.expectedBruteActions;
        const nerve = res.policy.expectedBruteNerve;
        const approximate = Number.isInteger(actions) ? '' : '≈';
        const actionText = Number.isInteger(actions) ? String(actions) : actions.toFixed(2);
        const nerveText = Number.isInteger(nerve) ? String(nerve) : nerve.toFixed(1);
        meta.appendChild(modalNode('span', '__crk_guess_badge',
          'BF ' + approximate + actionText + ' action' + (actions === 1 ? '' : 's')
          + ' · ' + approximate + nerveText + ' nerve'));
      }
    }
    box.appendChild(meta);

    const resultByPosition = new Map();
    for (const slotResult of (res.slots || [])) resultByPosition.set(slotResult.position, slotResult);
    const actionable = [];
    const clone = modalNode('div', '__crk_clone');
    clone.style.setProperty('--crk-slot-count', String(Math.max(1, st.slots.length)));

    for (let i = 0; i < st.slots.length; i++) {
      const actual = st.slots[i];
      const predicted = resultByPosition.get(i);
      const top = predicted && predicted.letters && predicted.letters[0];
      const canUse = actual.open && top && st.guessesLeft !== 0;
      const slotBox = modalNode(canUse ? 'button' : 'div', '__crk_clone_slot');
      if (canUse) slotBox.type = 'button';
      slotBox.dataset.best = String(!!(res.best && res.best.position === i));
      slotBox.appendChild(modalNode('span', '__crk_clone_num', String(i + 1)));

      if (!actual.hidden) {
        slotBox.dataset.state = 'revealed';
        slotBox.appendChild(modalNode('span', '__crk_clone_char', actual.char));
        slotBox.appendChild(modalNode('span', '__crk_clone_pct', 'REVEALED'));
      } else if (actual.encrypted) {
        slotBox.dataset.state = 'locked';
        slotBox.appendChild(modalNode('span', '__crk_clone_char', '🔒'));
        slotBox.appendChild(modalNode('span', '__crk_clone_pct', top
          ? top.char + ' ' + formatPct(top.p)
          : 'LOCKED'));
      } else if (top) {
        slotBox.dataset.state = 'open';
        slotBox.appendChild(modalNode('span', '__crk_clone_char', top.char));
        slotBox.appendChild(modalNode('span', '__crk_clone_pct', formatPct(top.p)));
        slotBox.setAttribute('aria-label', 'Send ' + top.char + ' to slot ' + (i + 1)
          + ', ' + formatPct(top.p));
        slotBox.onclick = () => placeSuggestedCharacter(row, i, top.char);
        actionable.push(i);
      } else {
        slotBox.dataset.state = 'open';
        slotBox.appendChild(modalNode('span', '__crk_clone_char', '?'));
        slotBox.appendChild(modalNode('span', '__crk_clone_pct', 'NO ESTIMATE'));
      }
      clone.appendChild(slotBox);
    }
    box.appendChild(clone);

    const incorrect = st.guessLog.filter(item => item && item.hit === false);
    if (incorrect.length) {
      box.appendChild(modalNode('div', '__crk_modal_section', 'Incorrect attempts'));
      const attempts = modalNode('div', '__crk_attempt_grid');
      for (const item of incorrect) {
        const probability = Number.isFinite(item.p) ? formatPct(item.p) : 'probability unavailable';
        const attempt = modalNode('span', '__crk_attempt',
          'Slot ' + (item.pos + 1) + ': × ' + item.char + ' · ' + probability);
        attempt.title = item.char + ' was tried in slot ' + (item.pos + 1)
          + ' when it was estimated at ' + probability + '.';
        attempts.appendChild(attempt);
      }
      box.appendChild(attempts);
    }

    const implied = impliedWord(res, st);
    if (implied && implied.filled) {
      meta.appendChild(modalNode('span', '__crk_guess_badge', 'Likely ' + implied.text.toUpperCase()));
    }

    if (actionable.length) {
      box.appendChild(modalNode('div', '__crk_guess_help',
        'Tap a suggested letter in the cloned password to send it to that exact slot. '
        + 'Use the alternatives below for another choice. '
        + (res.policy && res.policy.action === 'GUESS'
          ? 'Guessing first is the optimal action: it costs no nerve, and brute force remains available after a miss.'
          : '')));
      box.appendChild(modalNode('div', '__crk_modal_section', 'Choose a slot'));
      const tabs = modalNode('div', '__crk_slot_tabs');
      const choices = modalNode('div', '__crk_choice_grid');
      const tabButtons = new Map();

      const renderChoices = position => {
        choices.innerHTML = '';
        for (const [pos, tab] of tabButtons) tab.setAttribute('aria-pressed', String(pos === position));
        const slotResult = resultByPosition.get(position);
        for (const letter of ((slotResult && slotResult.letters) || []).slice(0, maxSugPref())) {
          const choice = modalNode('button', '__crk_choice', letter.char);
          choice.type = 'button';
          choice.appendChild(modalNode('small', '', formatPct(letter.p)));
          choice.setAttribute('aria-label', 'Send ' + letter.char + ' to slot ' + (position + 1)
            + ', ' + formatPct(letter.p));
          choice.onclick = () => placeSuggestedCharacter(row, position, letter.char);
          choices.appendChild(choice);
        }
      };

      for (const position of actionable) {
        const tab = modalNode('button', '__crk_slot_tab', 'Slot ' + (position + 1));
        tab.type = 'button';
        tab.setAttribute('aria-pressed', 'false');
        tab.onclick = () => renderChoices(position);
        tabButtons.set(position, tab);
        tabs.appendChild(tab);
      }
      box.appendChild(tabs);
      box.appendChild(modalNode('div', '__crk_modal_section', 'Character choices'));
      box.appendChild(choices);
      const preferred = actionable.indexOf(res.best && res.best.position) !== -1
        ? res.best.position
        : actionable[0];
      renderChoices(preferred);
    } else {
      box.appendChild(modalNode('div', '__crk_guess_help',
        st.guessesLeft === 0
          ? 'No guesses remain for this password.'
          : 'Every hidden slot is encrypted. Predicted letters are shown for planning, but cannot be sent until the slot is open.'));
    }

    if (res.words && res.words.length) {
      box.appendChild(modalNode('div', '__crk_modal_section', 'Matching passwords'));
      const words = modalNode('div', '__crk_word_grid');
      for (const word of res.words) {
        const contextHint = Number(word.ctxObs) > 0 ? ' · type match' : '';
        words.appendChild(modalNode('span', '__crk_word',
          word.word + ' ' + formatPct(word.p) + contextHint));
      }
      box.appendChild(words);
    }

    overlay.addEventListener('click', event => {
      if (event.target === overlay) closeSuggestionModal(true);
    });
    overlay.addEventListener('keydown', event => {
      if (event.key === 'Escape') closeSuggestionModal(true);
    });
    document.body.appendChild(overlay);
    activeSuggestionModal = { overlay: overlay, row: row, trigger: trigger };
    close.focus({ preventScroll: true });
  }

  async function renderPanel(row, st) {
    const pattern = st.chars.join('');
    const complete = pattern.indexOf('*') === -1;

    if (complete) {
      st.queryToken++;
      if (activeSuggestionModal && activeSuggestionModal.row === row) closeSuggestionModal(false);
      if (st.panel && st.panel.parentNode) st.panel.parentNode.removeChild(st.panel);
      st.panel = null;
      return;
    }

    const panel = ensurePanel(row, st);
    const list = panel.firstChild;
    positionPanel(row, panel);

    const slots = st.slots || [];
    const guessPositions = [];
    if (st.guessesLeft !== 0) {
      // Only slots that actually accept typing. 1.3.1 disagreed with itself
      // here: with a finite guess count it recommended any unencrypted hidden
      // slot, including ones with no input to type into.
      for (let i = 0; i < slots.length; i++) if (slots[i].guessable) guessPositions.push(i);
      if (!guessPositions.length) {
        for (let i = 0; i < slots.length; i++) if (slots[i].open) guessPositions.push(i);
      }
    }

    const token = ++st.queryToken;
    let res;
    try {
      res = await Engine.call('query', {
        pattern,
        exclusions: st.exclusions.map(s => Array.from(s)),
        guessPositions,
        guessesLeft: st.guessesLeft,
        hiddenCharacters: slots.filter(s => s.hidden).length,
        encryptionLayers: slots.reduce((sum, s) => sum + (s.hidden ? s.layers : 0), 0),
        bruteStrength: st.bruteStrength,
        level: st.level,
        contextKey: st.contextKey,
        maxSug: maxSugPref(),
      });
    } catch (e) {
      if (token !== st.queryToken) return;
      list.innerHTML = '';
      renderLauncher(list, row, st, null, [], {
        label: 'SCORER ERROR',
        aria: 'Crack scorer error',
        title: String(e.message || e),
        state: 'locked',
        disabled: true,
      });
      return;
    }
    if (token !== st.queryToken) return;

    // The model rebuild announces itself and nothing put the badge back, so it
    // sat on "Building character model…" indefinitely once a crack completed.
    if (readyStatus && /Building character model/.test(lastStatus)) setStatus(readyStatus);

    st.lastResult = res;
    list.innerHTML = '';

    if (res.unsupported) {
      renderLauncher(list, row, st, null, [], {
        label: 'LENGTH ' + res.len,
        aria: 'Length ' + res.len + ' is not indexed',
        title: 'Outside the indexed range ' + MIN_LENGTH + '-' + MAX_LENGTH + '.',
        state: 'locked',
        disabled: true,
      });
      return;
    }

    // An untrained model returns a flat distribution, and printing "A 2.6%" for
    // every slot makes a still-loading dictionary indistinguishable from a
    // genuine out-of-vocabulary word. 1.3.1 had a loading state; 2.0.0 dropped
    // it by mistake. Say which it is.
    if (!res.modelTrained || !res.dictSize) {
      renderLauncher(list, row, st, null, [], {
        label: 'CRACK LOADING…',
        aria: dictLoadState || 'Dictionary loading',
        title: 'The dictionary is not ready yet. Progress is shown in the status badge and settings.',
        disabled: true,
      });
      return;
    }

    // No typeable slot means no recommendation. The core returns best = null for
    // an empty guessPositions array; rows that reported NO OPEN SLOTS used to
    // show a confident TRY chip for a slot the player could not use.
    if (!res.best) {
      const noneLeft = st.guessesLeft === 0;
      const policy = res.policy || {};
      const work = Number(policy.workCycles) || 0;
      renderLauncher(list, row, st, res, guessPositions, {
        label: policy.action === 'BRUTE_FORCE' ? '⚙ BRUTE FORCE · VIEW' : '🔒 VIEW LOCKED',
        aria: policy.action === 'BRUTE_FORCE'
          ? 'Brute force recommended; open password details'
          : 'Open locked password predictions',
        title: (policy.reason || (noneLeft
          ? 'Torn reports no guesses remaining on this job.'
          : 'Every remaining hidden slot is encrypted.'))
          + (work ? ' Remaining brute-force work: ' + work + ' cycles.' : ''),
        state: 'locked',
      });
      return;
    }

    renderLauncher(list, row, st, res, guessPositions, {
      label: '★ ' + (res.best.position + 1) + ':' + res.best.char + ' ' + formatPct(res.best.p),
      aria: 'Best guess ' + res.best.char + ' for slot ' + (res.best.position + 1)
        + ', ' + formatPct(res.best.p) + '; open all suggestions',
      title: (res.policy ? res.policy.reason + ' ' : '')
        + 'Open the cloned password and tappable character choices.',
      state: 'ready',
    });
  }

  /**
   * The pattern with confident gaps filled in, e.g. BRUSHY11??. Worth showing
   * even when no slot can be typed into, because it tells you whether the
   * decryption is worth paying for.
   */
  function impliedWord(res, st) {
    if (!res.slots || !res.slots.length) return null;
    const chars = st.chars.slice();
    let filled = 0;
    for (const slot of res.slots) {
      const top = slot.letters && slot.letters[0];
      if (top && top.p >= IMPLIED_MIN_P) {
        chars[slot.position] = top.char.toLowerCase();
        filled++;
      } else {
        chars[slot.position] = '?';
      }
    }
    return filled ? { text: chars.join(''), filled } : null;
  }

  function lettersChip(list, res, positions, label) {
    const ranked = res.slots
      .filter(s => s.letters && s.letters.length && positions.indexOf(s.position) !== -1)
      .sort((a, b) => b.letters[0].p - a.letters[0].p);
    if (!ranked.length) return;
    const shown = ranked.slice(0, LETTERS_SHOWN);
    const parts = shown.map(s => (s.position + 1) + ':' + s.letters[0].char + ' ' + formatPct(s.letters[0].p));
    const detail = ranked.map(s => 'Position ' + (s.position + 1) + ': '
      + s.letters.slice(0, 6).map(x => x.char + ' ' + formatPct(x.p)).join(' \u00b7 ')).join('\n');
    const more = ranked.length - shown.length;
    chip(list, label + ' ' + parts.join(' \u00b7 ') + (more > 0 ? ' +' + more : ''), 'pos', detail);
  }

  function renderWordChips(list, res) {
    const sep = document.createElement('span');
    sep.textContent = '|';
    sep.style.opacity = '0.65';
    list.appendChild(sep);
    for (const w of res.words) {
      const bits = ['source ' + tierName(w.src)];
      if (w.src === 0) bits.push('list rank ' + (w.rank + 1).toLocaleString());
      if (w.obs) bits.push('seen globally ' + w.obs + 'x');
      if (w.ctxObs) bits.push('seen for this target/service ' + w.ctxObs + 'x');
      chip(list, w.word + ' ' + formatPct(w.p), 'sug',
        bits.join(' \u00b7 ')
        + '\nProbability already includes the chance the password is not in the dictionary.',
        { tier: tierName(w.src) });
    }
  }


  function scheduleRender(row, st) {
    if (st.updateTimer) clearTimeout(st.updateTimer);
    st.updateTimer = setTimeout(() => {
      st.updateTimer = null;
      renderPanel(row, st).catch(e => log('render failed', e));
    }, 40);
  }

  /* ==================================================================== */
  /* Feedback capture                                                     */
  /* ==================================================================== */

  function attachRowObserver(row, st) {
    if (st.observer) return;
    // Watching class attributes means the transient incorrect-guess flash
    // cannot slip between polls, which is what used to lose exclusions.
    const obs = new MutationObserver(() => { processRow(row, 'mutation'); });
    obs.observe(row, {
      subtree: true, childList: true, characterData: true,
      attributes: true, attributeFilter: ['class', 'aria-label'],
    });
    st.observer = obs;

    if (row.dataset.crkInput === '1') return;
    row.dataset.crkInput = '1';
    const remember = (ev) => {
      const t = ev.target;
      if (!t || !t.matches || !t.matches('input[type="text"]')) return;
      const slot = t.closest(SEL.charSlot);
      if (!slot || !row.contains(slot)) return;
      const slots = Array.from(row.querySelectorAll(SEL.charSlot));
      const idx = slots.indexOf(slot);
      if (idx < 0) return;
      const raw = ev.type === 'keydown' ? ev.key : t.value;
      const ch = String(raw || '').slice(-1).toUpperCase();
      if (!VALID_CHAR.test(ch)) return;
      const cur = rows.get(row);
      if (!cur) return;
      cur.pending = {
        pos: idx, char: ch, at: performance.now(), wallAt: Date.now(),
        guessesBefore: cur.guessesLeft,
      };
    };
    row.addEventListener('keydown', remember, true);
    row.addEventListener('input', remember, true);
  }

  function resolvePending(row, st, slots) {
    const p = st.pending;
    if (!p) return;
    if (p.pos < 0 || p.pos >= slots.length) { st.pending = null; return; }

    const age = performance.now() - p.at;
    const slot = slots[p.pos];
    const cls = String(slot.el.className || '');
    const flashed = cls.indexOf(SEL.flashClass) !== -1;
    const dropped = Number.isFinite(p.guessesBefore) && Number.isFinite(st.guessesLeft)
      && st.guessesLeft < p.guessesBefore;
    const net = getBool(PREF.netHook, false) ? takeNetVerdict(p.wallAt) : null;

    if (net) {
      if (net.correct) { recordGuess(st, p.pos, p.char, true); }
      else { recordMiss(row, st, p.pos, p.char); }
      st.pending = null;
      return;
    }

    if (slot.char !== '*') {
      // Server returned a character, so the guess landed.
      recordGuess(st, p.pos, p.char, slot.char === p.char);
      st.pending = null;
      return;
    }
    if (dropped || flashed) {
      recordMiss(row, st, p.pos, p.char);
      st.pending = null;
      return;
    }
    if (age > PENDING_TIMEOUT_MS) st.pending = null;
  }

  function addExclusion(st, pos, ch) {
    if (!st.exclusions[pos]) st.exclusions[pos] = new Set();
    if (st.exclusions[pos].has(ch)) return false;
    st.exclusions[pos].add(ch);
    return true;
  }

  function invalidateSuggestionResult(row, st) {
    // A query launched before the exclusion was known must never be allowed to
    // publish afterwards. Invalidate its token immediately instead of waiting
    // for the debounced replacement query to start.
    st.queryToken++;
    st.lastResult = null;
    if (activeSuggestionModal && activeSuggestionModal.row === row) {
      closeSuggestionModal(false);
    }
    if (st.panel && st.panel.isConnected && st.panel.firstChild) {
      const list = st.panel.firstChild;
      list.innerHTML = '';
      renderLauncher(list, row, st, null, [], {
        label: 'UPDATING…',
        aria: 'Updating password suggestions after failed attempt',
        state: 'locked',
        disabled: true,
      });
    }
    scheduleRender(row, st);
  }

  function recordMiss(row, st, pos, ch) {
    // Preserve the probability from the result that proposed this character,
    // and save the new exclusion, before clearing that now-stale result.
    addExclusion(st, pos, ch);
    recordGuess(st, pos, ch, false);
    invalidateSuggestionResult(row, st);
  }

  /* ==================================================================== */
  /* Main row processing                                                  */
  /* ==================================================================== */

  function processRow(row, why) {
    const slotEls = Array.from(row.querySelectorAll(SEL.charSlot));
    if (!slotEls.length) return;

    const slots = slotEls.map((el, i) => {
      const s = readSlot(el, i);
      s.el = el;
      return s;
    });
    const len = slots.length;
    const chars = slots.map(s => s.char);
    const pattern = chars.join('');
    const ctx = readContext(row);
    const level = readLevel(row);
    const guessState = readGuessState(row);
    const guessesLeft = guessState.left;
    const memoryIdentitySettled = !guessState.pattern || guessState.pattern === pattern;
    const bruteStrength = readBruteStrength();

    let st = stateOf(row, len);

    // --- identity: has this recycled node become a different job? --------
    const lengthChanged = st.len !== len;
    const contextChanged = !!(st.contextKey && ctx.key && st.contextKey !== ctx.key);
    const revealedNow = chars.filter(c => c !== '*').length;
    const revealedBefore = st.chars.filter(c => c !== '*').length;
    // A drop in revealed characters means this cannot be the same password.
    // 1.3.1 only caught length/context changes and complete -> fresh, so a
    // partially revealed row replaced by a same-shape job inherited stale
    // exclusions and actively poisoned the new word.
    const revealedDropped = revealedNow < revealedBefore;

    if (!st.identityReady || lengthChanged || contextChanged || revealedDropped) {
      st = resetState(row, len, ctx.key, level);
      st.identityReady = true;
    }

    // Context, revealed characters, and the remaining-guess count arrive in
    // separate React renders after a refresh. Retry a rejected restore when
    // any of those identity fields changes instead of making the first,
    // possibly skeletal render the only chance to recover failed attempts.
    const memorySignature = [ctx.key, len, pattern,
      Number.isFinite(guessesLeft) ? guessesLeft : '?',
      guessState.pattern || '?'].join('::');
    if (ctx.key && !st.guessLog.length && st.memorySignature !== memorySignature) {
      st.memorySignature = memorySignature;
      const restored = loadCrackMemory(
        ctx.key, len, pattern, guessesLeft, memoryIdentitySettled,
      );
      if (restored) {
        st.exclusions = restored.exclusions;
        st.guessLog = restored.attempts;
        st.wrongCount = restored.attempts.length;
        invalidateSuggestionResult(row, st);
      }
    }

    st.len = len;
    st.contextKey = ctx.key;
    st.title = ctx.title;
    st.service = ctx.service;
    if (Number.isFinite(level)) st.level = level;
    st.guessesLeft = guessesLeft;
    st.bruteStrength = bruteStrength;
    st.slots = slots;
    st.maxEncryptionLayersSeen = Math.max(
      st.maxEncryptionLayersSeen || 0,
      slots.reduce((sum, slot) => sum + (slot.hidden ? slot.layers : 0), 0),
    );

    if (chars.some(c => c === '*')) st.everHadHidden = true;

    resolvePending(row, st, slots);
    st.chars = chars;
    attachRowObserver(row, st);

    // --- completion ------------------------------------------------------
    const complete = pattern.indexOf('*') === -1;
    if (complete && !st.memoryCleared) {
      forgetCrackMemory(st.contextKey, st.len);
      st.memoryCleared = true;
    } else if (!complete) {
      st.memoryCleared = false;
    }
    if (complete && st.everHadHidden && st.completedWord !== pattern) {
      // everHadHidden is the fix for 1.3.1 counting an already-finished row
      // as a fresh observation on every single page load.
      st.completedWord = pattern;
      if (VALID_CHAR.test(pattern[0]) && /^[A-Z0-9]+$/.test(pattern)) {
        onCrackCompleted(row, st, pattern);
      } else {
        log('completed word has unsupported characters, skipping:', pattern);
      }
    }

    // --- redraw only when something the panel depends on changed ----------
    const sig = [
      pattern,
      guessesLeft === null ? '?' : guessesLeft,
      ctx.key,
      st.level,
      st.exclusions.map(s => Array.from(s).sort().join('')).join('|'),
      slots.map(s => s.layers + (s.hasInput ? 'i' : '')).join(''),
      Number.isFinite(bruteStrength) ? bruteStrength : '?',
    ].join('::');
    if (sig !== st.signature) {
      st.signature = sig;
      scheduleRender(row, st);
    } else if (!st.panel || !st.panel.isConnected) {
      scheduleRender(row, st);
    } else if (why === 'resize') {
      positionPanel(row, st.panel);
    }
  }

  async function onCrackCompleted(row, st, word) {
    try {
      // Ask before adding, so "was it already known" is answerable. This is
      // both the dictionary-coverage measurement and the repeat-rate
      // measurement, and they are different questions: base means the generic
      // list had it, pool/local means we have genuinely seen it before.
      const found = await Engine.call('has', { word });
      const foundSrc = found.found ? found.src : null;
      recordCrack(st, word, foundSrc);

      await Engine.call('recordObs', { word, contextKey: st.contextKey });

      if (getBool(PREF.upload, true)) {
        await enqueueOutbox(word, {
          title: st.title,
          service: st.service,
          level: st.level,
        });
      }
      scheduleRender(row, st);
    } catch (e) {
      log('completion handling failed', e);
    }
  }

  /* ==================================================================== */
  /* Page scanning — observer driven, slow interval only as a safety net   */
  /* ==================================================================== */

  let listObserver = null;
  let safetyTimer = null;
  let rootObserver = null;

  function scanAll(why) {
    if (!runtimeStarted || !isCrackingPage()) return;
    const current = document.querySelector(SEL.currentCrime);
    if (!current) return;
    const container = current.querySelector(SEL.virtualList);
    if (!container) return;

    if (!listObserver) {
      listObserver = new MutationObserver(() => scanAll('list'));
      listObserver.observe(container, { childList: true, subtree: false });
    }

    for (const row of container.querySelectorAll(SEL.crimeOption)) {
      try { processRow(row, why); } catch (e) { log('row failed', e); }
    }
  }

  function startObservers() {
    if (!rootObserver) {
      // Torn's SPA may mount the cracking UI after we boot, so watch for it
      // rather than relying on the hash listener alone.
      rootObserver = new MutationObserver(() => {
        if (isCrackingPage()) { startRuntime(); scanAll('root'); injectMenuButton(); }
      });
      rootObserver.observe(document.body, { childList: true, subtree: true });
    }
    if (!safetyTimer) safetyTimer = setInterval(() => scanAll('safety'), SAFETY_SCAN_MS);
    window.addEventListener('resize', onResize, { passive: true });
  }

  let resizeTimer = null;
  function onResize() {
    if (resizeTimer) clearTimeout(resizeTimer);
    resizeTimer = setTimeout(() => { refreshStyleVars(); scanAll('resize'); }, 150);
  }

  /* ==================================================================== */
  /* Dictionary bootstrap + community sync                                */
  /* ==================================================================== */

  let dictReady = false;
  let dictLoadState = 'DICTIONARY LOADING\u2026';
  let readyStatus = '';

  function readyStatusFromStats(stats) {
    const total = Math.max(0, Number(stats && stats.total) || 0);
    const observed = Math.max(0, Number(stats && stats.repeat && stats.repeat.distinct) || 0);
    return 'Ready (' + total.toLocaleString() + ' words'
      + (observed ? ', ' + observed.toLocaleString() + ' observed' : '') + ')';
  }

  async function restoreReadyStatus(expectedStatus) {
    if (expectedStatus && lastStatus !== expectedStatus) return;
    try {
      readyStatus = readyStatusFromStats(await Engine.call('stats'));
    } catch (e) {
      if (!readyStatus) throw e;
    }
    if (!expectedStatus || lastStatus === expectedStatus) setStatus(readyStatus || 'Ready');
  }

  async function bootstrapDictionary() {
    setStatus('Opening cache…');
    let s;
    try {
      s = await Engine.call('init');
    } catch (e) {
      setStatus('Scorer unavailable');
      return;
    }

    await applyStoredParams();

    const installedBase = await idbGet('base_snapshot_id');
    if (!s.total || installedBase !== BASE_SNAPSHOT_ID) {
      dictLoadState = 'DOWNLOADING WORDLIST\u2026';
      setStatus('Downloading base wordlist…');
      const ok = await downloadBaseList();
      if (!ok) {
        dictLoadState = 'WORDLIST DOWNLOAD FAILED — RETRYING';
        setTimeout(() => bootstrapDictionary().catch(() => {}), 60000);
        return;
      }
      s = await Engine.call('stats');
    }

    dictReady = true;
    dictLoadState = '';
    readyStatus = readyStatusFromStats(s);
    setStatus(readyStatus);
    await pushCalibration();
    scheduleAutoSync();
  }

  async function downloadBaseList() {
    let progressTimer = null;
    try {
      const manifestText = await fetchBasePart(BASE_MANIFEST_URL, 'manifest');
      const manifest = JSON.parse(manifestText);
      const chunks = Array.isArray(manifest.chunks) ? manifest.chunks : [];
      const expectedCount = Number(manifest.count);
      if (manifest.format_version !== 1 || manifest.snapshot_id !== BASE_SNAPSHOT_ID) {
        throw new Error('base manifest version mismatch');
      }
      if (!Number.isInteger(expectedCount) || expectedCount <= 0 || expectedCount > 2000000) {
        throw new Error('invalid base manifest count');
      }
      if (!chunks.length || chunks.length > 100) throw new Error('invalid base chunk list');

      let nextRank = 0;
      for (const chunk of chunks) {
        if (!chunk || !/^chunks\/base-\d{3}\.txt$/.test(String(chunk.path || ''))) {
          throw new Error('invalid base chunk path');
        }
        if (Number(chunk.start_rank) !== nextRank
          || !Number.isInteger(Number(chunk.line_count))
          || Number(chunk.line_count) <= 0
          || Number(chunk.line_count) > 50000) {
          throw new Error('invalid base chunk metadata');
        }
        nextRank += Number(chunk.line_count);
      }
      if (nextRank !== expectedCount) throw new Error('base manifest total mismatch');

      await Engine.call('beginBaseReplace');
      const progressStartedAt = Date.now();
      let completedWords = 0;
      let activePart = 1;
      const refreshProgress = (force = false) => {
        if (!force && /failed — retrying in/i.test(lastStatus)) return;
        const progress = formatProgressStatus(
          'Indexing dictionary ' + activePart + '/' + chunks.length,
          completedWords,
          expectedCount,
          progressStartedAt,
        );
        dictLoadState = progress.toUpperCase();
        setStatus(progress);
      };
      refreshProgress();
      progressTimer = setInterval(refreshProgress, 1000);
      for (let i = 0; i < chunks.length; i++) {
        const chunk = chunks[i];
        const part = i + 1;
        activePart = part;
        refreshProgress();
        const text = await fetchBasePart(COMMUNITY_POOL_ORIGIN + '/base/' + chunk.path, part + '/' + chunks.length);
        if (!text) throw new Error('empty base chunk');
        refreshProgress(true);
        const indexed = await Engine.call('appendBaseChunk', {
          text: text,
          startRank: Number(chunk.start_rank),
        });
        if (Number(indexed.valid) !== Number(chunk.line_count)) {
          throw new Error('base chunk line count mismatch');
        }
        completedWords += Number(chunk.line_count);
        refreshProgress(true);
      }

      clearInterval(progressTimer);
      progressTimer = null;
      dictLoadState = 'SAVING WORDLIST\u2026';
      setStatus('Saving base dictionary · elapsed '
        + formatDuration(Date.now() - progressStartedAt) + '…');
      const r = await Engine.call('finishBaseReplace', { expectedCount: expectedCount });
      await idbSet('base_snapshot_id', BASE_SNAPSHOT_ID);
      setStatus('Indexed ' + r.total.toLocaleString() + ' words');
      return true;
    } catch (e) {
      log('base dictionary install failed', e);
      setStatus('Failed to fetch base dictionary (will retry)');
      return false;
    } finally {
      if (progressTimer) clearInterval(progressTimer);
    }
  }

  async function fetchBasePart(url, label) {
    const DELAYS = [0, 3000, 10000, 30000];
    let lastError = null;
    for (let attempt = 0; attempt < DELAYS.length; attempt++) {
      const wait = DELAYS[attempt];
      if (wait) {
        setStatus('Dictionary part ' + label + ' failed — retrying in '
          + Math.ceil(wait / 1000) + 's…');
        await new Promise(resolve => setTimeout(resolve, wait));
      }
      try {
        const res = await gmRequest({ method: 'GET', url: url, timeout: 30000, responseType: 'text' });
        if (res.status < 200 || res.status >= 300) throw new Error('status ' + res.status);
        const text = res.responseText || '';
        if (!text) throw new Error('empty response');
        return text;
      } catch (e) {
        lastError = e;
      }
    }
    throw lastError || new Error('dictionary part failed');
  }

  function isGzip(p) { return /\.gz$/i.test(String(p || '').split('?')[0]); }

  async function gunzipToText(buf) {
    if (typeof DecompressionStream !== 'function') {
      throw new Error('DecompressionStream unavailable — serve an uncompressed snapshot');
    }
    const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream('gzip'));
    return new Response(stream).text();
  }

  async function fetchRemoteMeta(force) {
    if (!COMMUNITY_SYNC_ENABLED) return null;
    const cached = (await idbGet('cf_metadata')) || {};
    const last = Number(await idbGet('cf_last_meta_check_ts')) || 0;
    if (!force && Date.now() - last < SYNC_CHECK_INTERVAL_MS) return cached;
    const checkedAt = Date.now();
    // Record attempts as well as successes so an outage cannot create a tight
    // automatic retry loop. Keep the old key current for users upgrading from
    // 2.1.6 and for existing diagnostic snippets.
    await idbSet('cf_last_meta_check_ts', checkedAt);
    await idbSet('cf_last_sync_ts', checkedAt);
    try {
      const url = METADATA_URL + '?cb=' + (force ? Date.now() : Math.floor(Date.now() / 60000));
      const res = await gmRequest({ method: 'GET', url, timeout: 10000 });
      if (res.status !== 200) throw new Error('metadata status ' + res.status);
      const meta = JSON.parse(res.responseText || '{}');
      const rec = {
        count: meta.count || 0,
        snapshot_path: meta.snapshot_path || meta.latest_path || null,
        diff_path: meta.diff_path || null,
        generated_at: meta.generated_at || null,
        context_snapshot_path: meta.context_snapshot_path || null,
        context_count: Number(meta.context_count) || 0,
        context_etag: meta.context_etag || null,
        context_generated_at: meta.context_generated_at || null,
      };
      await idbSet('cf_metadata', rec);
      await idbSet('cf_remote_count', rec.count);
      return rec;
    } catch (e) {
      log('meta fetch failed', e);
      if (force) throw e;
      return cached;
    }
  }

  async function syncCommunityContexts(meta, force) {
    if (!CONTEXT_SNAPSHOT_URL || !meta || !meta.context_snapshot_path) {
      return { status: 'unsupported', imported: 0 };
    }
    if (!/^contexts\/[A-Za-z0-9._-]+$/.test(String(meta.context_snapshot_path))) {
      throw new Error('invalid context snapshot path');
    }

    const storedEtag = String((await idbGet('cf_context_etag')) || '');
    if (!force && meta.context_etag && storedEtag === meta.context_etag) {
      return { status: 'unchanged', imported: Number(await idbGet('cf_context_count')) || 0 };
    }

    const res = await gmRequest({
      method: 'GET',
      url: CF_ORIGIN + '/' + meta.context_snapshot_path,
      headers: storedEtag ? { 'If-None-Match': storedEtag } : {},
      timeout: 20000,
      responseType: 'text',
    });
    if (res.status === 304) {
      if (meta.context_etag) await idbSet('cf_context_etag', meta.context_etag);
      await idbSet('cf_context_sync_ts', Date.now());
      return { status: 'unchanged', imported: Number(await idbGet('cf_context_count')) || 0 };
    }
    if (res.status !== 200) throw new Error('context snapshot status ' + res.status);

    const payload = JSON.parse(res.responseText || '{}');
    const associations = Array.isArray(payload.associations) ? payload.associations : null;
    if (payload.format_version !== 1 || !associations || associations.length > 500000) {
      throw new Error('invalid context snapshot');
    }
    if (Number(payload.count) !== associations.length) {
      throw new Error('context snapshot count mismatch');
    }

    const imported = await Engine.call('replacePoolContexts', { associations });
    const responseEtag = headerOf(res.responseHeaders, 'ETag');
    await idbSet('cf_context_etag', responseEtag || meta.context_etag || '');
    await idbSet('cf_context_count', Number(imported.imported) || 0);
    await idbSet('cf_context_sync_ts', Date.now());
    return { status: 'downloaded', imported: Number(imported.imported) || 0 };
  }

  async function syncCommunity(force) {
    if (!COMMUNITY_SYNC_ENABLED) return { status: 'disabled', added: 0, updated: 0, changed: 0 };
    let lastDownload = Number(await idbGet('cf_last_download_ts')) || 0;
    if (!lastDownload) {
      // 2.1.6 only stored the last metadata check. Seed the new download clock
      // once so the compatibility migration cannot postpone small updates on
      // every 15-minute check.
      lastDownload = Number(await idbGet('cf_last_sync_ts')) || Date.now();
      await idbSet('cf_last_download_ts', lastDownload);
    }
    const meta = await fetchRemoteMeta(force);
    if (!meta || !meta.snapshot_path) throw new Error('community metadata unavailable');

    let contextSync = { status: 'unsupported', imported: 0 };
    try {
      contextSync = await syncCommunityContexts(meta, force);
    } catch (e) {
      log('context sync failed', e);
      contextSync = { status: 'failed', imported: 0 };
    }

    const lastCount = Number(await idbGet('cf_last_downloaded_count')) || 0;
    const delta = Math.max(0, (meta.count || 0) - lastCount);
    const overdue = delta > 0 && (!lastDownload || Date.now() - lastDownload >= SYNC_MAX_WAIT_MS);
    if (!force && delta < DOWNLOAD_MIN_DELTA && !overdue) {
      await idbSet('cf_pending_delta', delta);
      return {
        status: 'pending', pending: delta, added: 0, updated: 0, changed: 0,
        contexts: contextSync,
      };
    }

    setStatus(force ? 'Manual sync…' : 'Syncing (+' + delta + ')…');
    const etag = (await idbGet('cf_remote_etag')) || '';
    const gz = isGzip(meta.snapshot_path);
    let res;
    try {
      res = await gmRequest({
        method: 'GET',
        url: CF_STORAGE_BASE + '/' + meta.snapshot_path,
        headers: etag ? { 'If-None-Match': etag } : {},
        timeout: 45000,
        responseType: gz ? 'arraybuffer' : 'text',
      });
    } catch (e) {
      log('snapshot fetch failed', e);
      throw new Error('community snapshot download failed');
    }

    const newEtag = headerOf(res.responseHeaders, 'ETag');
    if (newEtag) await idbSet('cf_remote_etag', newEtag);
    if (res.status === 304) {
      await idbSet('cf_last_downloaded_count', meta.count || 0);
      await idbSet('cf_last_download_ts', Date.now());
      await idbSet('cf_pending_delta', 0);
      setTransientStatus('0 words added/updated');
      return {
        status: 'unchanged', pending: 0, added: 0, updated: 0, changed: 0,
        contexts: contextSync,
      };
    }
    if (res.status !== 200) throw new Error('community snapshot status ' + res.status);

    const text = gz ? await gunzipToText(res.response) : (res.responseText || '');
    if (!text) throw new Error('empty community snapshot');
    setStatus('Indexing community snapshot…');
    // src 1 = pool. Words already present from the v1 migration get their tier
    // upgraded rather than skipped, which repairs their prior.
    const r = await Engine.call('indexText', { text, src: 1 });
    await idbSet('cf_last_downloaded_count', meta.count || 0);
    await idbSet('cf_last_download_ts', Date.now());
    await idbSet('cf_pending_delta', 0);
    const added = Number(r.added) || 0;
    const updated = Number(r.updated) || 0;
    const changed = added + updated;
    setTransientStatus(changed.toLocaleString() + ' words added/updated');
    return {
      status: 'downloaded', pending: 0, added, updated, changed, total: r.total,
      contexts: contextSync,
    };
  }

  let autoSyncTimer = null;
  async function scheduleAutoSync() {
    if (!COMMUNITY_SYNC_ENABLED) return;
    if (autoSyncTimer) clearTimeout(autoSyncTimer);
    const last = Number(await idbGet('cf_last_meta_check_ts')) || 0;
    // Metadata is tiny, so check it periodically. The full snapshot is only
    // downloaded at the 25-word threshold or when the 24-hour cap is reached.
    const wait = Math.max(30000, last + SYNC_CHECK_INTERVAL_MS - Date.now());
    autoSyncTimer = setTimeout(async () => {
      autoSyncTimer = null;
      if (runtimeStarted && isCrackingPage()) {
        try {
          await syncCommunity(false);
        } catch (e) {
          log('auto sync failed', e);
          setStatus('Community sync failed (will retry)');
        }
      }
      scheduleAutoSync();
    }, wait);
  }

  /* ==================================================================== */
  /* Outbox                                                               */
  /* ==================================================================== */

  let outboxBusy = false;
  let outboxTimer = null;
  let outboxFailures = 0;
  let outboxChain = Promise.resolve();
  let clientRegistrationPromise = null;

  /** All queue mutations go through one chain, so no read-modify-write races. */
  function withQueue(fn) {
    outboxChain = outboxChain.then(async () => {
      const q = (await idbGet('cf_outbox')) || [];
      const next = await fn(q);
      if (next !== undefined) await idbSet('cf_outbox', next.slice(0, OUTBOX_MAX_QUEUE));
    }).catch(e => log('outbox queue op failed', e));
    return outboxChain;
  }

  function normalizeOutboxItem(raw) {
    const source = typeof raw === 'string' ? { word: raw } : (raw || {});
    const word = String(source.word || '').trim().toUpperCase();
    if (!/^[A-Z0-9]{4,10}$/.test(word)) return null;
    const title = String(source.title || '').trim().replace(/\s+/g, ' ').slice(0, 80);
    const service = String(source.service || '').trim().replace(/\s+/g, ' ').slice(0, 80);
    const rawLevel = Number(source.level);
    const level = Number.isInteger(rawLevel) && rawLevel >= 1 && rawLevel <= 100 ? rawLevel : null;
    return { word, title, service, level };
  }

  function outboxItemKey(raw) {
    const item = normalizeOutboxItem(raw);
    return item ? item.word + '\u001f' + item.title.toUpperCase() + '\u001f' + item.service.toUpperCase() : '';
  }

  async function enqueueOutbox(word, details) {
    if (!COMMUNITY_SYNC_ENABLED || !getBool(PREF.upload, true) || !word) return;
    const item = normalizeOutboxItem(Object.assign({ word }, details || {}));
    if (!item) return;
    const key = outboxItemKey(item);
    let queued = 0;
    await withQueue(q => {
      const normalized = q.map(normalizeOutboxItem).filter(Boolean);
      const existing = normalized.findIndex(entry => outboxItemKey(entry) === key);
      if (existing >= 0) normalized[existing] = item;
      else normalized.push(item);
      queued = normalized.length;
      return normalized;
    });
    if (queued >= OUTBOX_BATCH_SIZE) {
      if (outboxTimer) clearTimeout(outboxTimer);
      outboxTimer = null;
      flushOutbox().catch(e => log('outbox flush failed', e));
    } else if (!outboxTimer) {
      outboxTimer = setTimeout(flushOutbox, OUTBOX_FLUSH_DELAY_MS);
    }
  }

  async function registerClient(force) {
    if (!COMMUNITY_SYNC_ENABLED || !CF_REGISTER_CLIENT_URL) return '';
    if (!force) {
      const stored = String((await idbGet(CLIENT_ID_STORE_KEY)) || '');
      if (CLIENT_ID_RE.test(stored)) return stored;
    }
    if (clientRegistrationPromise) return clientRegistrationPromise;
    clientRegistrationPromise = (async () => {
      if (force) await idbSet(CLIENT_ID_STORE_KEY, '');
      const res = await gmRequest({
        method: 'POST',
        url: CF_REGISTER_CLIENT_URL,
        headers: { 'Content-Type': 'application/json' },
        data: '{}',
        timeout: 15000,
      });
      if (res.status < 200 || res.status >= 300) {
        throw new Error('client registration status ' + res.status);
      }
      const data = JSON.parse(res.responseText || '{}');
      const clientId = String(data.client_id || '');
      if (data.ok !== true || !CLIENT_ID_RE.test(clientId)) {
        throw new Error('invalid client registration response');
      }
      await idbSet(CLIENT_ID_STORE_KEY, clientId);
      return clientId;
    })().finally(() => { clientRegistrationPromise = null; });
    return clientRegistrationPromise;
  }

  async function postBatch(observations, retried) {
    const clientId = await registerClient(false);
    const headers = { 'Content-Type': 'application/json' };
    if (clientId) headers['X-cRaCked-Client-ID'] = clientId;
    headers['X-cRaCked-Version'] = CLIENT_VERSION;
    headers['X-cRaCked-Build-ID'] = CLIENT_BUILD_ID;
    const res = await gmRequest({
      method: 'POST',
      url: CF_ADD_WORD_URL,
      headers,
      data: JSON.stringify({ observations }),
      timeout: 15000,
    });
    if ((res.status === 400 || res.status === 401) && clientId && !retried) {
      let data = {};
      try { data = JSON.parse(res.responseText || '{}'); } catch (e) {}
      if (data.error === 'invalid client ID' || data.error === 'client registration required') {
        await registerClient(true);
        return postBatch(observations, true);
      }
    }
    if (res.status < 200 || res.status >= 300) throw new Error('status ' + res.status);
    return res;
  }

  async function flushOutbox() {
    outboxTimer = null;
    if (outboxBusy) return;                       // no concurrent flushes
    if (!COMMUNITY_SYNC_ENABLED || !getBool(PREF.upload, true)) {
      await withQueue(() => []);
      return;
    }
    outboxBusy = true;
    try {
      while (true) {
        let batch = [];
        await withQueue(q => {
          batch = q.slice(0, OUTBOX_BATCH_SIZE);
          return q;                               // do not remove yet
        });
        if (!batch.length) break;

        try {
          await postBatch(batch);
          // Only drop from the queue once the server has accepted them.
          const sent = new Set(batch.map(outboxItemKey));
          await withQueue(q => q.filter(item => !sent.has(outboxItemKey(item))));
          outboxFailures = 0;
          log('outbox flushed', batch.length);
        } catch (e) {
          outboxFailures++;
          log('outbox batch failed', outboxFailures, e);
          if (outboxFailures >= OUTBOX_MAX_CONSECUTIVE_FAILURES) {
            // Back off instead of spinning. Nothing is discarded.
            setStatus('Upload paused (retrying later)');
            outboxTimer = setTimeout(flushOutbox, OUTBOX_BACKOFF_MS);
            break;
          }
          outboxTimer = setTimeout(flushOutbox, OUTBOX_POST_INTERVAL_MS * outboxFailures);
          break;
        }
        await new Promise(r => setTimeout(r, OUTBOX_POST_INTERVAL_MS));
      }
    } finally {
      outboxBusy = false;
    }
  }

  /* ==================================================================== */
  /* Params                                                               */
  /* ==================================================================== */

  async function applyStoredParams() {
    let over = null;
    try {
      const raw = getStr(PREF.params, '');
      if (raw) over = JSON.parse(raw);
    } catch (e) { log('bad params json', e); }
    try { await Engine.call('setParams', { params: over }); } catch (e) { log('setParams failed', e); }
  }

  /* ==================================================================== */
  /* Settings                                                             */
  /* ==================================================================== */

  function el(tag, props, css) {
    const e = document.createElement(tag);
    if (props) Object.assign(e, props);
    if (css) e.style.cssText = css;
    return e;
  }

  function addRow(parent, label, control) {
    const row = el('div');
    row.className = '__crk_row';
    row.appendChild(el('div', { textContent: label }));
    if (control) row.appendChild(control);
    parent.appendChild(row);
    return row;
  }

  function addCheck(parent, label, key, def, onChange) {
    const wrap = el('label', {}, 'cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;margin:8px 0;');
    const cb = el('input');
    cb.type = 'checkbox';
    cb.checked = getBool(key, def);
    cb.onchange = () => { setBool(key, cb.checked); if (onChange) onChange(cb.checked); };
    wrap.appendChild(cb);
    wrap.appendChild(el('span', { textContent: label }));
    parent.appendChild(wrap);
    return cb;
  }

  async function showSettings() {
    injectStyle();
    if (document.querySelector('.__crk_ov')) return;
    const overlay = el('div');
    overlay.className = '__crk_ov';
    overlay.style.background = theme().overlayBg;
    const box = el('div');
    box.className = '__crk_box';
    overlay.appendChild(box);

    const settingsHead = el('div');
    settingsHead.className = '__crk_settings_head';
    const settingsTitle = el('div', { textContent: 'cRaCked 2.2.8 — Settings' });
    settingsTitle.className = '__crk_settings_title';
    settingsHead.appendChild(settingsTitle);

    const tools = el('div');
    tools.className = '__crk_tools';
    settingsHead.appendChild(tools);
    box.appendChild(settingsHead);

    function toolButton(glyph, title, danger) {
      const b = el('button', { textContent: glyph, title });
      b.className = '__crk_tool' + (danger ? ' danger' : '');
      b.setAttribute('aria-label', title);
      tools.appendChild(b);
      return b;
    }

    let bSync = null;
    if (COMMUNITY_SYNC_ENABLED) {
      bSync = toolButton('↻', 'Sync community words now', false);
      bSync.onclick = async () => {
        if (bSync.dataset.busy === 'true') return;
        bSync.dataset.busy = 'true';
        bSync.disabled = true;
        setStatus('Syncing community words…');
        try {
          await syncCommunity(true);
          await refreshStats();
          await refreshNextSync();
        } catch (e) {
          setStatus('Sync failed: ' + (e.message || e));
        } finally {
          bSync.disabled = false;
          bSync.dataset.busy = 'false';
        }
      };
    }

    const bResetObs = toolButton('↺', 'Reset observation counts', true);
    const bClear = toolButton('⌫', 'Clear wordlist cache', true);

    const confirmPanel = el('div');
    confirmPanel.className = '__crk_confirm';
    const confirmMsg = el('div');
    confirmMsg.className = '__crk_confirm_msg';
    const confirmActions = el('div');
    confirmActions.className = '__crk_confirm_actions';
    const bConfirmCancel = el('button', { textContent: 'Cancel' });
    const bConfirmAction = el('button', { textContent: 'Confirm' });
    bConfirmAction.className = 'danger';
    confirmActions.appendChild(bConfirmCancel);
    confirmActions.appendChild(bConfirmAction);
    confirmPanel.appendChild(confirmMsg);
    confirmPanel.appendChild(confirmActions);
    box.appendChild(confirmPanel);

    let pendingConfirmAction = null;
    const closeConfirm = () => {
      pendingConfirmAction = null;
      confirmPanel.dataset.open = 'false';
      confirmMsg.textContent = '';
      bConfirmAction.disabled = false;
      bConfirmAction.textContent = 'Confirm';
    };
    const openConfirm = (message, label, action) => {
      pendingConfirmAction = action;
      confirmMsg.textContent = message;
      bConfirmAction.textContent = label;
      confirmPanel.dataset.open = 'true';
      bConfirmCancel.focus();
    };
    bConfirmCancel.onclick = closeConfirm;
    bConfirmAction.onclick = async () => {
      if (!pendingConfirmAction) return;
      const action = pendingConfirmAction;
      pendingConfirmAction = null;
      bConfirmAction.disabled = true;
      bConfirmAction.textContent = 'Working…';
      try { await action(); } finally { closeConfirm(); }
    };

    bResetObs.onclick = async () => {
      const stats = await Engine.call('stats').catch(() => null);
      const n = stats && stats.repeat ? stats.repeat.distinct : 0;
      openConfirm(
        'Reset sighting counts for ' + n + ' observed words? Words stay indexed and keep their priority.',
        'Reset observations',
        async () => {
          const after = await Engine.call('resetObs');
          setStatus('Observation counts reset; ' + after.total.toLocaleString() + ' words retained');
          await refreshStats();
        },
      );
    };

    bClear.onclick = () => {
      openConfirm(
        'Clear the complete local wordlist cache? cRaCked will reload and download/index the dictionary again.',
        'Clear cache',
        async () => {
          const clientId = await idbGet(CLIENT_ID_STORE_KEY);
          await idbClearAll();
          if (CLIENT_ID_RE.test(String(clientId || ''))) {
            await idbSet(CLIENT_ID_STORE_KEY, clientId);
          }
          location.reload();
        },
      );
    };

    box.appendChild(el('hr'));

    const statusLine = el('div', { textContent: 'Dictionary: ' + lastStatus },
      'font-size:12px;text-align:center;margin-bottom:6px;');
    box.appendChild(statusLine);
    statusSinks.add(statusLine);

    const nextSync = el('div', { textContent: '…' }, 'font-size:12px;text-align:center;margin-bottom:6px;');
    box.appendChild(nextSync);

    const statsDiv = el('div', { textContent: 'Loading stats…' });
    statsDiv.className = '__crk_mono';
    statsDiv.style.cssText = 'margin:8px 0;';
    box.appendChild(statsDiv);

    box.appendChild(el('hr'));

    // ---- display -------------------------------------------------------
    const appearance = el('details');
    appearance.className = '__crk_settings_group';
    appearance.open = getBool(PREF.appearanceOpen, false);
    const appearanceSummary = el('summary', { textContent: 'Appearance & suggestions' });
    const appearanceBody = el('div');
    appearanceBody.className = '__crk_settings_group_body';
    appearance.appendChild(appearanceSummary);
    appearance.appendChild(appearanceBody);
    appearance.addEventListener('toggle', () => setBool(PREF.appearanceOpen, appearance.open));
    box.appendChild(appearance);

    const themeSel = el('select');
    themeSel.innerHTML = '<option value="dark">Black (default)</option><option value="light">White</option>';
    themeSel.value = themeName();
    themeSel.onchange = () => {
      const p = THEME_PRESETS[themeSel.value === 'light' ? 'light' : 'dark'];
      setStr(PREF.theme, themeSel.value);
      setStr(PREF.uiBg, p.uiBg); setStr(PREF.uiText, p.uiText); setStr(PREF.uiBorder, p.uiBorder);
      setStr(PREF.sugBg, p.sugBg); setStr(PREF.sugText, p.sugText); setStr(PREF.boxBg, p.boxBg);
      // No reload needed — CSS variables update live.
      refreshStyleVars();
      overlay.style.background = theme().overlayBg;
    };
    addRow(appearanceBody, 'Theme preset', themeSel);

    const maxSugIn = el('input');
    maxSugIn.type = 'number'; maxSugIn.min = '1'; maxSugIn.max = String(MAX_MAX_SUGGESTIONS);
    maxSugIn.value = String(maxSugPref());
    maxSugIn.oninput = () => {
      const n = clampInt(maxSugIn.value, MIN_MAX_SUGGESTIONS, MAX_MAX_SUGGESTIONS, DEFAULT_MAX_SUGGESTIONS);
      setInt(PREF.maxSug, n);
    };
    addRow(appearanceBody, 'Max suggestions', maxSugIn);

    const fontIn = el('input');
    fontIn.type = 'number'; fontIn.min = '5'; fontIn.max = '20';
    fontIn.value = String(getInt(PREF.sugFont, 10));
    fontIn.oninput = () => { setInt(PREF.sugFont, fontIn.value); refreshStyleVars(); };
    addRow(appearanceBody, 'Suggestion font size (px)', fontIn);

    const colorFields = [
      ['Suggestion text', PREF.sugText, 'sugText'],
      ['Suggestion background', PREF.sugBg, 'sugBg'],
      ['UI text', PREF.uiText, 'uiText'],
      ['UI background', PREF.uiBg, 'uiBg'],
      ['UI border', PREF.uiBorder, 'uiBorder'],
      ['Settings panel background', PREF.boxBg, 'boxBg'],
    ];
    for (const [label, key, presetKey] of colorFields) {
      const inp = el('input');
      inp.type = 'text';
      inp.value = getStr(key, preset()[presetKey]);
      inp.oninput = () => { setStr(key, inp.value.trim()); refreshStyleVars(); };
      addRow(appearanceBody, label, inp);
    }

    addCheck(appearanceBody, 'Show status badge', PREF.badge, true,
      on => { ensureBadge().style.display = on ? 'block' : 'none'; });

    box.appendChild(el('hr'));

    // ---- data / privacy -------------------------------------------------
    box.appendChild(el('div', {
      textContent: 'Community pool',
    }, 'font-size:13px;margin-bottom:4px;'));
    const poolToggleRow = el('div');
    poolToggleRow.className = '__crk_pool_toggle_row';
    const poolToggle = el('input');
    poolToggle.type = 'checkbox';
    poolToggle.checked = getBool(PREF.upload, true);
    poolToggle.setAttribute('aria-label', 'Share cracked passwords with the community pool');
    const poolSwitch = el('label');
    poolSwitch.className = '__crk_switch';
    poolSwitch.appendChild(poolToggle);
    poolSwitch.appendChild(el('span', { className: '__crk_switch_slider' }));
    poolToggleRow.appendChild(el('span', { textContent: 'Off' }));
    poolToggleRow.appendChild(poolSwitch);
    poolToggleRow.appendChild(el('span', { textContent: 'On' }));
    box.appendChild(poolToggleRow);
    poolToggle.onchange = () => {
      setBool(PREF.upload, poolToggle.checked);
      setTransientStatus('Community sharing ' + (poolToggle.checked ? 'enabled' : 'disabled'));
    };
    box.appendChild(el('div', {
      textContent: 'The switch controls sharing: when On, passwords you crack are uploaded to the shared pool at '
        + (CF_ORIGIN || 'the configured endpoint') + ' with the Torn target/type, service, '
        + 'and crack level so matching jobs can rank them more accurately. '
        + 'Off keeps your cracks local; you still receive the pool on sync.',
    }, 'font-size:11px;opacity:.8;margin-bottom:6px;'));
    box.appendChild(el('hr'));

    // ---- model ----------------------------------------------------------
    box.appendChild(el('div', { textContent: 'Model' }, 'font-size:13px;margin-bottom:4px;'));
    addCheck(box, 'Read guess verdicts from network responses (experimental)', PREF.netHook, false,
      () => setStatus('Reload the page to apply the network hook'));

    const paramsTa = el('textarea');
    paramsTa.rows = 6;
    paramsTa.className = '__crk_mono';
    paramsTa.style.cssText = 'width:100%;box-sizing:border-box;';
    paramsTa.placeholder = '{ "OBS_CTX_W": 3.0, "COVERAGE": { "10": 0.3 } }';
    paramsTa.value = getStr(PREF.params, '');
    const paramsMsg = el('div', {}, 'font-size:11px;opacity:.8;margin-top:2px;');
    box.appendChild(el('div', {
      textContent: 'Parameter overrides (JSON). Sweep these with the offline replay harness rather than by feel.',
    }, 'font-size:11px;opacity:.8;margin:6px 0 2px;'));
    box.appendChild(paramsTa);
    box.appendChild(paramsMsg);
    paramsTa.onchange = async () => {
      const raw = paramsTa.value.trim();
      if (raw) {
        try { JSON.parse(raw); } catch (e) { paramsMsg.textContent = 'Invalid JSON: ' + e.message; return; }
      }
      setStr(PREF.params, raw);
      await applyStoredParams();
      paramsMsg.textContent = 'Applied.';
      scanAll('params');
    };

    box.appendChild(el('hr'));

    // ---- buttons --------------------------------------------------------
    const btns = el('div', {}, 'display:flex;gap:8px;justify-content:center;flex-wrap:wrap;');
    box.appendChild(btns);

    const bExport = el('button', { textContent: 'Export telemetry (JSON)' });
    bExport.onclick = async () => {
      await loadTelemetry();
      let dump = {};
      try { dump = await Engine.call('dumpObs'); } catch (e) { /* ignore */ }
      const payload = {
        exportedAt: new Date().toISOString(),
        version: '2.2.8',
        params: getStr(PREF.params, ''),
        cracks: telemetry.cracks,
        guesses: telemetry.guesses,
        observations: dump.obs || {},
        contextObservations: dump.ctxObs || {},
        communityContextObservations: dump.poolCtxObs || {},
      };
      const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
      const a = el('a');
      a.href = URL.createObjectURL(blob);
      a.download = 'cracked-telemetry-' + Date.now() + '.json';
      document.body.appendChild(a);
      a.click();
      setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
    };
    btns.appendChild(bExport);

    const bClose = el('button', { textContent: 'Close' });
    btns.appendChild(bClose);

    box.appendChild(el('hr'));
    box.appendChild(el('div', {
      textContent: COMMUNITY_SYNC_ENABLED
        ? 'Packed dictionary + calibrated probability model — SirAua [3785905], MoDuL [4022159], TheDno [4443016]'
        : 'Local mode — set COMMUNITY_POOL_ORIGIN to enable the shared pool. SirAua [3785905], MoDuL [4022159], TheDno [4443016]',
    }, 'font-size:11px;text-align:center;opacity:.9;'));

    document.body.appendChild(overlay);

    /* stats */
    async function refreshStats() {
      try {
        const s = await Engine.call('stats');
        await loadTelemetry();
        const rep = s.repeat || { distinct: 0, total: 0, seenMoreThanOnce: 0, avgPerWord: 0 };
        const remote = await idbGet('cf_remote_count');
        const remoteContexts = Number(await idbGet('cf_context_count')) || 0;

        // These are browser-local measurements. The remote pool has one row
        // per word, so another device can add a sighting but cannot duplicate
        // a word in the downloaded dictionary.
        const cracks = telemetry.cracks;
        const n = cracks.length;
        const inBase = cracks.filter(c => c.foundSrc === 0).length;
        const repeat = cracks.filter(c => c.foundSrc === 1 || c.foundSrc === 2).length;
        const wrong = cracks.reduce((a, c) => a + (c.wrong || 0), 0);
        const covered = inBase + repeat;
        const coverageRate = n ? covered / n : 0;
        const percentage = value => n ? (100 * value / n).toFixed(1) + '%' : '—';
        const addLine = (label, value, options = {}) => {
          const line = el('div');
          line.className = '__crk_stat_line';
          if (options.sub) line.dataset.sub = 'true';
          const name = el('span', { textContent: label });
          const result = value && value.nodeType ? value : el('span', { textContent: String(value) });
          if (options.title) {
            name.title = options.title;
            result.title = options.title;
          }
          line.appendChild(name);
          line.appendChild(result);
          statsDiv.appendChild(line);
        };
        const addGap = () => statsDiv.appendChild(el('div', { className: '__crk_stat_gap' }));
        const addNote = text => statsDiv.appendChild(el('div', {
          className: '__crk_stat_note', textContent: text,
        }));

        statsDiv.replaceChildren();
        statsDiv.className = '__crk_mono __crk_stats';

        const perLength = el('span');
        perLength.className = '__crk_stat_per_length';
        for (const length of Object.keys(s.perLength).sort((a, b) => Number(a) - Number(b))) {
          const absolute = Math.max(0, Number(s.perLength[length]) || 0);
          const item = el('span');
          item.appendChild(document.createTextNode(length + ': '));
          item.appendChild(el('span', {
            className: '__crk_stat_compact',
            textContent: formatCompactNumber(absolute),
            title: absolute.toLocaleString() + ' indexed words',
          }));
          perLength.appendChild(item);
        }
        addLine('Stored per length', perLength,
          { title: 'Hover each compact count for its absolute value.' });
        addLine('Total indexed', Number(s.total || 0).toLocaleString()
          + (remote ? '   remote pool: ' + Number(remote).toLocaleString() : ''));
        if (remoteContexts) addLine('Shared type links', remoteContexts.toLocaleString());

        addGap();
        addLine('Observed on device', rep.distinct.toLocaleString() + ' distinct / '
          + rep.total.toLocaleString() + ' sightings'
          + (rep.distinct ? '  (avg ' + rep.avgPerWord.toFixed(2) + ')' : ''), {
          title: 'Distinct indexed passwords seen by this browser and their total sightings.',
        });
        addLine('Seen more than 1x', rep.seenMoreThanOnce.toLocaleString()
          + (rep.distinct ? '  (' + (100 * rep.seenMoreThanOnce / rep.distinct).toFixed(1)
            + '% of distinct)' : ''));

        addGap();
        addLine('Passwords cracked', n.toLocaleString(), {
          title: 'Completed passwords recorded in crack telemetry in this browser.',
        });
        addLine('Known before cracking', n ? covered.toLocaleString() + '  (' + percentage(covered) + ')' : '—', {
          title: 'Subtotal: passwords already present before their crack completed. The two indented rows are its breakdown.',
        });
        addLine('Base dictionary', n ? inBase.toLocaleString() + '  (' + percentage(inBase) + ')' : '—', {
          sub: true,
          title: 'Known beforehand from the bundled base dictionary.',
        });
        addLine('Shared / learned', n ? repeat.toLocaleString() + '  (' + percentage(repeat) + ')' : '—', {
          sub: true,
          title: 'Known beforehand from the community pool or from an earlier crack learned on this device.',
        });
        if (n) addLine('Mean wrong guesses', (wrong / n).toFixed(2) + ' per password');

        const assessment = n
          ? 'Before solving, cRaCked already knew ' + (100 * coverageRate).toFixed(1)
            + '% of these passwords. This is prior coverage, not current storage: a new password '
            + 'counts as unknown for that crack and is learned only after completion, so this value is not expected to be 100%.'
          : 'No completed passwords have been recorded in this browser yet.';
        addNote(assessment + (n > 0 && n < 30
          ? ' The sample is still small; about 30 cracks will make the percentage more useful.' : ''));
        addNote('“Known before cracking” is the subtotal; “Base dictionary” and “Shared / learned” are its breakdown.');
        addNote('Crack metrics are local to this browser; shared words and type links are de-duplicated remotely.');
      } catch (e) {
        statsDiv.textContent = 'Stats unavailable: ' + (e.message || e);
      }
    }

    async function refreshNextSync() {
      if (!COMMUNITY_SYNC_ENABLED) { nextSync.textContent = 'Community sync: disabled'; return; }
      const lastCheck = Number(await idbGet('cf_last_meta_check_ts')) || 0;
      const lastDownload = Number(await idbGet('cf_last_download_ts')) || 0;
      const remain = lastCheck + SYNC_CHECK_INTERVAL_MS - Date.now();
      const pend = Number(await idbGet('cf_pending_delta')) || 0;
      const forcedIn = lastDownload ? lastDownload + SYNC_MAX_WAIT_MS - Date.now() : 0;
      nextSync.textContent = (remain <= 0 ? 'Next pool check: now' : 'Next pool check in ' + formatDuration(remain))
        + (pend ? ' (+' + pend + ' pending; update by '
          + (forcedIn <= 0 ? 'next check' : formatDuration(forcedIn)) + ')' : '');
    }

    await refreshStats();
    await refreshNextSync();
    const t1 = setInterval(refreshNextSync, 1000);
    const t2 = setInterval(refreshStats, 15000);

    bClose.onclick = () => {
      statusSinks.delete(statusLine);
      clearInterval(t1); clearInterval(t2);
      overlay.remove();
    };
    overlay.addEventListener('click', (e) => { if (e.target === overlay) bClose.onclick(); });
  }

  function injectMenuButton() {
    if (!runtimeStarted || !isCrackingPage()) return;
    if (document.getElementById('__crack_menu_btn')) return;
    const header = document.querySelector(SEL.appHeader);
    if (!header) return;
    const btn = el('button', {
      id: '__crack_menu_btn',
      textContent: 'cRaCked 2.2.8 — tap-to-slot suggestions (click for settings)',
    });
    btn.onclick = showSettings;
    header.appendChild(btn);
    ensureBadge();
  }

  /* ==================================================================== */
  /* Runtime                                                              */
  /* ==================================================================== */

  let runtimeStarted = false;

  function cleanupView() {
    closeSuggestionModal(false);
    const btn = document.getElementById('__crack_menu_btn');
    if (btn) btn.remove();
    document.querySelectorAll('.__crk_panel').forEach(p => p.remove());
    if (listObserver) { listObserver.disconnect(); listObserver = null; }
    if (statusEl) { statusEl.remove(); statusEl = null; }
  }

  function startRuntime() {
    if (runtimeStarted) return;
    runtimeStarted = true;

    // 1.3.1 stored rig-planner preferences that no longer exist.
    ['crack_rig_skill_level', 'crack_rig_inv_HPCPU', 'crack_rig_inv_ECPU', 'crack_rig_inv_CPU',
      'crack_rig_inv_FAN', 'crack_rig_inv_WB', 'crack_rig_inv_HS', 'crack_rig_inv_PSU']
      .forEach(k => localStorage.removeItem(k));

    injectStyle();
    ensureBadge();
    setStatus('Initializing…');
    installNetHook();

    bootstrapDictionary().catch(e => { log('bootstrap failed', e); setStatus('Init failed'); });
    startObservers();
    scanAll('start');
    injectMenuButton();
  }

  function stopRuntime() {
    runtimeStarted = false;
    if (safetyTimer) { clearInterval(safetyTimer); safetyTimer = null; }
    cleanupView();
  }

  function onRouteChange() {
    if (isCrackingPage()) startRuntime();
    else stopRuntime();
  }

  startRigStrengthObserver();
  onRouteChange();
  window.addEventListener('hashchange', onRouteChange);
  window.addEventListener('popstate', onRouteChange);

  const pushState = history.pushState;
  const replaceState = history.replaceState;
  history.pushState = function () {
    const r = pushState.apply(this, arguments);
    setTimeout(onRouteChange, 0);
    return r;
  };
  history.replaceState = function () {
    const r = replaceState.apply(this, arguments);
    setTimeout(onRouteChange, 0);
    return r;
  };
})();