cRaCked

Cracking helper with community word pool, probability-ranked suggestions, encrypted-slot awareness, and safe positional estimates

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         cRaCked
// @namespace    modul.torn.cracking
// @version      1.3.1
// @description  Cracking helper with community word pool, probability-ranked suggestions, encrypted-slot awareness, and safe positional estimates
// @author       SirAua [3785905] & MoDuL [4022159]
// @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      gitlab.com
// @connect      pp-api.sokin.xyz
// @run-at       document-idle
// @license      mit
// ==/UserScript==

(function () {
    'use strict';

    function isCrackingPage() {
        return location.pathname === '/page.php'
            && new URLSearchParams(location.search).get('sid') === 'crimes'
            && location.hash === '#/cracking';
    }

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

    /* --------------------------
       Config
       -------------------------- */
    const debug = false;
    const UPDATE_INTERVAL = 800;
    const MIN_LENGTH = 4;
    const MAX_LENGTH = 10;

    const WORDLIST_URL =
        'https://gitlab.com/kalilinux/packages/seclists/-/raw/kali/master/Passwords/Common-Credentials/Pwdb_top-1000000.txt?ref_type=heads';

    const DOWNLOAD_MIN_DELTA = 20;

    const COMMUNITY_POOL_ORIGIN = 'https://pp-api.sokin.xyz/torn-crack-pool';
    const COMMUNITY_SYNC_ENABLED = true;
    const CF_WORKER_ORIGIN = COMMUNITY_SYNC_ENABLED ? COMMUNITY_POOL_ORIGIN.replace(/\/+$/, '') : '';

    const CF_ADD_WORD_URL = COMMUNITY_SYNC_ENABLED ? `${CF_WORKER_ORIGIN}/submit` : '';
    const CF_STORAGE_BASE = COMMUNITY_SYNC_ENABLED ? `${CF_WORKER_ORIGIN}/words` : '';
    const METADATA_URL = COMMUNITY_SYNC_ENABLED ? `${CF_STORAGE_BASE}/metadata.json` : '';

    /* --------------------------
        Rate-limiting / batching
        -------------------------- */
    const SYNC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
    const OUTBOX_FLUSH_INTERVAL_MS = 5 * 1000;
    const OUTBOX_POST_INTERVAL_MS = 2000;
    const OUTBOX_BATCH_SIZE = 5;

    const DB_NAME = 'crack';
    const STORE_NAME = 'dictionary';
    const STATUS_PREF_KEY = 'crack_show_badge';
    const EXCL_STORAGE_PREFIX = 'crack_excl_';

    // theme + UI preferences
    const THEME_PREF_KEY = 'crack_theme'; // 'dark' | 'light'

    const PREF_SUG_FONT_PX = 'crack_sug_font_px';
    const PREF_SUG_TEXT_COLOR = 'crack_sug_text_color';
    const PREF_SUG_BG_COLOR = 'crack_sug_bg_color';

    const PREF_UI_TEXT_COLOR = 'crack_ui_text_color';
    const PREF_UI_BG_COLOR = 'crack_ui_bg_color';
    const PREF_UI_BORDER_COLOR = 'crack_ui_border_color';

    const PREF_UI_BOX_BG_COLOR = 'crack_ui_box_color';
    const PREF_SHOW_SUG_ON_COMPLETE = 'crack_show_sug_on_complete';
    const PREF_SHOW_ADVANCED_OPT_COLOR = 'crack_show_advanced_opt_color';
    const PREF_MAX_SUGGESTIONS = 'crack_max_suggestions_size';
    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',
        },
    };

    /* --------------------------
       State
       -------------------------- */
    let dict = [];
    let dictRevision = 0;
    let dictLoaded = false;
    let dictLoading = false;
    let remoteWords = new Set();
    let statusEl = null;
    const prevRowStates = new Map();
    const panelUpdateTimers = new Map();
    const LAST_INPUT = { key: null, time: 0 };

    let outboxFlushTimer = null;
    let lastOutboxPost = 0;
    let wordObservationCounts = null;
    let contextObservationCounts = null;
    let observationWriteChain = Promise.resolve();

    let scanInterval = null;
    let menuInterval = null;
    let runtimeStarted = false;
    let autoSyncTimer = null;
    let autoSyncInFlight = false;

    /* --------------------------
       Utils
       -------------------------- */
    function crackLog(...args) { if (debug) console.log('[Crack]', ...args); }

    function isPdaUserAgentCheck() {
        return window.navigator.userAgent.toLowerCase().includes("com.manuito.tornpda");
    }

    function isCompactCrackingView() {
        return isPdaUserAgentCheck() || window.innerWidth <= 700;
    }

    function getBoolPref(key, def = true) {
        const v = localStorage.getItem(key);
        return v === null ? def : v === '1';
    }

    function setBoolPref(key, val) {
        localStorage.setItem(key, val ? '1' : '0');
    }

    function getStrPref(key, def = '') {
        const v = localStorage.getItem(key);
        return v === null ? def : String(v);
    }

    function setStrPref(key, val) {
        localStorage.setItem(key, String(val));
    }

    function getIntPref(key, def = 0) {
        const v = localStorage.getItem(key);
        const n = v === null ? NaN : parseInt(v, 10);
        return Number.isFinite(n) ? n : def;
    }

    function setIntPref(key, val) {
        const n = Number(val);
        localStorage.setItem(key, String(Number.isFinite(n) ? Math.trunc(n) : 0));
    }

    function clampInt(n, min, max, def) {
        n = Number(n);
        if (!Number.isFinite(n)) n = def;
        return Math.max(min, Math.min(max, Math.trunc(n)));
    }

    function getClampedIntPref(key, def, min, max) {
        return clampInt(getIntPref(key, def), min, max, def);
    }

    function splitMiddle(str) {
        const mid = Math.ceil(str.length / 2);
        return str.slice(0, mid) + "\n" + str.slice(mid);
    }

    function normalizeThemeName(v) {
        return v === 'light' ? 'light' : 'dark';
    }

    function getThemePreset() {
        const name = normalizeThemeName(getStrPref(THEME_PREF_KEY, 'dark'));
        return { name, ...THEME_PRESETS[name] };
    }

    function getTheme() {
        const preset = getThemePreset();
        return {
            name: preset.name,
            uiBg: getStrPref(PREF_UI_BG_COLOR, preset.uiBg),
            uiText: getStrPref(PREF_UI_TEXT_COLOR, preset.uiText),
            uiBorder: getStrPref(PREF_UI_BORDER_COLOR, preset.uiBorder),
            overlayBg: preset.overlayBg,
            boxBg: getStrPref(PREF_UI_BOX_BG_COLOR, preset.boxBg),
            sugBg: getStrPref(PREF_SUG_BG_COLOR, preset.sugBg),
            sugText: getStrPref(PREF_SUG_TEXT_COLOR, preset.sugText),
            sugFontPx: getIntPref(PREF_SUG_FONT_PX, 10),
        };
    }

    function removeLegacyRigPreferences() {
        const keys = [
            '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'
        ];
        for (const key of keys) localStorage.removeItem(key);
    }

    function applyPreset(themeName) {
        const name = normalizeThemeName(themeName);
        const p = THEME_PRESETS[name];
        setStrPref(THEME_PREF_KEY, name);
        setStrPref(PREF_UI_BG_COLOR, p.uiBg);
        setStrPref(PREF_UI_TEXT_COLOR, p.uiText);
        setStrPref(PREF_UI_BORDER_COLOR, p.uiBorder);
        setStrPref(PREF_SUG_BG_COLOR, p.sugBg);
        setStrPref(PREF_SUG_TEXT_COLOR, p.sugText);
        setStrPref(PREF_UI_BOX_BG_COLOR, p.boxBg);
        if (localStorage.getItem(PREF_SUG_FONT_PX) === null) setIntPref(PREF_SUG_FONT_PX, 10);
    }

    function applyStatusBadgeTheme(el) {
        const t = getTheme();
        if (!el) return;
        el.style.background = t.uiBg;
        el.style.color = t.uiText;
        el.style.border = `1px solid ${t.uiBorder}`;
    }

    function applyMenuButtonTheme(btn) {
        const t = getTheme();
        if (!btn) return;
        btn.style.background = t.uiBg;
        btn.style.color = t.uiText;
        btn.style.border = `1px solid ${t.uiBorder}`;
        btn.style.borderRadius = '4px';
    }

    function appplyMenuInputTheme(inpt) {
        const t = getTheme();
        if (!inpt) return;
        inpt.style.background = t.uiBg;
        inpt.style.color = t.uiText;
        inpt.style.border = `1px solid ${t.uiBorder}`;
        inpt.style.borderRadius = '4px';
    }

    function styleSugSpan(sp) {
        const t = getTheme();
        const compact = isCompactCrackingView();
        sp.style.padding = '2px 4px';
        sp.style.margin = '0 2px';
        sp.style.display = 'inline-block';
        sp.style.borderRadius = '3px';
        sp.style.fontSize = `${compact ? Math.min(t.sugFontPx, 9) : t.sugFontPx}px`;
        sp.style.color = t.sugText;
        sp.style.maxWidth = compact ? '100%' : '';
        sp.style.overflowWrap = compact ? 'anywhere' : '';
        sp.style.whiteSpace = compact ? 'normal' : '';
    }

    function styleMessageChip(sp) {
        styleSugSpan(sp);
        const state = sp?.dataset?.msgState || '';
        if (state === 'no-match') sp.style.color = '#ff5555';
        else if (state === 'complete') sp.style.color = '#82c91e';
        else sp.style.color = '#ffd75a';
        sp.style.background = 'transparent';
        sp.style.border = 'none';
        sp.style.fontWeight = state === 'complete' ? 'bold' : 'normal';
    }

    function applyPanelTheme(panel) {
        const t = getTheme();
        if (!panel) return;
        const compact = isCompactCrackingView();
        panel.style.background = t.sugBg;
        panel.style.color = t.sugText;
        panel.style.fontSize = `${compact ? Math.min(t.sugFontPx, 9) : t.sugFontPx}px`;
        panel.style.textAlign = 'center';
        panel.style.position = 'absolute';
        panel.style.zIndex = '9999';

        const listDiv = panel.querySelector(':scope > div');
        if (!listDiv) return;
        listDiv.style.gap = compact ? '1px' : '2px';
        listDiv.style.lineHeight = compact ? '1.05' : '1.2';
        for (const child of Array.from(listDiv.children)) {
            const kind = child.dataset ? child.dataset.kind : '';
            if (kind === 'msg') {
                styleMessageChip(child);
            } else if (kind === 'sug' || kind === 'prob-best' || kind === 'prob-position') {
                styleSugSpan(child);
            }
            if (kind === 'prob-best') {
                child.style.fontWeight = 'bold';
                child.style.border = `1px solid ${t.uiBorder}`;
            }
        }
    }

    function positionPanelBesideWord(row, panel) {
        if (!row || !panel) return;

        const firstSlot = row.querySelector('[class^="charSlot"]:not([class*="charSlotDummy"])');
        if (!firstSlot) return;

        const rowRect = row.getBoundingClientRect();
        const slotRect = firstSlot.getBoundingClientRect();
        if (!rowRect.width || !rowRect.height || !slotRect.width) return;

        if (getComputedStyle(row).position === 'static') row.style.position = 'relative';

        const compact = isCompactCrackingView();
        const listDiv = panel.querySelector(':scope > div');
        const slotTop = Math.floor(slotRect.top - rowRect.top);
        const availableWidth = Math.floor(slotRect.left - rowRect.left - 8);
        const rowWidth = Math.max(80, Math.floor(rowRect.width - 8));
        const safeLeftWidth = Math.max(0, availableWidth - 4);

        panel.dataset.layout = compact ? 'compact' : 'desktop';
        panel.style.boxSizing = 'border-box';
        panel.style.overflowX = 'hidden';
        panel.style.overflowY = 'auto';
        panel.style.whiteSpace = 'normal';

        if (compact) {
            const topStripHeight = Math.max(0, slotTop - 6);

            if (safeLeftWidth >= 52) {
                panel.style.left = '4px';
                panel.style.right = 'auto';
                panel.style.top = '4px';
                panel.style.bottom = 'auto';
                panel.style.width = `${safeLeftWidth}px`;
                panel.style.maxWidth = `${safeLeftWidth}px`;
                panel.style.maxHeight = `${topStripHeight >= 24 ? topStripHeight : Math.max(28, Math.floor(rowRect.height - 8))}px`;
                if (listDiv) {
                    listDiv.style.flexDirection = 'column';
                    listDiv.style.alignItems = 'stretch';
                    listDiv.style.flexWrap = 'nowrap';
                }
                return;
            }

            // TornPDA can have no safe left gutter. In that case keep the panel above
            // the first password slot instead of crossing over the slot row.
            panel.style.left = '4px';
            panel.style.right = 'auto';
            panel.style.top = '2px';
            panel.style.bottom = 'auto';
            panel.style.width = `${rowWidth}px`;
            panel.style.maxWidth = `${rowWidth}px`;
            panel.style.maxHeight = `${Math.max(24, Math.min(42, topStripHeight || 32))}px`;
            if (listDiv) {
                listDiv.style.flexDirection = 'row';
                listDiv.style.alignItems = 'center';
                listDiv.style.flexWrap = 'wrap';
            }
            return;
        }

        // Keep the probability panel entirely to the left of Torn's first character slot.
        // This preserves the actual cracking word and the brute-force controls.
        const usableWidth = Math.max(120, availableWidth - 4);
        const usableHeight = Math.max(30, Math.floor(rowRect.height - 8));

        panel.style.left = '4px';
        panel.style.right = 'auto';
        panel.style.top = '4px';
        panel.style.bottom = 'auto';
        panel.style.width = `${Math.min(usableWidth, Math.max(120, availableWidth))}px`;
        panel.style.maxWidth = `${Math.max(120, availableWidth)}px`;
        panel.style.maxHeight = `${usableHeight}px`;
        if (listDiv) {
            listDiv.style.flexDirection = 'row';
            listDiv.style.alignItems = 'center';
            listDiv.style.flexWrap = 'wrap';
        }
    }

    function applyThemeEverywhere() {
        applyStatusBadgeTheme(statusEl);
        const btn = document.getElementById('__crack_menu_btn');
        if (btn) applyMenuButtonTheme(btn);
        for (const panel of document.querySelectorAll('.__crackhelp_panel')) {
            applyPanelTheme(panel);
        }
    }

    function addHr(parent, theme0, count = 1, margin = "12px 0") {
        for (let i = 0; i < count; i++) {
            const hr = document.createElement("hr");
            hr.style.cssText = `border:none; border-top:1px solid ${theme0.uiBorder}; margin:${margin};`;
            parent.appendChild(hr);
        }
    }

    function ensureStatusBadge() {
        if (statusEl) return statusEl;
        statusEl = document.createElement('div');
        statusEl.id = '__crack_status';
        statusEl.style.cssText = `
            position: fixed; right: 10px; bottom: 40px; z-index: 10000;
            padding:6px 8px; font-size:11px; font-family:monospace; opacity:0.9;
            border-radius:6px;
        `;
        statusEl.textContent = 'Dictionary: Idle';
        document.body.appendChild(statusEl);

        applyStatusBadgeTheme(statusEl);

        const show = getBoolPref(STATUS_PREF_KEY, true);
        statusEl.style.display = show ? 'block' : 'none';
        return statusEl;
    }

    const __statusSinks = new Set();
    function registerStatusSink(el) { if (el) __statusSinks.add(el); }
    function unregisterStatusSink(el) { if (el) __statusSinks.delete(el); }

    function setStatus(msg) {
        const text = `Dictionary: ${msg}`;
        const badge = ensureStatusBadge();
        if (badge.textContent !== text) badge.textContent = text;
        __statusSinks.forEach(el => { if (el && el.textContent !== text) el.textContent = text; });
        crackLog('STATUS →', msg);
    }

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

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

    function isGzipPath(pathOrUrl) {
        try {
            const s = String(pathOrUrl || '');
            const clean = s.split('?')[0];
            return /\.gz$/i.test(clean);
        } catch (_) {
            return false;
        }
    }

    async function gunzipArrayBufferToText(arrayBuffer) {
        if (!arrayBuffer) return '';
        if (typeof DecompressionStream !== 'function') {
            throw new Error('Your browser does not support DecompressionStream(gzip). Upload an uncompressed snapshot/diff (plain .txt / .ndjson) or use a modern browser.');
        }
        const ds = new DecompressionStream('gzip');
        const stream = new Blob([arrayBuffer]).stream().pipeThrough(ds);
        return await new Response(stream).text();
    }

    async function responseToText(res, pathOrUrl) {
        if (isGzipPath(pathOrUrl)) {
            return await gunzipArrayBufferToText(res.response);
        }
        return res.responseText || '';
    }

    function metadataURL(force = false) {
        if (!COMMUNITY_SYNC_ENABLED || !METADATA_URL) return '';
        const ts = force ? Date.now() : Math.floor(Date.now() / 60000);
        return `${METADATA_URL}?cb=${ts}`;
    }

    function formatShortDuration(ms) {
        if (ms <= 0) return 'now';
        const s = Math.floor(ms / 1000);
        const d = Math.floor(s / 86400);
        const h = Math.floor((s % 86400) / 3600);
        const m = Math.floor((s % 3600) / 60);
        const 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 cleanupView() {
        const btn = document.getElementById('__crack_menu_btn');
        if (btn && btn.parentNode) btn.parentNode.removeChild(btn);

        const panels = document.querySelectorAll('.__crackhelp_panel');
        if (panels) panels.forEach(p => p.remove());

        for (const timer of panelUpdateTimers.values()) clearTimeout(timer);
        panelUpdateTimers.clear();
        prevRowStates.clear();

        if (statusEl && statusEl.parentNode) {
            statusEl.parentNode.removeChild(statusEl);
            statusEl = null;
        }
    }

    /* --------------------------
       IndexedDB
       -------------------------- */
    function openDB() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, 1);
            request.onupgradeneeded = () => {
                const db = request.result;
                if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
            };
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

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

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

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

    async function clearLocalDictCache() {
        await idbClear();
        crackLog('Cleared cached dictionary from IndexedDB');
        setStatus('Cleared cache — reload');
    }

    /* --------------------------
       Key capture
       -------------------------- */
    function captureKey(k) {
        if (!k) return;
        const m = String(k).match(/^[A-Za-z0-9._]$/);
        if (!m) return;
        LAST_INPUT.key = k.toUpperCase();
        LAST_INPUT.time = performance.now();
    }

    window.addEventListener('keydown', (e) => {
        if (e.metaKey || e.ctrlKey || e.altKey) return;
        captureKey(e.key);
    }, true);

    /* --------------------------
       Dictionary load
       -------------------------- */
    async function commitBucketsToIDB(buckets) {
        let changed = false;
        for (const lenStr of Object.keys(buckets)) {
            const L = Number(lenStr);
            const newArr = Array.from(buckets[lenStr]);
            let existing = await idbGet(`len_${L}`);
            if (!existing) existing = [];
            const merged = Array.from(new Set([...existing, ...newArr]));
            if (merged.length !== existing.length) changed = true;
            await idbSet(`len_${L}`, merged);
            dict[L] = merged;
        }
        if (changed) dictRevision++;
    }

    async function fetchAndIndex(url, onProgress) {
        setStatus('Downloading base wordlist …');
        let res;
        try {
            res = await gmRequest({ method: 'GET', url, timeout: 90000, responseType: 'text' });
        } catch (e) {
            throw e;
        }

        if (res.status < 200 || res.status >= 300 || !res.responseText) {
            const err = new Error(`Bad response from base wordlist: ${res.status}`);
            err.status = res.status;
            throw err;
        }

        setStatus('Indexing…');

        const lines = (res.responseText || '').split(/\r?\n/);
        const buckets = {};
        let processed = 0;

        for (const raw of lines) {
            processed++;
            const word = (raw || '').trim().toUpperCase();
            if (!word) continue;
            if (!/^[A-Z0-9_.]+$/.test(word)) continue;
            const L = word.length;
            if (L < MIN_LENGTH || L > MAX_LENGTH) continue;
            if (!buckets[L]) buckets[L] = new Set();
            buckets[L].add(word);

            if (processed % 5000 === 0 && typeof onProgress === 'function') {
                onProgress({ phase: '1M-index', processed, pct: null });
                await new Promise(r => setTimeout(r, 0));
            }
        }

        await commitBucketsToIDB(buckets);

        const perLengthCounts = {};
        for (let L = MIN_LENGTH; L <= MAX_LENGTH; L++) {
            perLengthCounts[L] = (await idbGet(`len_${L}`))?.length || 0;
        }

        setStatus('1M cached');
        return { totalProcessed: processed, perLengthCounts };
    }

    function needReloadAfterBaseLoad() {
        try {
            if (sessionStorage.getItem('__crack_base_reload_done') === '1') return false;
            sessionStorage.setItem('__crack_base_reload_done', '1');
            return true;
        } catch {
            return true;
        }
    }

    async function loadDict() {
        if (dictLoaded || dictLoading) return;
        dictLoading = true;
        setStatus('Loading from cache…');

        let hasData = false;
        dict = [];
        for (let len = MIN_LENGTH; len <= MAX_LENGTH; len++) {
            const chunk = await idbGet(`len_${len}`);
            if (chunk && chunk.length) {
                dict[len] = chunk;
                hasData = true;
            }
        }

        if (!hasData) {
            crackLog('No cache found. Downloading dictionary…');
            const MAX_TRIES = 4;
            const DELAYS = [0, 3000, 10000, 30000];
            let ok = false, lastErr = null;

            for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
                try {
                    await fetchAndIndex(WORDLIST_URL, ({ phase, processed }) => {
                        if (phase === '1M-index') setStatus(`Indexing 1M… processed ${processed}`);
                    });
                    ok = true;
                    break;
                } catch (e) {
                    lastErr = e;
                    const wait = DELAYS[Math.min(attempt, DELAYS.length - 1)];
                    crackLog(`Base download failed (try ${attempt + 1}/${MAX_TRIES})`, e);
                    setStatus(`Download failed (try ${attempt + 1}/${MAX_TRIES}) — retrying in ${Math.ceil(wait / 1000)}s…`);
                    if (wait) await new Promise(r => setTimeout(r, wait));
                }
            }

            if (!ok) {
                crackLog('Giving up on base download for now.', lastErr);
                dictLoading = false;
                dictLoaded = false;
                setTimeout(() => { loadDict().catch(() => {}); }, 60000);
                setStatus('Failed to fetch base wordlist (will retry)...');
                return;
            }

            if (needReloadAfterBaseLoad()) {
                setStatus('Dictionary cached — reloading…');
                setTimeout(() => location.reload(), 120);
                return;
            }
        } else {
            crackLog('Dictionary loaded from IndexedDB');
        }

        dictLoaded = true;
        dictLoading = false;
        setStatus(COMMUNITY_SYNC_ENABLED ? 'Ready' : 'Ready (local mode)');
    }

    async function fetchRemoteMeta(force = false) {
        if (!COMMUNITY_SYNC_ENABLED) {
            await idbSet('cf_pending_delta', 0);
            return { count: 0, etag: '', snapshot_path: null, diff_path: null, generated_at: null, disabled: true };
        }
        try {
            const lastSync = Number(await idbGet('cf_last_sync_ts')) || 0;
            const now = Date.now();

            if (!force && (now - lastSync) < SYNC_MIN_INTERVAL_MS) {
                crackLog('Skipping fetchRemoteMeta (recent sync)');
                const cachedMeta = await idbGet('cf_metadata') || {};
                return {
                    count: cachedMeta.count || Number(await idbGet('cf_remote_count')) || 0,
                    etag: '',
                    snapshot_path: cachedMeta.snapshot_path || null,
                    diff_path: cachedMeta.diff_path || null,
                    generated_at: cachedMeta.generated_at || null
                };
            }

            const metaUrl = metadataURL(force);
            crackLog('Fetching metadata.json ->', metaUrl);
            const metaRes = await gmRequest({ method: 'GET', url: metaUrl, timeout: 10000, responseType: 'text' });

            if (metaRes.status !== 200) {
                crackLog('metadata.json not available; using cached meta only', metaRes.status);
                const cachedMeta = await idbGet('cf_metadata') || {};
                return {
                    count: cachedMeta.count || Number(await idbGet('cf_remote_count')) || 0,
                    etag: '',
                    snapshot_path: cachedMeta.snapshot_path || null,
                    diff_path: cachedMeta.diff_path || null,
                    generated_at: cachedMeta.generated_at || null
                };
            }

            const meta = JSON.parse(metaRes.responseText || '{}');
            const toSave = {
                count: meta.count || 0,
                etag: '',
                snapshot_path: meta.snapshot_path || meta.latest_path || null,
                diff_path: meta.diff_path || null,
                generated_at: meta.generated_at || null
            };

            await idbSet('cf_metadata', toSave);
            await idbSet('cf_remote_count', toSave.count);
            await idbSet('cf_last_sync_ts', Date.now());

            return {
                count: toSave.count,
                etag: '',
                snapshot_path: toSave.snapshot_path,
                diff_path: toSave.diff_path,
                generated_at: toSave.generated_at
            };
        } catch (e) {
            crackLog('fetchRemoteMeta failed:', e);
            return {
                count: Number(await idbGet('cf_remote_count')) || 0,
                etag: '',
                snapshot_path: null,
                diff_path: null,
                generated_at: null
            };
        }
    }

    async function downloadCommunityWordlist(meta, ifNoneMatchEtag) {
        if (!COMMUNITY_SYNC_ENABLED) return 0;
        try {
            if (!meta || !meta.snapshot_path) {
                crackLog('No snapshot_path in metadata.');
                return 0;
            }

            const snapshotUrl = `${CF_STORAGE_BASE}/${meta.snapshot_path}`;
            crackLog('Fetching snapshot ->', snapshotUrl);

            const headers = {};
            if (ifNoneMatchEtag) headers['If-None-Match'] = ifNoneMatchEtag;

            const isGz = isGzipPath(meta.snapshot_path);
            const res = await gmRequest({
                method: 'GET',
                url: snapshotUrl,
                headers,
                timeout: 45000,
                responseType: isGz ? 'arraybuffer' : 'text'
            });

            const remoteEtag = getHeader(res.responseHeaders, 'ETag') || '';
            if (remoteEtag) await idbSet('cf_remote_etag', remoteEtag);

            if (res.status === 304) {
                crackLog('Snapshot unchanged (304)');
                await idbSet('cf_last_downloaded_count', meta.count || 0);
                await idbSet('cf_last_sync_ts', Date.now());
                return 0;
            }

            if (res.status !== 200) {
                crackLog('Snapshot fetch failed, status:', res.status);
                return 0;
            }

            const text = await responseToText(res, meta.snapshot_path);
            setStatus('Indexing snapshot…');
            const lines = text.split(/\r?\n/);
            const buckets = {};
            let processed = 0;

            for (const raw of lines) {
                processed++;
                const word = (raw || '').trim().toUpperCase();
                if (!word) continue;
                if (!/^[A-Z0-9_.]+$/.test(word)) continue;
                const L = word.length;
                if (L < MIN_LENGTH || L > MAX_LENGTH) continue;
                if (!buckets[L]) buckets[L] = new Set();
                buckets[L].add(word);
                if (processed % 5000 === 0) await new Promise(r => setTimeout(r, 0));
            }

            await commitBucketsToIDB(buckets);
            setStatus('Snapshot indexed');

            await idbSet('cf_remote_count', meta.count || 0);
            await idbSet('cf_last_downloaded_count', meta.count || 0);
            await idbSet('cf_last_sync_ts', Date.now());

            await idbSet('cf_metadata', {
                snapshot_path: meta.snapshot_path,
                diff_path: meta.diff_path || null,
                count: meta.count || 0,
                generated_at: meta.generated_at || null,
                etag: remoteEtag || ''
            });

            return 1;
        } catch (e) {
            crackLog('downloadCommunityWordlist failed:', e);
            return 0;
        }
    }

    async function checkRemoteAndMaybeDownload(force = false) {
        if (!COMMUNITY_SYNC_ENABLED) {
            await idbSet('cf_pending_delta', 0);
            setStatus('Ready (local mode)');
            return 0;
        }
        const meta = await fetchRemoteMeta(force);

        const lastDownloaded = (await idbGet('cf_last_downloaded_count')) || 0;
        const remoteCount = meta.count || Number(await idbGet('cf_remote_count')) || 0;
        const delta = Math.max(0, remoteCount - lastDownloaded);

        if (!force && delta < DOWNLOAD_MIN_DELTA) {
            crackLog(`Skip download: delta=${delta} < ${DOWNLOAD_MIN_DELTA}`);
            await idbSet('cf_pending_delta', delta);
            return 0;
        }

        setStatus(force ? 'Manual sync…' : `Syncing (+${delta})…`);
        const etag = (await idbGet('cf_remote_etag')) || '';
        const added = await downloadCommunityWordlist(meta, etag);
        await idbSet('cf_pending_delta', 0);
        return added;
    }

    async function msUntilEligibleSync() {
        if (!COMMUNITY_SYNC_ENABLED) return Number.POSITIVE_INFINITY;
        const last = Number(await idbGet('cf_last_sync_ts')) || 0;
        const remain = last + SYNC_MIN_INTERVAL_MS - Date.now();
        return Math.max(0, remain);
    }

    function startAutoSyncHeartbeat() {
        if (!COMMUNITY_SYNC_ENABLED) return;
        if (autoSyncTimer) return;
        autoSyncTimer = setInterval(async () => {
            if (autoSyncInFlight) return;
            if (!runtimeStarted) return;
            if (!isCrackingPage()) return;

            try {
                const remain = await msUntilEligibleSync();
                if (remain > 0) return;

                autoSyncInFlight = true;
                setStatus('Auto-syncing community words…');

                const added = await checkRemoteAndMaybeDownload(false);

                const remoteCount = await idbGet('cf_remote_count');
                const delta = await idbGet('cf_pending_delta');
                if (added && added > 0) {
                    setStatus(`Ready (+${added}, remote: ${remoteCount})`);
                } else {
                    setStatus(`Ready (remote ${remoteCount}${delta ? `, +${delta} pending` : ''})`);
                }
            } catch (e) {
                crackLog('Auto-sync failed', e);
                setStatus('Ready');
            } finally {
                autoSyncInFlight = false;
            }
        }, 1000);
    }

    /* --------------------------
       Outbox
       -------------------------- */
    async function enqueueOutbox(word) {
        if (!COMMUNITY_SYNC_ENABLED) return;
        if (!word) return;
        const w = word.toUpperCase();
        let out = await idbGet('cf_outbox') || [];
        if (!out.includes(w)) {
            out.push(w);
            await idbSet('cf_outbox', out);
            crackLog('Enqueued word to outbox:', w);
            ensureOutboxFlushScheduled();
        }
    }

    function ensureOutboxFlushScheduled() {
        if (outboxFlushTimer) return;
        outboxFlushTimer = setTimeout(flushOutbox, OUTBOX_FLUSH_INTERVAL_MS);
    }

    async function flushOutbox() {
        outboxFlushTimer = null;
        if (!COMMUNITY_SYNC_ENABLED) {
            await idbSet('cf_outbox', []);
            return;
        }
        let out = await idbGet('cf_outbox') || [];
        if (!out || out.length === 0) return;

        while (out.length > 0) {
            const batch = out.splice(0, OUTBOX_BATCH_SIZE);
            const now = Date.now();
            const sinceLast = now - lastOutboxPost;
            if (sinceLast < OUTBOX_POST_INTERVAL_MS) {
                await new Promise(r => setTimeout(r, OUTBOX_POST_INTERVAL_MS - sinceLast));
            }

            try {
                await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'POST',
                        url: CF_ADD_WORD_URL,
                        headers: { 'Content-Type': 'application/json' },
                        data: JSON.stringify({ words: batch }),
                        onload: (res) => (res.status >= 200 && res.status < 300) ? resolve(res) : reject(res),
                        onerror: reject,
                        ontimeout: reject,
                        timeout: 15000
                    });
                });

                crackLog('Flushed outbox batch:', batch.length);
                for (const w of batch) {
                    remoteWords.add(w);
                    await addWordToLocalCache(w);
                }
            } catch (e) {
                crackLog('Batch POST failed, falling back to single POSTs', e);
                for (const w of batch) {
                    try {
                        await new Promise((resolve, reject) => {
                            GM_xmlhttpRequest({
                                method: 'POST',
                                url: CF_ADD_WORD_URL,
                                headers: { 'Content-Type': 'application/json' },
                                data: JSON.stringify({ word: w }),
                                onload: (r) => (r.status >= 200 && r.status < 300) ? resolve(r) : reject(r),
                                onerror: reject,
                                ontimeout: reject,
                                timeout: 10000
                            });
                        });

                        crackLog('Flushed outbox (single):', w);
                        remoteWords.add(w);
                        await addWordToLocalCache(w);
                        await new Promise(r => setTimeout(r, OUTBOX_POST_INTERVAL_MS));
                    } catch (ee) {
                        crackLog('Single POST failed for', w, ee);
                        out.unshift(w);
                        break;
                    }
                }
            }

            lastOutboxPost = Date.now();
            await idbSet('cf_outbox', out);
        }
    }

    /* --------------------------
       Exclusions + suggestions
       -------------------------- */
    function loadExclusions(rowKey, len) {
        const raw = sessionStorage.getItem(EXCL_STORAGE_PREFIX + rowKey + '_' + len);
        let arr = [];
        if (raw) {
            try { arr = JSON.parse(raw); } catch {}
        }

        const out = new Array(len);
        for (let i = 0; i < len; i++) {
            const s = Array.isArray(arr[i]) ? arr[i] : (typeof arr[i] === 'string' ? arr[i].split('') : []);
            out[i] = new Set(s.map(c => String(c || '').toUpperCase()).filter(Boolean));
        }
        return out;
    }

    function saveExclusions(rowKey, len, sets) {
        const arr = new Array(len);
        for (let i = 0; i < len; i++) arr[i] = Array.from(sets[i] || new Set());
        sessionStorage.setItem(EXCL_STORAGE_PREFIX + rowKey + '_' + len, JSON.stringify(arr));
    }

    function schedulePanelUpdate(panel) {
        if (!panel) return;
        const key = panel.dataset.rowkey;
        if (panelUpdateTimers.has(key)) clearTimeout(panelUpdateTimers.get(key));
        panelUpdateTimers.set(key, setTimeout(() => {
            panel.updateSuggestions();
            panelUpdateTimers.delete(key);
        }, 50));
    }

    function addExclusion(rowKey, pos, letter, len) {
        letter = String(letter || '').toUpperCase();
        if (!letter) return;
        const sets = loadExclusions(rowKey, len);
        if (!sets[pos]) sets[pos] = new Set();
        const before = sets[pos].size;
        sets[pos].add(letter);
        if (sets[pos].size !== before) {
            saveExclusions(rowKey, len, sets);
            const panel = document.querySelector(`.__crackhelp_panel[data-rowkey="${rowKey}"]`);
            schedulePanelUpdate(panel);
        }
    }

    function formatProbability(probability) {
        const pct = Math.max(0, Number(probability) || 0) * 100;
        if (pct >= 10) return `${pct.toFixed(0)}%`;
        if (pct >= 1) return `${pct.toFixed(1)}%`;
        if (pct >= 0.1) return `${pct.toFixed(2)}%`;
        return '<0.1%';
    }

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

    function getRowContext(crimeOption) {
        const wrapper = crimeOption.querySelector('[class^="typeAndService"]');
        const spans = wrapper ? Array.from(wrapper.querySelectorAll('span')) : [];
        const titleEl = spans[0] || crimeOption.querySelector('span[class^="type"]');
        const serviceEl = spans[1] || crimeOption.querySelector('span[class^="service"]');
        const title = String(titleEl?.textContent || '').replace(/\s+/g, ' ').trim();
        const service = String(serviceEl?.textContent || '').replace(/\s+/g, ' ').trim();
        const titleKey = normalizeContextPart(title);
        const serviceKey = normalizeContextPart(service);
        return {
            title,
            service,
            key: titleKey || serviceKey ? `${titleKey}||${serviceKey}` : ''
        };
    }

    function getGuessesLeft(crimeOption) {
        const section = crimeOption.querySelector('[class^="guessesSection"]');
        if (!section) return null;

        const label = String(section.getAttribute('aria-label') || '').trim();
        if (/^No guesses\b/i.test(label)) return 0;
        const match = label.match(/^(\d+)\s+guess(?:es)?\b/i);
        if (match) return Number(match[1]);
        return null;
    }

    function readSlotState(slot, index) {
        const discoveredEl = slot.querySelector('[class^="discoveredChar"]');
        let char = String(discoveredEl?.textContent || '').trim().toUpperCase();
        if (!/^[A-Z0-9._]$/.test(char)) char = '*';

        const className = typeof slot.className === 'string'
            ? slot.className
            : String(slot.getAttribute('class') || '');
        const aria = String(slot.getAttribute('aria-label') || '');
        const layerMatch = aria.match(/\b(\d+)\s+encryption\b/i);
        const encryptionLayers = layerMatch
            ? Math.max(0, Number(layerMatch[1]) || 0)
            : (className.includes('hasEncryption') ? 1 : 0);
        const encrypted = encryptionLayers > 0 || className.includes('hasEncryption');
        const hidden = char === '*';
        const hasInput = !!slot.querySelector('input[type="text"]');

        return {
            index,
            char,
            hidden,
            encrypted,
            encryptionLayers,
            open: hidden && !encrypted,
            hasInput,
            guessable: hidden && !encrypted && hasInput
        };
    }

    function buildRowActionState(crimeOption, slotStates, guessesLeft, context) {
        const openPositions = slotStates.filter(slot => slot.open).map(slot => slot.index);
        const actionablePositions = guessesLeft === 0
            ? []
            : Number.isFinite(guessesLeft)
                ? openPositions
                : slotStates.filter(slot => slot.guessable).map(slot => slot.index);
        const signature = [
            Number.isFinite(guessesLeft) ? guessesLeft : '?',
            context.key,
            slotStates.map(slot => `${slot.char}:${slot.encryptionLayers}:${slot.hasInput ? 1 : 0}`).join('|')
        ].join('::');

        return {
            guessesLeft,
            openPositions,
            actionablePositions,
            unknownCount: slotStates.filter(slot => slot.hidden).length,
            contextKey: context.key,
            title: context.title,
            service: context.service,
            signature
        };
    }

    async function getContextObservationCounts() {
        if (contextObservationCounts && typeof contextObservationCounts === 'object') {
            return contextObservationCounts;
        }
        const stored = await idbGet('context_word_observations');
        contextObservationCounts = stored && typeof stored === 'object' && !Array.isArray(stored)
            ? stored
            : {};
        return contextObservationCounts;
    }

    async function getWordObservationCounts() {
        if (wordObservationCounts && typeof wordObservationCounts === 'object') {
            return wordObservationCounts;
        }
        const stored = await idbGet('word_observations');
        wordObservationCounts = stored && typeof stored === 'object' && !Array.isArray(stored)
            ? stored
            : {};
        return wordObservationCounts;
    }

    function recordWordObservation(word, contextKey = '') {
        const normalized = String(word || '').trim().toUpperCase();
        const normalizedContext = String(contextKey || '').trim();
        if (!normalized) return Promise.resolve(0);

        observationWriteChain = observationWriteChain.then(async () => {
            const counts = await getWordObservationCounts();
            const next = Math.max(0, Number(counts[normalized]) || 0) + 1;
            counts[normalized] = next;
            await idbSet('word_observations', counts);

            if (normalizedContext) {
                const contextual = await getContextObservationCounts();
                if (!contextual[normalizedContext] || typeof contextual[normalizedContext] !== 'object') {
                    contextual[normalizedContext] = {};
                }
                contextual[normalizedContext][normalized] =
                    Math.max(0, Number(contextual[normalizedContext][normalized]) || 0) + 1;
                await idbSet('context_word_observations', contextual);
            }

            return next;
        }).catch((err) => {
            crackLog('Failed to record word observation:', normalized, err);
            return 0;
        });

        return observationWriteChain;
    }

    async function analyzePattern(pattern, rowKey, actionablePositions = [], contextKey = '') {
        const len = pattern.length;
        const empty = { suggestions: [], positions: [], bestGuess: null, matchCount: 0, totalWeight: 0 };
        if (len < MIN_LENGTH || len > MAX_LENGTH) return empty;

        if (!dict[len]) {
            const chunk = await idbGet(`len_${len}`);
            if (!chunk) return empty;
            dict[len] = chunk;
        }

        const maxSug = getClampedIntPref(
            PREF_MAX_SUGGESTIONS,
            DEFAULT_MAX_SUGGESTIONS,
            MIN_MAX_SUGGESTIONS,
            MAX_MAX_SUGGESTIONS
        );
        const exSets = loadExclusions(rowKey, len);
        const exclusions = exSets.map(set => Array.from(set || []));
        const observations = await getWordObservationCounts();
        const allContextObservations = await getContextObservationCounts();
        const contextObservations = contextKey && allContextObservations[contextKey]
            ? allContextObservations[contextKey]
            : {};

        const workerUrl = URL.createObjectURL(new Blob([`
            function patternMatches(word, pattern) {
                if (!word || word.length !== pattern.length) return false;
                for (let i = 0; i < pattern.length; i++) {
                    if (pattern[i] !== '*' && pattern[i] !== word[i]) return false;
                }
                return true;
            }

            function addTopCandidate(top, candidate, max) {
                let lo = 0;
                let hi = top.length;
                while (lo < hi) {
                    const mid = (lo + hi) >> 1;
                    const cur = top[mid];
                    if (cur.weight > candidate.weight ||
                        (cur.weight === candidate.weight && cur.rank < candidate.rank)) {
                        lo = mid + 1;
                    } else {
                        hi = mid;
                    }
                }
                top.splice(lo, 0, candidate);
                if (top.length > max) top.pop();
            }

            self.onmessage = function(e) {
                const {
                    dictChunk,
                    pattern,
                    max,
                    exclusions,
                    observations,
                    contextObservations,
                    actionablePositions
                } = e.data;
                const patternText = String(pattern || '');
                const unknownPositions = Array.from(new Set(actionablePositions || []))
                    .filter(position => Number.isInteger(position) && position >= 0 &&
                        position < patternText.length && patternText[position] === '*');

                const letterWeights = unknownPositions.map(() => Object.create(null));
                const top = [];
                let totalWeight = 0;
                let matchCount = 0;

                for (let rank = 0; rank < dictChunk.length; rank++) {
                    const word = dictChunk[rank];
                    if (!patternMatches(word, patternText)) continue;

                    let excluded = false;
                    for (let i = 0; i < word.length; i++) {
                        const blocked = exclusions[i];
                        if (blocked && blocked.includes(word[i])) {
                            excluded = true;
                            break;
                        }
                    }
                    if (excluded) continue;

                    // The source dictionary is popularity-ranked but has no raw frequency counts.
                    // Global Torn observations improve the prior, while same target/service
                    // observations receive a stronger contextual boost.
                    const observed = Math.max(0, Number(observations[word]) || 0);
                    const contextObserved = Math.max(0, Number(contextObservations[word]) || 0);
                    const rankWeight = 1 / Math.sqrt(rank + 25);
                    const observationBoost = 1 +
                        (3 * Math.log2(1 + observed)) +
                        (8 * Math.log2(1 + contextObserved));
                    const weight = rankWeight * observationBoost;

                    totalWeight += weight;
                    matchCount++;

                    for (let p = 0; p < unknownPositions.length; p++) {
                        const ch = word[unknownPositions[p]];
                        letterWeights[p][ch] = (letterWeights[p][ch] || 0) + weight;
                    }

                    addTopCandidate(top, { word, weight, rank, observed, contextObserved }, max);
                }

                const suggestions = top.map(item => ({
                    word: item.word,
                    probability: totalWeight > 0 ? item.weight / totalWeight : 0,
                    rank: item.rank + 1,
                    observed: item.observed,
                    contextObserved: item.contextObserved
                }));

                const positions = unknownPositions.map((position, idx) => {
                    const letters = Object.entries(letterWeights[idx])
                        .map(([letter, weight]) => ({
                            letter,
                            probability: totalWeight > 0 ? weight / totalWeight : 0
                        }))
                        .sort((a, b) => b.probability - a.probability || a.letter.localeCompare(b.letter));
                    return { position, letters: letters.slice(0, 5) };
                });

                let bestGuess = null;
                for (const pos of positions) {
                    const first = pos.letters[0];
                    if (!first) continue;
                    if (!bestGuess || first.probability > bestGuess.probability) {
                        bestGuess = {
                            position: pos.position,
                            letter: first.letter,
                            probability: first.probability
                        };
                    }
                }

                self.postMessage({ suggestions, positions, bestGuess, matchCount, totalWeight });
            };
        `], { type: 'application/javascript' }));

        const worker = new Worker(workerUrl);

        return await new Promise((resolve) => {
            let done = false;
            const finish = (value) => {
                if (done) return;
                done = true;
                try { worker.terminate(); } catch (_) {}
                try { URL.revokeObjectURL(workerUrl); } catch (_) {}
                resolve(value || empty);
            };

            worker.onmessage = (e) => finish(e.data);
            worker.onerror = () => finish(empty);
            worker.postMessage({
                dictChunk: dict[len],
                pattern: pattern.toUpperCase(),
                max: maxSug,
                exclusions,
                observations,
                contextObservations,
                actionablePositions
            });
        });
    }

    function prependPanelToRow(row, pat, rowKey, rowState) {
        let panel = row.querySelector('.__crackhelp_panel');
        const state = rowState || {
            guessesLeft: null,
            openPositions: [],
            actionablePositions: [],
            unknownCount: 0,
            contextKey: '',
            title: '',
            service: '',
            signature: ''
        };

        if (!panel) {
            panel = document.createElement('div');
            panel.className = '__crackhelp_panel';
            panel.dataset.rowkey = rowKey;
            panel.dataset.pattern = pat;
            panel.dataset.dictRevision = String(dictRevision);
            panel.dataset.stateSignature = state.signature || '';
            panel._rowState = state;
            panel._seq = 0;
            panel.style.cssText = 'text-align:center; position:absolute; z-index:9999; white-space:normal;';
            panel.style.border = `1px solid ${getTheme().uiBorder}`;
            panel.style.borderRadius = '4px';

            const listDiv = document.createElement('div');
            listDiv.style.cssText = 'padding:2px; display:flex; align-items:center; justify-content:flex-start; align-content:flex-start; flex-wrap:wrap; gap:2px; line-height:1.2;';
            panel.appendChild(listDiv);

            function addChip(text, kind, title = '', messageState = '') {
                const sp = document.createElement('span');
                sp.dataset.kind = kind;
                if (messageState) sp.dataset.msgState = messageState;
                sp.textContent = text;
                sp.title = title;
                if (kind === 'msg') styleMessageChip(sp);
                else styleSugSpan(sp);
                if (kind === 'prob-best') {
                    sp.style.fontWeight = 'bold';
                    sp.style.border = `1px solid ${getTheme().uiBorder}`;
                } else if (kind === 'prob-position') {
                    sp.style.opacity = '0.95';
                }
                listDiv.appendChild(sp);
                return sp;
            }

            panel.updateSuggestions = async function () {
                const curPat = panel.dataset.pattern || '';
                const curRowKey = panel.dataset.rowkey;
                const currentState = panel._rowState || state;
                const guessesLeft = currentState.guessesLeft;
                const openPositions = currentState.openPositions || [];
                const actionablePositions = currentState.actionablePositions || [];

                const showOnComplete = getBoolPref(PREF_SHOW_SUG_ON_COMPLETE, true);
                if (!showOnComplete && curPat && !curPat.includes('*')) {
                    if (listDiv.childNodes.length) listDiv.innerHTML = '';
                    return;
                }

                applyPanelTheme(panel);

                if (!dictLoaded && dictLoading) {
                    listDiv.innerHTML = '';
                    addChip('(loading dictionary…)', 'msg', '', 'loading');
                    return;
                }

                const seq = ++panel._seq;
                const result = await analyzePattern(
                    curPat,
                    curRowKey,
                    actionablePositions,
                    currentState.contextKey || ''
                );
                if (seq !== panel._seq) return;

                listDiv.innerHTML = '';

                const isComplete = curPat && !curPat.includes('*');
                if (isComplete) {
                    addChip('PASSWORD FOUND', 'msg', 'The complete password is visible.', 'complete');
                } else if (guessesLeft === 0) {
                    addChip(
                        'NO GUESSES LEFT — BRUTE FORCE ONLY',
                        'msg',
                        `${openPositions.length} unencrypted hidden slot${openPositions.length === 1 ? '' : 's'} remain, but Torn reports no guesses left.`,
                        'no-guesses'
                    );
                } else if (currentState.unknownCount > 0 && openPositions.length === 0) {
                    addChip(
                        'NO OPEN SLOTS — BRUTE FORCE REQUIRED',
                        'msg',
                        'Every remaining hidden slot is still encrypted.',
                        'blocked'
                    );
                }

                if (!result || result.matchCount === 0) {
                    addChip(
                        dictLoaded ? '(no matches)' : '(loading dictionary…)',
                        'msg',
                        '',
                        dictLoaded ? 'no-match' : 'loading'
                    );
                    return;
                }

                if (result.bestGuess && guessesLeft !== 0) {
                    const best = result.bestGuess;
                    addChip(
                        `★ TRY ${best.position + 1}:${best.letter} ${formatProbability(best.probability)}`,
                        'prob-best',
                        `Highest estimated success chance among currently unencrypted, guessable slots and ${result.matchCount.toLocaleString()} matching words.`
                    );
                }

                const positionSummaries = [];
                const positionDetails = [];
                for (const pos of result.positions) {
                    if (!pos.letters || !pos.letters.length) continue;
                    const top = pos.letters[0];
                    positionSummaries.push(`${pos.position + 1}:${top.letter} ${formatProbability(top.probability)}`);
                    positionDetails.push(
                        `Position ${pos.position + 1}: ` + pos.letters
                            .map(x => `${x.letter} ${formatProbability(x.probability)}`)
                            .join(' · ')
                    );
                }
                if (positionSummaries.length && guessesLeft !== 0) {
                    addChip(
                        `LETTERS ${positionSummaries.join(' · ')}`,
                        'prob-position',
                        positionDetails.join('\n')
                    );
                }

                if (result.suggestions.length) {
                    const sep = document.createElement('span');
                    sep.textContent = '|';
                    sep.style.opacity = '0.65';
                    listDiv.appendChild(sep);
                }

                for (const item of result.suggestions) {
                    const observedText = item.observed
                        ? ` · observed globally ${item.observed} time${item.observed === 1 ? '' : 's'}`
                        : '';
                    const contextText = item.contextObserved
                        ? ` · observed for this target/service ${item.contextObserved} time${item.contextObserved === 1 ? '' : 's'}`
                        : '';
                    addChip(
                        `${item.word} ${formatProbability(item.probability)}`,
                        'sug',
                        `Estimated share of current matches · source rank ${item.rank.toLocaleString()}${observedText}${contextText}`
                    );
                }
            };

            row.prepend(panel);
            applyPanelTheme(panel);
            positionPanelBesideWord(row, panel);
            schedulePanelUpdate(panel);
        } else {
            const patternChanged = panel.dataset.pattern !== pat;
            const dictionaryChanged = panel.dataset.dictRevision !== String(dictRevision);
            const stateChanged = panel.dataset.stateSignature !== (state.signature || '');
            panel.dataset.pattern = pat;
            panel.dataset.dictRevision = String(dictRevision);
            panel.dataset.stateSignature = state.signature || '';
            panel._rowState = state;
            applyPanelTheme(panel);
            positionPanelBesideWord(row, panel);
            if (patternChanged || dictionaryChanged || stateChanged) schedulePanelUpdate(panel);
        }

        return panel;
    }

    async function isWordInLocalDict(word) {
        const len = word.length;
        if (!dict[len]) {
            const chunk = await idbGet(`len_${len}`);
            if (!chunk) return false;
            dict[len] = chunk;
        }
        return dict[len].includes(word);
    }

    async function addWordToLocalCache(word) {
        const len = word.length;
        if (len < MIN_LENGTH || len > MAX_LENGTH) return;
        let chunk = await idbGet(`len_${len}`);
        if (!chunk) chunk = [];
        if (!chunk.includes(word)) {
            chunk.push(word);
            await idbSet(`len_${len}`, chunk);
            if (!dict[len]) dict[len] = [];
            if (!dict[len].includes(word)) dict[len].push(word);
            dictRevision++;
            crackLog('Added to local cache:', word);
        }
    }

    function getRowKey(crimeOption) {
        if (!crimeOption.dataset.crackKey) {
            crimeOption.dataset.crackKey = String(Date.now()) + '-' + Math.floor(Math.random() * 100000);
        }
        return crimeOption.dataset.crackKey;
    }

    function resetRowTracking(crimeOption) {
        const oldKey = crimeOption.dataset.crackKey;
        if (oldKey) {
            prevRowStates.delete(oldKey);
            const panel = crimeOption.querySelector('.__crackhelp_panel');
            if (panel && panel.parentNode) {
                const key = panel.dataset.rowkey;
                if (panelUpdateTimers.has(key)) {
                    clearTimeout(panelUpdateTimers.get(key));
                    panelUpdateTimers.delete(key);
                }
                panel.parentNode.removeChild(panel);
            }
        }
        delete crimeOption.dataset.crackCompletedWord;
        crimeOption.dataset.crackKey = String(Date.now()) + '-' + Math.floor(Math.random() * 100000);
        return crimeOption.dataset.crackKey;
    }

    function attachSlotSensors(crimeOption) {
        if (crimeOption.dataset.crackDelegated === '1') return;
        crimeOption.dataset.crackDelegated = '1';

        const slotSelector = '[class^="charSlot"]:not([class*="charSlotDummy"])';

        const rememberGuess = (event) => {
            const target = event.target;
            if (!target || !target.matches || !target.matches('input[type="text"]')) return;
            const slot = target.closest(slotSelector);
            if (!slot || !crimeOption.contains(slot)) return;

            const slots = Array.from(crimeOption.querySelectorAll(slotSelector));
            const index = slots.indexOf(slot);
            if (index < 0) return;

            const raw = event.type === 'keydown' ? event.key : target.value;
            const letter = String(raw || '').slice(-1).toUpperCase();
            if (!/^[A-Z0-9._]$/.test(letter)) return;

            const rowKey = getRowKey(crimeOption);
            const existing = prevRowStates.get(rowKey) || {
                chars: slots.map(item => readSlotState(item, 0).char),
                guessesLeft: getGuessesLeft(crimeOption)
            };
            existing.pendingGuess = {
                i: index,
                letter,
                time: performance.now(),
                guessesBefore: Number.isFinite(existing.guessesLeft)
                    ? existing.guessesLeft
                    : getGuessesLeft(crimeOption)
            };
            prevRowStates.set(rowKey, existing);
            captureKey(letter);
        };

        crimeOption.addEventListener('keydown', rememberGuess, true);
        crimeOption.addEventListener('input', rememberGuess, true);
    }

    function scanCrimePage() {
        if (!runtimeStarted) return;
        if (!isCrackingPage()) {
            cleanupView();
            return;
        }

        const currentCrime = document.querySelector('[class^="currentCrime"]');
        if (!currentCrime) return;

        const container = currentCrime.querySelector('[class^="virtualList"]');
        if (!container) return;

        const crimeOptions = container.querySelectorAll('[class^="crimeOptionWrapper"]');

        for (const crimeOption of crimeOptions) {
            let rowKey = getRowKey(crimeOption);
            attachSlotSensors(crimeOption);

            const charSlots = Array.from(
                crimeOption.querySelectorAll('[class^="charSlot"]:not([class*="charSlotDummy"])')
            );
            if (!charSlots.length) continue;

            const slotStates = charSlots.map((slot, index) => readSlotState(slot, index));
            const curChars = slotStates.map(slot => slot.char);
            const patText = curChars.join('');
            const guessesLeft = getGuessesLeft(crimeOption);
            const context = getRowContext(crimeOption);
            let actionState = buildRowActionState(crimeOption, slotStates, guessesLeft, context);

            const now = performance.now();
            const len = curChars.length;
            let prev = prevRowStates.get(rowKey) || {
                chars: Array(len).fill('*'),
                guessesLeft,
                contextKey: context.key,
                pendingGuess: null
            };

            // Torn's virtual list recycles row elements. A changed length, changed target/service,
            // or a completed row becoming fresh again means this DOM node now represents a new job.
            const lengthChanged = prev.chars && prev.chars.length !== len;
            const contextChanged = !!(prev.contextKey && context.key && prev.contextKey !== context.key);
            const prevWasComplete = prev.chars && prev.chars.length === len && prev.chars.every(ch => ch !== '*');
            const nowIsFreshHidden = curChars.length === len && curChars.every(ch => ch === '*');
            if (lengthChanged || contextChanged || (prevWasComplete && nowIsFreshHidden)) {
                rowKey = resetRowTracking(crimeOption);
                prev = {
                    chars: Array(len).fill('*'),
                    guessesLeft,
                    contextKey: context.key,
                    pendingGuess: null
                };
                actionState = buildRowActionState(crimeOption, slotStates, guessesLeft, context);
            }

            let pendingGuess = prev.pendingGuess || null;
            if (pendingGuess && pendingGuess.i >= 0 && pendingGuess.i < len) {
                const age = now - pendingGuess.time;
                const currentChar = curChars[pendingGuess.i];
                const guessesDropped = Number.isFinite(pendingGuess.guessesBefore) &&
                    Number.isFinite(guessesLeft) &&
                    guessesLeft < pendingGuess.guessesBefore;
                const wrongFlash = String(charSlots[pendingGuess.i].className || '')
                    .includes('incorrectGuessFlash');

                if (currentChar !== '*') {
                    // The server returned a character, therefore the guess was correct.
                    pendingGuess = null;
                } else if (guessesDropped || (age >= 100 && wrongFlash)) {
                    // The slot stayed hidden and Torn consumed a guess: record a confirmed miss.
                    addExclusion(rowKey, pendingGuess.i, pendingGuess.letter, len);
                    pendingGuess = null;
                } else if (age > 4000) {
                    // Avoid carrying a stale pending guess after a failed/cancelled request.
                    pendingGuess = null;
                }
            }

            prevRowStates.set(rowKey, {
                chars: curChars,
                guessesLeft,
                contextKey: context.key,
                pendingGuess,
                slotSignature: actionState.signature,
                time: now
            });

            if (!patText.includes('*')) {
                const newWord = patText.toUpperCase();
                if (!/^[A-Z0-9_.]+$/.test(newWord)) {
                    crackLog('Revealed word contains invalid chars. skipping:', newWord);
                } else if (crimeOption.dataset.crackCompletedWord !== newWord) {
                    // Count one observation per completed Torn row, not once per 800 ms scan.
                    crimeOption.dataset.crackCompletedWord = newWord;
                    (async () => {
                        await recordWordObservation(newWord, context.key);

                        const localHas = await isWordInLocalDict(newWord);
                        const supHas = remoteWords.has(newWord);
                        if (!localHas && !supHas) {
                            await addWordToLocalCache(newWord);
                            await enqueueOutbox(newWord);
                        } else if (supHas && !localHas) {
                            await addWordToLocalCache(newWord);
                        }

                        const panel = crimeOption.querySelector('.__crackhelp_panel');
                        if (panel && panel.updateSuggestions) schedulePanelUpdate(panel);
                    })();
                }
            }

            const showOnComplete = getBoolPref(PREF_SHOW_SUG_ON_COMPLETE, true);
            const isComplete = patText && !patText.includes('*');
            if (isComplete && !showOnComplete) {
                const existing = crimeOption.querySelector('.__crackhelp_panel');
                if (existing) {
                    const key = existing.dataset.rowkey;
                    if (panelUpdateTimers.has(key)) {
                        clearTimeout(panelUpdateTimers.get(key));
                        panelUpdateTimers.delete(key);
                    }
                    existing.remove();
                }
            } else if (!/^[*]+$/.test(patText)) {
                prependPanelToRow(crimeOption, patText, rowKey, actionState);
            }
        }
    }

    /* --------------------------
       Settings UI
       -------------------------- */
    async function showMenuOverlay() {
        const theme0 = getTheme();

        const overlay = document.createElement('div');
        overlay.style.cssText = `
            position: fixed; inset: 0;
            width: 100%; height: 100%;
            background: ${theme0.overlayBg};
            color: ${theme0.uiText};
            font-size: 14px;
            padding: 12px;
            box-sizing: border-box;
            overflow: auto;
            overflow-x: hidden;
            -webkit-overflow-scrolling: touch;
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 2147483647;
            overflow: hidden;
            overscroll-behavior: contain;
            touch-action: none;
        `;

        const box = document.createElement('div');
        box.style.cssText = `
            background: ${theme0.boxBg};
            padding: 18px;
            border: 1px solid ${theme0.uiBorder};
            border-radius: 10px;
            text-align: left;
            width: min(520px, 92vw);
            max-width: 520px;
            box-sizing: border-box;
            max-height: calc(100vh - 24px);
            overflow: auto;
            overflow-x: hidden;
            -webkit-overflow-scrolling: touch;
            touch-action: pan-y;
        `;

        const isMobile = isPdaUserAgentCheck();
        if (isMobile) {
            overlay.style.fontSize = '13px';
            overlay.style.padding = '10px';
            overlay.style.alignItems = 'flex-start';

            box.style.padding = '12px';
            box.style.width = '100%';
            box.style.maxWidth = '100%';
            box.style.marginTop = '10px';
            box.style.marginBottom = '10px';
        }

        const title = document.createElement('div');
        title.textContent = 'Settings (BETA)';
        title.style.cssText = `margin-bottom:12px; font-size:20px; color:${theme0.uiText}; text-align:center;`;
        if (isMobile) title.style.fontSize = '18px';
        box.appendChild(title);

        addHr(box, theme0, 2);

        const statusLine = document.createElement('div');
        statusLine.style.cssText = `color:${theme0.uiText}; font-size:12px; margin-bottom:8px; text-align:center;`;
        if (isMobile) statusLine.style.fontSize = '11px';
        statusLine.textContent = ensureStatusBadge().textContent;
        registerStatusSink(statusLine);
        box.appendChild(statusLine);

        const nextSyncDiv = document.createElement('div');
        nextSyncDiv.style.cssText = `color:${theme0.uiText}; font-size:12px; margin-bottom:8px; text-align:center;`;
        if (isMobile) nextSyncDiv.style.fontSize = '11px';
        nextSyncDiv.textContent = 'Calculating next sync time…';
        box.appendChild(nextSyncDiv);

        const wordCountDiv = document.createElement('div');
        wordCountDiv.style.cssText = `color:${theme0.uiText}; font-size:12px; margin-bottom:10px; text-align:center;`;
        if (isMobile) wordCountDiv.style.fontSize = '11px';
        wordCountDiv.style.whiteSpace = "pre-line";
        wordCountDiv.textContent = 'Loading dictionary stats...';
        box.appendChild(wordCountDiv);

        addHr(box, theme0, 1);

        function addRow(labelText, controlEl, parent = box) {
            const row = document.createElement('div');
            row.style.cssText = 'display:flex; align-items:center; justify-content:space-between; gap:12px; margin:6px 0; flex-wrap:wrap;';
            const lab = document.createElement('div');
            lab.textContent = labelText;
            lab.style.cssText = 'font-size:12px; opacity:0.95; min-width:0; flex: 1 1 auto;';

            if (isMobile) {
                row.style.flexDirection = 'column';
                row.style.alignItems = 'stretch';
                row.style.gap = '6px';
                row.style.justifyContent = 'flex-start';
                lab.style.textAlign = 'left';
                lab.style.fontSize = '11px';
            }

            row.appendChild(lab);

            if (controlEl) {
                const tag = (controlEl.tagName || '').toLowerCase();
                const type = (controlEl.type || '').toLowerCase();
                const isCheckLike = tag === 'input' && (type === 'checkbox' || type === 'radio');

                if (isMobile && !isCheckLike) {
                    controlEl.style.width = '100%';
                    controlEl.style.maxWidth = '100%';
                    controlEl.style.boxSizing = 'border-box';
                }

                row.appendChild(controlEl);
            }

            parent.appendChild(row);
            return { row, lab, controlEl };
        }

        const themeSel = document.createElement('select');
        themeSel.style.cssText = 'padding:4px 6px; font-size:12px;';
        appplyMenuInputTheme(themeSel);
        themeSel.innerHTML = `<option value="dark">Black (default)</option><option value="light">White</option>`;
        themeSel.value = normalizeThemeName(getStrPref(THEME_PREF_KEY, 'dark'));
        addRow('Theme preset', themeSel);

        const maxSug = document.createElement('input');
        maxSug.type = 'number';
        maxSug.min = '1';
        maxSug.max = String(MAX_MAX_SUGGESTIONS);
        maxSug.step = '1';
        maxSug.value = String(getClampedIntPref(PREF_MAX_SUGGESTIONS, DEFAULT_MAX_SUGGESTIONS, MIN_MAX_SUGGESTIONS, MAX_MAX_SUGGESTIONS));
        maxSug.style.cssText = 'width:90px; padding:4px 6px; font-size:12px;';
        appplyMenuInputTheme(maxSug);
        addRow('Max. Suggestions', maxSug);

        const advColorWrap = document.createElement('div');
        advColorWrap.style.cssText = 'display:flex; flex-direction:column; gap:8px; margin-top:6px;';
        box.appendChild(advColorWrap);

        function setAdvancedColorsVisible(on) {
            advColorWrap.style.display = on ? '' : 'none';
        }

        const boxBg = document.createElement('input');
        boxBg.type = 'text';
        boxBg.value = getStrPref(PREF_UI_BOX_BG_COLOR, getThemePreset().boxBg);
        boxBg.placeholder = '#000';
        boxBg.style.cssText = 'width:140px; padding:4px 6px; font-size:12px;';
        appplyMenuInputTheme(boxBg);
        addRow('Settings Main color', boxBg, advColorWrap);

        const fontSize = document.createElement('input');
        fontSize.type = 'number';
        fontSize.min = '5';
        fontSize.max = '20';
        fontSize.step = '1';
        fontSize.value = String(getIntPref(PREF_SUG_FONT_PX, 10));
        fontSize.style.cssText = 'width:90px; padding:4px 6px; font-size:12px;';
        appplyMenuInputTheme(fontSize);
        addRow('Suggestion font size (px)', fontSize, advColorWrap);

        const sugText = document.createElement('input');
        sugText.type = 'text';
        sugText.value = getStrPref(PREF_SUG_TEXT_COLOR, getThemePreset().sugText);
        sugText.placeholder = '#0f0';
        sugText.style.cssText = 'width:140px; padding:4px 6px; font-size:12px;';
        appplyMenuInputTheme(sugText);
        addRow('Suggestion text color', sugText, advColorWrap);

        const sugBg = document.createElement('input');
        sugBg.type = 'text';
        sugBg.value = getStrPref(PREF_SUG_BG_COLOR, getThemePreset().sugBg);
        sugBg.placeholder = '#000 or transparent';
        sugBg.style.cssText = 'width:140px; padding:4px 6px; font-size:12px;';
        appplyMenuInputTheme(sugBg);
        addRow('Suggestion panel background', sugBg, advColorWrap);

        const uiText = document.createElement('input');
        uiText.type = 'text';
        uiText.value = getStrPref(PREF_UI_TEXT_COLOR, getThemePreset().uiText);
        uiText.placeholder = '#0f0';
        uiText.style.cssText = 'width:140px; padding:4px 6px; font-size:12px;';
        appplyMenuInputTheme(uiText);
        addRow('Overall UI text color', uiText, advColorWrap);

        const uiBg = document.createElement('input');
        uiBg.type = 'text';
        uiBg.value = getStrPref(PREF_UI_BG_COLOR, getThemePreset().uiBg);
        uiBg.placeholder = '#000';
        uiBg.style.cssText = 'width:140px; padding:4px 6px; font-size:12px;';
        appplyMenuInputTheme(uiBg);
        addRow('Overall UI background', uiBg, advColorWrap);

        const uiBorder = document.createElement('input');
        uiBorder.type = 'text';
        uiBorder.value = getStrPref(PREF_UI_BORDER_COLOR, getThemePreset().uiBorder);
        uiBorder.placeholder = '#0f0';
        uiBorder.style.cssText = 'width:140px; padding:4px 6px; font-size:12px;';
        appplyMenuInputTheme(uiBorder);
        addRow('Overall UI border color', uiBorder, advColorWrap);

        const showAdvancedOptRow = document.createElement('label');
        showAdvancedOptRow.style.cssText = 'cursor:pointer; display:flex; align-items:center; gap:8px; font-size:12px; margin:10px 0 6px;';
        const showAdvancedOptChk = document.createElement('input');
        showAdvancedOptChk.type = 'checkbox';
        showAdvancedOptChk.checked = getBoolPref(PREF_SHOW_ADVANCED_OPT_COLOR, true);
        const showAdvancedOptText = document.createElement('span');
        showAdvancedOptText.textContent = 'Show advanced color options';
        showAdvancedOptRow.appendChild(showAdvancedOptChk);
        showAdvancedOptRow.appendChild(showAdvancedOptText);
        if (isMobile) {
            showAdvancedOptRow.style.width = '100%';
            showAdvancedOptRow.style.fontSize = '11px';
        }
        box.appendChild(showAdvancedOptRow);

        setAdvancedColorsVisible(showAdvancedOptChk.checked);
        showAdvancedOptChk.addEventListener('change', () => {
            setBoolPref(PREF_SHOW_ADVANCED_OPT_COLOR, showAdvancedOptChk.checked);
            setAdvancedColorsVisible(showAdvancedOptChk.checked);
        });

        const showCompleteRow = document.createElement('label');
        showCompleteRow.style.cssText = 'cursor:pointer; display:flex; align-items:center; gap:8px; font-size:12px;';
        const showCompleteChk = document.createElement('input');
        showCompleteChk.type = 'checkbox';
        showCompleteChk.checked = getBoolPref(PREF_SHOW_SUG_ON_COMPLETE, true);
        const showCompleteText = document.createElement('span');
        showCompleteText.textContent = 'Show suggestions when crack is complete';
        showCompleteRow.appendChild(showCompleteChk);
        showCompleteRow.appendChild(showCompleteText);
        box.appendChild(showCompleteRow);

        const badgeRow = document.createElement('label');
        badgeRow.style.cssText = 'cursor:pointer; display:flex; align-items:center; gap:8px; font-size:12px; margin-top:8px;';
        const badgeChk = document.createElement('input');
        badgeChk.type = 'checkbox';
        badgeChk.checked = getBoolPref(STATUS_PREF_KEY, true);
        const badgeText = document.createElement('span');
        badgeText.textContent = 'Show status badge';
        badgeRow.appendChild(badgeChk);
        badgeRow.appendChild(badgeText);
        box.appendChild(badgeRow);

        addHr(box, theme0, 1);

        const btnRow = document.createElement('div');
        btnRow.style.cssText = 'display:flex; gap:8px; justify-content:center; margin-top:12px; flex-wrap:wrap;';
        if (isMobile) {
            btnRow.style.flexDirection = 'column';
            btnRow.style.alignItems = 'stretch';
            btnRow.style.justifyContent = 'flex-start';
        }

        const btnReset = document.createElement('button');
        btnReset.textContent = 'Reset to theme preset';
        btnReset.style.cssText = 'padding:6px 10px; cursor:pointer; border-radius:6px; font-size:12px;';
        if (isMobile) btnReset.style.width = '100%';
        applyMenuButtonTheme(btnReset);

        const btnSync = document.createElement('button');
        btnSync.textContent = COMMUNITY_SYNC_ENABLED ? 'Sync Community Words Now' : 'Community Sync Unavailable';
        btnSync.style.cssText = 'padding:6px 10px; cursor:pointer; border-radius:6px; font-size:12px;';
        if (isMobile) btnSync.style.width = '100%';
        applyMenuButtonTheme(btnSync);

        const btnCache = document.createElement('button');
        btnCache.textContent = 'Clear Wordlist Cache';
        btnCache.style.cssText = 'padding:6px 10px; background:#a00; color:#fff; cursor:pointer; border-radius:6px; font-size:12px; border:none;';
        if (isMobile) btnCache.style.width = '100%';

        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = 'Close';
        cancelBtn.style.cssText = 'padding:6px 10px; cursor:pointer; border-radius:6px; font-size:12px;';
        if (isMobile) cancelBtn.style.width = '100%';
        applyMenuButtonTheme(cancelBtn);

        btnRow.appendChild(btnReset);
        if (COMMUNITY_SYNC_ENABLED) btnRow.appendChild(btnSync);
        btnRow.appendChild(btnCache);
        btnRow.appendChild(cancelBtn);
        box.appendChild(btnRow);

        addHr(box, theme0, 1);

        const pwrdByMsg = document.createElement('div');
        pwrdByMsg.style.cssText = `color:${theme0.uiText}; font-size:12px; text-align:center; opacity:0.95;`;
        pwrdByMsg.textContent = COMMUNITY_SYNC_ENABLED
            ? 'VM public pool + IndexedDB + probability-ranked suggestions - Made with Love ❤ by SirAua [3785905] (and friends)'
            : 'Local IndexedDB mode - set COMMUNITY_POOL_ORIGIN to enable your VM public pool';
        box.appendChild(pwrdByMsg);

        overlay.appendChild(box);
        document.body.appendChild(overlay);

        const applyOverlayTheme = () => {
            const t = getTheme();
            overlay.style.background = t.overlayBg;
            overlay.style.color = t.uiText;
            box.style.background = t.boxBg;
            box.style.borderColor = t.uiBorder;
            title.style.color = t.uiText;
            statusLine.style.color = t.uiText;
            nextSyncDiv.style.color = t.uiText;
            wordCountDiv.style.color = t.uiText;
            pwrdByMsg.style.color = t.uiText;
            applyMenuButtonTheme(btnReset);
            if (COMMUNITY_SYNC_ENABLED) applyMenuButtonTheme(btnSync);
            applyMenuButtonTheme(cancelBtn);
        };

        const onAnyPrefChange = () => {
            applyThemeEverywhere();
            applyOverlayTheme();
        };

        themeSel.onchange = () => {
            applyPreset(themeSel.value);
            const preset = getThemePreset();
            uiText.value = getStrPref(PREF_UI_TEXT_COLOR, preset.uiText);
            uiBg.value = getStrPref(PREF_UI_BG_COLOR, preset.uiBg);
            uiBorder.value = getStrPref(PREF_UI_BORDER_COLOR, preset.uiBorder);
            sugText.value = getStrPref(PREF_SUG_TEXT_COLOR, preset.sugText);
            sugBg.value = getStrPref(PREF_SUG_BG_COLOR, preset.sugBg);
            boxBg.value = getStrPref(PREF_UI_BOX_BG_COLOR, preset.boxBg);
            onAnyPrefChange();
            location.reload();
        };

        const saveMaxSuggestions = () => {
            const n = clampInt(maxSug.value, MIN_MAX_SUGGESTIONS, MAX_MAX_SUGGESTIONS, DEFAULT_MAX_SUGGESTIONS);
            maxSug.value = String(n);
            setIntPref(PREF_MAX_SUGGESTIONS, n);
            onAnyPrefChange();
        };
        maxSug.onchange = saveMaxSuggestions;
        maxSug.oninput = saveMaxSuggestions;

        boxBg.onchange = () => { setStrPref(PREF_UI_BOX_BG_COLOR, boxBg.value); onAnyPrefChange(); };

        fontSize.onchange = () => { setIntPref(PREF_SUG_FONT_PX, fontSize.value); onAnyPrefChange(); };
        fontSize.oninput = () => { setIntPref(PREF_SUG_FONT_PX, fontSize.value); onAnyPrefChange(); };

        sugText.onchange = () => { setStrPref(PREF_SUG_TEXT_COLOR, sugText.value.trim()); onAnyPrefChange(); };
        sugBg.onchange = () => { setStrPref(PREF_SUG_BG_COLOR, sugBg.value.trim()); onAnyPrefChange(); };

        uiText.onchange = () => { setStrPref(PREF_UI_TEXT_COLOR, uiText.value.trim()); onAnyPrefChange(); };
        uiBg.onchange = () => { setStrPref(PREF_UI_BG_COLOR, uiBg.value.trim()); onAnyPrefChange(); };
        uiBorder.onchange = () => { setStrPref(PREF_UI_BORDER_COLOR, uiBorder.value.trim()); onAnyPrefChange(); };

        showCompleteChk.onchange = () => { setBoolPref(PREF_SHOW_SUG_ON_COMPLETE, showCompleteChk.checked); };
        setAdvancedColorsVisible(showAdvancedOptChk.checked);

        badgeChk.onchange = () => {
            const show = badgeChk.checked;
            setBoolPref(STATUS_PREF_KEY, show);
            ensureStatusBadge().style.display = show ? 'block' : 'none';
        };

        btnReset.onclick = () => {
            applyPreset(themeSel.value);
            const preset = getThemePreset();
            uiText.value = getStrPref(PREF_UI_TEXT_COLOR, preset.uiText);
            uiBg.value = getStrPref(PREF_UI_BG_COLOR, preset.uiBg);
            uiBorder.value = getStrPref(PREF_UI_BORDER_COLOR, preset.uiBorder);
            sugText.value = getStrPref(PREF_SUG_TEXT_COLOR, preset.sugText);
            sugBg.value = getStrPref(PREF_SUG_BG_COLOR, preset.sugBg);
            onAnyPrefChange();
        };

        btnSync.onclick = async () => {
            if (!COMMUNITY_SYNC_ENABLED) {
                setStatus('Community sync unavailable');
                return;
            }
            btnSync.disabled = true;
            const oldText = btnSync.textContent;
            try {
                btnSync.textContent = 'Syncing…';
                await checkRemoteAndMaybeDownload(true);
                await updateNextSync();
                await refreshRemoteStats();
                setStatus('Ready');
            } catch (e) {
                crackLog('Manual sync failed', e);
                setStatus('Sync failed');
            } finally {
                btnSync.disabled = false;
                btnSync.textContent = oldText;
            }
        };

        btnCache.onclick = async () => {
            await clearLocalDictCache();
            location.reload();
        };

        let ticker = null;
        let statsTimer = null;

        cancelBtn.onclick = () => {
            unregisterStatusSink(statusLine);
            if (ticker) clearInterval(ticker);
            if (statsTimer) clearInterval(statsTimer);
            if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
        };

        let stats = [];
        (async () => {
            for (let len = MIN_LENGTH; len <= MAX_LENGTH; len++) {
                const chunk = await idbGet(`len_${len}`);
                stats.push(`${len}: ${chunk ? chunk.length : 0}`);
            }
            if (COMMUNITY_SYNC_ENABLED) {
                const remoteCount = await idbGet('cf_remote_count');
                const delta = await idbGet('cf_pending_delta');
                const observations = await getWordObservationCounts();
                const observationTotal = Object.values(observations).reduce((sum, n) => sum + (Number(n) || 0), 0);
                wordCountDiv.textContent =
                    splitMiddle(`Stored per length → ${stats.join(' | ')}  |  Remote cracked: ${remoteCount ?? 'n/a'}${delta ? ` ( +${delta} pending )` : ''}  |  Local observations: ${observationTotal}`);
            } else {
                const observations = await getWordObservationCounts();
                const observationTotal = Object.values(observations).reduce((sum, n) => sum + (Number(n) || 0), 0);
                wordCountDiv.textContent = splitMiddle(`Stored per length → ${stats.join(' | ')}  |  Community sync: disabled  |  Local observations: ${observationTotal}`);
            }
        })();

        const updateNextSync = async () => {
            if (!COMMUNITY_SYNC_ENABLED) {
                nextSyncDiv.textContent = 'Community sync: unavailable (worker returned 404)';
                return;
            }
            const lastSyncTs = Number(await idbGet('cf_last_sync_ts')) || 0;
            const nextAllowed = lastSyncTs + SYNC_MIN_INTERVAL_MS;
            const remaining = nextAllowed - Date.now();
            const eligible = remaining <= 0;
            const delta = Number(await idbGet('cf_pending_delta')) || 0;
            nextSyncDiv.textContent = eligible
                ? `Next sync: now${delta ? ` ( +${delta} pending )` : ''}`
                : `Next sync in ${formatShortDuration(remaining)}${delta ? ` ( +${delta} pending )` : ''}`;
        };

        const refreshRemoteStats = async () => {
            if (COMMUNITY_SYNC_ENABLED) {
                const remoteCount = await idbGet('cf_remote_count');
                const delta = await idbGet('cf_pending_delta');
                const observations = await getWordObservationCounts();
                const observationTotal = Object.values(observations).reduce((sum, n) => sum + (Number(n) || 0), 0);
                wordCountDiv.textContent =
                    splitMiddle(`Stored per length → ${stats.join(' | ')}  |  Remote cracked: ${remoteCount ?? 'n/a'}${delta ? ` ( +${delta} pending )` : ''}  |  Local observations: ${observationTotal}`);
            } else {
                const observations = await getWordObservationCounts();
                const observationTotal = Object.values(observations).reduce((sum, n) => sum + (Number(n) || 0), 0);
                wordCountDiv.textContent = splitMiddle(`Stored per length → ${stats.join(' | ')}  |  Community sync: disabled  |  Local observations: ${observationTotal}`);
            }
        };

        await updateNextSync();
        ticker = setInterval(updateNextSync, 1000);
        statsTimer = setInterval(refreshRemoteStats, 15000);

        applyOverlayTheme();
    }

    function injectMenuButton() {
        if (!runtimeStarted) return;
        if (!isCrackingPage()) {
            cleanupView();
            return;
        }
        if (document.getElementById('__crack_menu_btn')) return;

        const appHeader = document.querySelector('[class^="appHeaderDelimiter"]');
        if (!appHeader) return;

        const btn = document.createElement('button');
        btn.id = '__crack_menu_btn';
        btn.textContent = 'Bruteforce characters to show probability-ranked suggestions! (Click for settings)';
        btn.style.cssText = 'font-size:10px; text-align:left; z-index:9999; cursor:pointer; padding:4px 6px;';
        applyMenuButtonTheme(btn);
        btn.onclick = showMenuOverlay;
        appHeader.appendChild(btn);
        ensureStatusBadge();
    }

    /* --------------------------
       Runtime control
       -------------------------- */
    function startRuntime() {
        if (runtimeStarted) return;
        runtimeStarted = true;

        ensureStatusBadge();
        removeLegacyRigPreferences();

        try {
            if (sessionStorage.getItem('__crack_base_reload_done') === '1') {
                sessionStorage.removeItem('__crack_base_reload_done');
            }
        } catch {}

        if (localStorage.getItem(THEME_PREF_KEY) === null) applyPreset('dark');
        applyThemeEverywhere();
        setStatus('Initializing…');

        loadDict();
        scanCrimePage();

        if (!scanInterval) scanInterval = setInterval(scanCrimePage, UPDATE_INTERVAL);
        if (!menuInterval) menuInterval = setInterval(injectMenuButton, UPDATE_INTERVAL);

        startAutoSyncHeartbeat();
    }

    function stopRuntime() {
        runtimeStarted = false;
        cleanupView();
    }

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

    /* --------------------------
       Init
       -------------------------- */
    onRouteChange();

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

    const pushState = history.pushState;
    const replaceState = history.replaceState;

    history.pushState = function (...args) {
        const ret = pushState.apply(this, args);
        setTimeout(onRouteChange, 0);
        return ret;
    };

    history.replaceState = function (...args) {
        const ret = replaceState.apply(this, args);
        setTimeout(onRouteChange, 0);
        return ret;
    };
})();