Cracking helper with calibrated probability estimates, a character-model fallback for unknown words, and self-measuring telemetry
// ==UserScript==
// @name cRaCked
// @namespace modul.torn.cracking
// @version 2.2.8
// @description Cracking helper with calibrated probability estimates, a character-model fallback for unknown words, and self-measuring telemetry
// @author SirAua [3785905], MoDuL [4022159], TheDno [4443016]
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @match https://www.torn.com/page.php?sid=crimes*
// @grant GM_xmlhttpRequest
// @connect pp-api.sokin.xyz
// @run-at document-idle
// @license mit
// ==/UserScript==
(function () {
'use strict';
if (window.CRACK_SCRIPT_BOOTSTRAPPED) return;
window.CRACK_SCRIPT_BOOTSTRAPPED = true;
/* ==================================================================== */
/* Torn-specific selectors, all in one place so a UI change is one patch */
/* ==================================================================== */
const SEL = {
currentCrime: '[class^="currentCrime"]',
virtualList: '[class^="virtualList"]',
crimeOption: '[class^="crimeOptionWrapper"]',
charSlot: '[class^="charSlot"]:not([class*="charSlotDummy"])',
discoveredChar: '[class^="discoveredChar"]',
guessesSection: '[class^="guessesSection"]',
typeAndService: '[class^="typeAndService"]',
appHeader: '[class^="appHeaderDelimiter"]',
rigStatus: '[class^="rigStatus___"], [class*=" rigStatus___"]',
rigState: '[class^="status___"], [class*=" status___"]',
bruteStrength: '[class^="strength___"], [class*=" strength___"]',
statisticLabel: '[class^="label___"], [class*=" label___"]',
statisticValue: '[class^="value___"], [class*=" value___"]',
flashClass: 'incorrectGuessFlash',
encryptionClass: 'hasEncryption',
// CONFIRM: no verified selector for the crack level. readLevel() tries a
// few shapes and returns null harmlessly if none match, which only means
// the coverage estimate loses its strongest feature.
level: '[class^="level"]',
};
/* ==================================================================== */
/* Config */
/* ==================================================================== */
const DEBUG = false;
const SAFETY_SCAN_MS = 2500; // observers do the real work; this is a net
const MIN_LENGTH = 4;
const MAX_LENGTH = 10;
const PENDING_TIMEOUT_MS = 6000;
const IMPLIED_MIN_P = 0.45; // confidence needed to fill a gap in the LIKELY readout
const LETTERS_SHOWN = 4; // cap the LETTERS chip so it cannot overflow the panel
const COMMUNITY_POOL_ORIGIN = 'https://pp-api.sokin.xyz/torn-crack-pool';
const BASE_MANIFEST_URL = COMMUNITY_POOL_ORIGIN + '/base/manifest.json';
const BASE_SNAPSHOT_ID = 'sha256:04f79148d7354d284cf25361cb2c85f6e7d0ebdcf3cac089b501dc3dd298bab1';
const COMMUNITY_SYNC_ENABLED = true;
const CF_ORIGIN = COMMUNITY_SYNC_ENABLED ? COMMUNITY_POOL_ORIGIN.replace(/\/+$/, '') : '';
const CF_ADD_WORD_URL = CF_ORIGIN ? CF_ORIGIN + '/submit' : '';
const CF_REGISTER_CLIENT_URL = CF_ORIGIN ? CF_ORIGIN + '/clients/register' : '';
const CONTEXT_SNAPSHOT_URL = CF_ORIGIN ? CF_ORIGIN + '/contexts/snapshot.json' : '';
const CLIENT_ID_STORE_KEY = 'cf_client_id_v1';
const CLIENT_ID_RE = /^[A-Za-z0-9_-]{43}$/;
const CLIENT_VERSION = '2.2.8';
const CLIENT_BUILD_ID = 'c2a799b53fd129d37b02cbb072c00651c51435646c470a582e85add8fd1a1ed1';
const CF_STORAGE_BASE = CF_ORIGIN ? CF_ORIGIN + '/words' : '';
const METADATA_URL = CF_STORAGE_BASE ? CF_STORAGE_BASE + '/metadata.json' : '';
const DOWNLOAD_MIN_DELTA = 25;
const SYNC_CHECK_INTERVAL_MS = 15 * 60 * 1000;
const SYNC_MAX_WAIT_MS = 24 * 60 * 60 * 1000;
// Outbox
const OUTBOX_FLUSH_DELAY_MS = 5000;
const OUTBOX_POST_INTERVAL_MS = 2000;
const OUTBOX_BATCH_SIZE = 10;
const OUTBOX_MAX_QUEUE = 500;
const OUTBOX_MAX_CONSECUTIVE_FAILURES = 3;
const OUTBOX_BACKOFF_MS = 60000;
// Telemetry
const TELEMETRY_MAX_CRACKS = 3000;
const TELEMETRY_MAX_GUESSES = 20000;
const DB_NAME = 'crack';
const DB_VERSION = 2;
const STORE = 'dictionary';
// Preferences
const PREF = {
badge: 'crack_show_badge',
theme: 'crack_theme',
sugFont: 'crack_sug_font_px',
sugText: 'crack_sug_text_color',
sugBg: 'crack_sug_bg_color',
uiText: 'crack_ui_text_color',
uiBg: 'crack_ui_bg_color',
uiBorder: 'crack_ui_border_color',
boxBg: 'crack_ui_box_color',
appearanceOpen: 'crack_settings_appearance_open',
maxSug: 'crack_max_suggestions_size',
// Upload is now a visible, revocable choice. Default true preserves 1.3.1
// behaviour so the pool keeps growing; flip to false here if you would
// rather make it strictly opt-in.
upload: 'crack_upload_enabled',
netHook: 'crack_net_hook',
params: 'crack_params_json',
};
const DEFAULT_MAX_SUGGESTIONS = 8;
const MIN_MAX_SUGGESTIONS = 1;
const MAX_MAX_SUGGESTIONS = 20;
const THEME_PRESETS = {
dark: {
uiBg: '#000',
uiText: '#0f0',
uiBorder: '#0f0',
overlayBg: 'rgba(0,0,0,0.5)',
boxBg: '#111',
sugBg: '#000',
sugText: '#0f0',
},
light: {
uiBg: '#fff',
uiText: '#03396c',
uiBorder: '#39ace7',
overlayBg: 'rgba(0,0,0,0.5)',
boxBg: '#fff',
sugBg: '#fff',
sugText: '#03396c',
},
};
/* ==================================================================== */
/* Small utils */
/* ==================================================================== */
function log() { if (DEBUG) console.log.apply(console, ['[Crack]'].concat([].slice.call(arguments))); }
function isCrackingPage() {
return location.pathname === '/page.php'
&& new URLSearchParams(location.search).get('sid') === 'crimes'
// startsWith, not ===, so a subroute or query suffix does not disable us
&& location.hash.startsWith('#/cracking');
}
function isPda() {
return navigator.userAgent.toLowerCase().indexOf('com.manuito.tornpda') !== -1;
}
function isCompact() { return isPda() || window.innerWidth <= 700; }
const getBool = (k, d) => { const v = localStorage.getItem(k); return v === null ? d : v === '1'; };
const setBool = (k, v) => localStorage.setItem(k, v ? '1' : '0');
const getStr = (k, d) => { const v = localStorage.getItem(k); return v === null ? d : String(v); };
const setStr = (k, v) => localStorage.setItem(k, String(v));
const getInt = (k, d) => { const n = parseInt(localStorage.getItem(k), 10); return Number.isFinite(n) ? n : d; };
const setInt = (k, v) => localStorage.setItem(k, String(Math.trunc(Number(v) || 0)));
const clampInt = (n, lo, hi, d) => {
n = Math.trunc(Number(n));
if (!Number.isFinite(n)) n = d;
return Math.max(lo, Math.min(hi, n));
};
function maxSugPref() {
return clampInt(
getInt(PREF.maxSug, DEFAULT_MAX_SUGGESTIONS),
MIN_MAX_SUGGESTIONS,
MAX_MAX_SUGGESTIONS,
DEFAULT_MAX_SUGGESTIONS,
);
}
function themeName() { return getStr(PREF.theme, 'dark') === 'light' ? 'light' : 'dark'; }
function preset() { return THEME_PRESETS[themeName()]; }
function theme() {
const p = preset();
return {
name: themeName(),
uiBg: getStr(PREF.uiBg, p.uiBg),
uiText: getStr(PREF.uiText, p.uiText),
uiBorder: getStr(PREF.uiBorder, p.uiBorder),
overlayBg: p.overlayBg,
boxBg: getStr(PREF.boxBg, p.boxBg),
sugBg: getStr(PREF.sugBg, p.sugBg),
sugText: getStr(PREF.sugText, p.sugText),
sugFontPx: getInt(PREF.sugFont, 10),
};
}
function formatPct(p) {
const v = Math.max(0, Number(p) || 0) * 100;
if (v >= 10) return v.toFixed(0) + '%';
if (v >= 1) return v.toFixed(1) + '%';
if (v >= 0.1) return v.toFixed(2) + '%';
return '<0.1%';
}
function formatDuration(ms) {
if (ms <= 0) return 'now';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60), sec = s % 60;
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
if (h > 0) return h + 'h ' + m + 'm ' + sec + 's';
if (m > 0) return m + 'm ' + sec + 's';
return sec + 's';
}
function formatProgressStatus(label, completed, total, startedAt, now = Date.now()) {
const done = Math.max(0, Number(completed) || 0);
const size = Math.max(0, Number(total) || 0);
const elapsed = Math.max(0, now - startedAt);
const percent = size > 0 ? Math.min(100, Math.floor(100 * done / size)) : 0;
let remaining = 'time left: estimating…';
if (done > 0 && size > done && elapsed > 0) {
const estimate = Math.max(1000, elapsed * (size - done) / done);
remaining = '~' + formatDuration(estimate) + ' left';
} else if (size > 0 && done >= size) {
remaining = '0s left';
}
return label + ' · ' + done.toLocaleString() + '/' + size.toLocaleString()
+ ' words (' + percent + '%) · elapsed ' + formatDuration(elapsed) + ' · ' + remaining;
}
function formatCompactNumber(value) {
const number = Math.max(0, Math.floor(Number(value) || 0));
if (number >= 1000000) return (Math.floor(number / 100000) / 10).toFixed(1) + 'm';
if (number >= 1000) return (Math.floor(number / 100) / 10).toFixed(1) + 'k';
return number.toLocaleString();
}
function gmRequest(opts) {
return new Promise((resolve, reject) => {
try {
const o = Object.assign({ responseType: 'text' }, opts);
o.headers = Object.assign({ Accept: 'application/json, text/plain, */*; q=0.1' }, opts.headers || {});
GM_xmlhttpRequest(Object.assign({}, o, { onload: resolve, onerror: reject, ontimeout: reject }));
} catch (e) { reject(e); }
});
}
function headerOf(headers, name) {
const m = headers && headers.match ? headers.match(new RegExp('^' + name + ':\\s*(.*)$', 'mi')) : null;
return m ? m[1].trim() : null;
}
/* ==================================================================== */
/* IndexedDB — one cached connection, shared with the worker */
/* ==================================================================== */
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
async function idbGet(key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const r = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
async function idbSet(key, val) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(val, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function idbDel(key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function idbClearAll() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/* ==================================================================== */
/* Scoring core */
/* ==================================================================== */
/* __CRACK_CORE_FACTORY__ */
/* ---------------------------------------------------------------------
* Inlined verbatim from src/crack-core.js by build.mjs. Do not edit here.
*
* Wrapped in a factory so the exact same source can be used two ways: called
* directly on this thread, and stringified with toString() to build the
* Worker. One implementation, no duplication, readable in both.
* ------------------------------------------------------------------- */
function CrackCoreFactory() {
/* ===========================================================================
* crack-core — pure scoring core for cRaCked
* ---------------------------------------------------------------------------
* No DOM. No IndexedDB. No network. Everything here is deterministic and
* testable in Node, which is the whole point: this file is the single source
* of truth for the model, shared by the userscript's Worker and by the
* offline replay harness. build.mjs inlines it into the userscript.
*
* Design notes for the reviewer:
*
* - Words are stored PACKED: one Uint8Array per length bucket, fixed stride
* = word length. 272k eight-char words = 2.2 MB instead of ~16-20 MB of JS
* strings, and it transfers to a Worker zero-copy. No string allocation
* anywhere in the hot loop.
*
* - Rank is stored EXPLICITLY (Uint32Array) alongside a source tier
* (base list / community pool / locally cracked). The old script inferred
* rank from array position, so every appended community word landed at
* index ~272,000 and was penalised ~104x against generic top-of-list
* passwords. Source tier now carries the prior instead.
*
* - The model reserves explicit out-of-vocabulary mass (alpha). A sole
* surviving candidate can no longer report 100%; it reports the
* probability that the password is in the dictionary at all.
*
* - When alpha is low the estimate falls back down a ladder:
* trigram -> bigram (both sides) -> positional frequency -> unigram.
* That is the only thing that helps on words the dictionary has never
* seen, which at high crack levels is most of them.
* ======================================================================== */
/* ---------------------------------------------------------------- alphabet */
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const A = ALPHABET.length; // 36
const WILD = 255; // pattern placeholder for "hidden"
const CODE = new Uint8Array(256).fill(255);
for (let i = 0; i < A; i++) CODE[ALPHABET.charCodeAt(i)] = i;
function codeOfChar(ch) {
if (!ch) return 255;
const c = ch.toUpperCase().charCodeAt(0);
return c < 256 ? CODE[c] : 255;
}
/** Encode a word to codes. Returns null if it contains anything unsupported. */
function encodeWord(word) {
const w = String(word || '').toUpperCase();
if (!w) return null;
const out = new Uint8Array(w.length);
for (let i = 0; i < w.length; i++) {
const c = w.charCodeAt(i);
const v = c < 256 ? CODE[c] : 255;
if (v === 255) return null;
out[i] = v;
}
return out;
}
function decodeCodes(codes, off, len) {
let s = '';
for (let i = 0; i < len; i++) s += ALPHABET[codes[off + i]];
return s;
}
/* ------------------------------------------------------------ source tiers */
const SRC_BASE = 0; // the generic leaked-password wordlist
const SRC_POOL = 1; // community-confirmed Torn password
const SRC_LOCAL = 2; // password this client cracked itself
/* ------------------------------------------------------------- tuneables */
/**
* Every constant the model depends on, in one object, so the replay harness
* can sweep them and the settings UI can override them. None of these are
* sacred — they are starting points to be tuned against logged cracks.
*/
const DEFAULT_PARAMS = {
// prior(rank, src) = SRC_W[src] / (rank + RANK_OFFSET) ^ RANK_EXP
RANK_EXP: 0.85,
RANK_OFFSET: 30,
SRC_W: [1, 40, 80],
// Observation evidence is ADDITIVE, not a multiplicative log boost. Additive
// lets confirmed sightings genuinely dominate a bad prior and degrades
// gracefully; the old multiplicative form needed ~670 sightings of one word
// to overcome the rank penalty, i.e. it could never win.
OBS_GLOBAL_W: 0.5,
OBS_CTX_W: 3.0,
// Pseudo-token weight of an observed word when building the n-gram tables,
// so the character model learns Torn's vocabulary faster than the 869k
// generic list can drown it.
OBS_NGRAM_PSEUDO: 40,
// Backoff ladder mixing weights. Renormalised over whichever tiers are
// actually available at a given position.
W_TRI: 0.42,
W_BI: 0.20,
W_POS: 0.28,
W_UNI: 0.10,
// Add-k smoothing for the n-gram tables.
NGRAM_K: 0.35,
// Dictionary-coverage prior by word length: P(password is in the wordlist).
// Auto-calibrated from telemetry once enough cracks are logged; these are
// only the cold-start values.
COVERAGE: { 4: 0.85, 5: 0.80, 6: 0.70, 7: 0.60, 8: 0.50, 9: 0.40, 10: 0.30, 11: 0.22, 12: 0.18 },
COVERAGE_FALLBACK: 0.15,
// Coverage falls off with crack level. level 50 -> x0.80, level 100 -> x0.60.
LEVEL_DECAY: 0.004,
LEVEL_DECAY_FLOOR: 0.40,
// If the surviving candidates are dominated by words we have actually seen
// before, confidence that the answer is in-vocabulary should rise.
OBS_CONF: 1.8,
// Never display certainty. There is always a chance the word is out of
// vocabulary, and XTRIDENT66 is a reminder of how often that happens.
ALPHA_MAX: 0.92,
// Shrinkage for the auto-calibrated coverage estimate.
CALIB_PRIOR_N: 12,
// Embedded-word matching. Torn passwords observed so far look like a
// dictionary word with affixes: TERMINATOR, XTRIDENT66 (= X + TRIDENT + 66).
// Exact whole-string matching fails on those, and no n-gram can recover the
// inference, but aligning TRIDENT at offset 1 of _T_IDENT66 forces position 3
// to R outright. Only runs when whole-word matching found nothing, so it costs
// nothing in the cases the dictionary already handles.
AFFIX_ENABLED: true,
AFFIX_MIN_LEN: 4, // shortest embedded word worth trusting
AFFIX_MAX_PAD: 5, // covers observed 5+5 combined-word passwords
AFFIX_MIN_REVEALED: 2, // revealed characters the window must cover
AFFIX_PAD_PENALTY: 0.45, // per padding character; prefers longer embeddings
AFFIX_MAX: 0.9, // never let it fully crowd out the character model
// Ablation switch: ignore the dictionary entirely and score from the
// character model alone. Needed as an explicit flag because zeroing the
// COVERAGE table is not enough — measured calibration data legitimately
// overrides the cold-start prior, which would leak the dictionary back in.
DICT_DISABLED: false,
// Brute-force accounting. The action decision itself does not depend on a
// guessed nerve value: a manual guess is free, and after a miss the player
// can still brute-force the job. These constants only turn the remaining
// work units into an optional cost estimate for replay/tuning.
BRUTE_NERVE: 7,
};
function mergeParams(over) {
const p = { ...DEFAULT_PARAMS, ...(over || {}) };
p.SRC_W = (over && over.SRC_W) || DEFAULT_PARAMS.SRC_W.slice();
p.COVERAGE = { ...DEFAULT_PARAMS.COVERAGE, ...((over && over.COVERAGE) || {}) };
return p;
}
function priorOf(rank, src, params) {
const w = params.SRC_W[src] !== undefined ? params.SRC_W[src] : 1;
return w / Math.pow(rank + params.RANK_OFFSET, params.RANK_EXP);
}
/* ------------------------------------------------------------------ bucket */
/** A length bucket: append-only, so indices are stable and persistable. */
function makeBucket(len, cap) {
cap = Math.max(64, cap || 1024);
return {
len,
count: 0,
cap,
codes: new Uint8Array(cap * len),
rank: new Uint32Array(cap),
src: new Uint8Array(cap),
};
}
function grow(b, need) {
if (need <= b.cap) return;
let cap = b.cap;
while (cap < need) cap *= 2;
const codes = new Uint8Array(cap * b.len); codes.set(b.codes);
const rank = new Uint32Array(cap); rank.set(b.rank);
const src = new Uint8Array(cap); src.set(b.src);
b.codes = codes; b.rank = rank; b.src = src; b.cap = cap;
}
/** Append. Returns the new index. Caller is responsible for dedup. */
function bucketAdd(b, codes, rank, src) {
grow(b, b.count + 1);
b.codes.set(codes, b.count * b.len);
b.rank[b.count] = rank >>> 0;
b.src[b.count] = src;
return b.count++;
}
/**
* Linear scan for a word. On a packed byte array this is ~1 ms for 272k
* eight-char entries and runs only when a crack completes, so there is no
* reason to carry a parallel Set of 869k JS strings just for this.
*/
function bucketFind(b, codes) {
const { len, count } = b;
const arr = b.codes;
outer:
for (let i = 0; i < count; i++) {
const off = i * len;
for (let p = 0; p < len; p++) if (arr[off + p] !== codes[p]) continue outer;
return i;
}
return -1;
}
function bucketWord(b, i) {
return decodeCodes(b.codes, i * b.len, b.len);
}
/** Trim to exact size for persistence. */
function bucketExport(b) {
return {
len: b.len,
count: b.count,
codes: b.codes.slice(0, b.count * b.len),
rank: b.rank.slice(0, b.count),
src: b.src.slice(0, b.count),
};
}
function bucketImport(rec) {
const codes = rec.codes instanceof Uint8Array ? rec.codes : new Uint8Array(rec.codes);
const rank = rec.rank instanceof Uint32Array ? rec.rank : new Uint32Array(rec.rank);
const src = rec.src instanceof Uint8Array ? rec.src : new Uint8Array(rec.src);
return { len: rec.len, count: rec.count, cap: Math.max(64, rec.count), codes, rank, src };
}
/* ------------------------------------------------------------ n-gram model */
/**
* Character models built once over all buckets, weighted by prior plus
* observation pseudo-counts. These are what make an out-of-vocabulary guess
* meaningfully better than random.
*
* uni[c] global character frequency
* pos[len][p * A + c] positional frequency for that word length
* biL[prev * A + c] P(c | character to the left)
* biR[next * A + c] P(c | character to the right)
* triL[(p2 * A + p1) * A + c] P(c | two characters to the left)
* triR[(n2 * A + n1) * A + c] P(c | two characters to the right)
*
* The right-hand trigram matters more than it might look. Revealed characters
* cluster wherever the player has been guessing, so a gap frequently has its
* context entirely on one side. On a pattern like _T_IDENT66 the left trigram
* is unavailable (position 0 is still hidden) while the right one sees ?ID and
* is strongly informative.
*/
function buildModels(buckets, gobs, params, onProgress) {
const uni = new Float64Array(A);
const biL = new Float64Array(A * A);
const biR = new Float64Array(A * A);
const triL = new Float64Array(A * A * A);
const triR = new Float64Array(A * A * A);
const pos = {};
let mass = 0;
const keys = Object.keys(buckets);
const progressTotal = keys.reduce((sum, key) => {
const bucket = buckets[key];
return sum + (bucket && bucket.count ? bucket.count : 0);
}, 0);
let progressDone = 0;
let nextProgress = 25000;
if (typeof onProgress === 'function') onProgress(0, progressTotal);
for (const key of keys) {
const b = buckets[key];
if (!b || !b.count) continue;
const len = b.len;
const g = (gobs && gobs[len]) || null;
const parr = new Float64Array(len * A);
pos[len] = parr;
for (let i = 0; i < b.count; i++) {
const off = i * len;
let w = priorOf(b.rank[i], b.src[i], params);
if (g && g[i]) w += params.OBS_NGRAM_PSEUDO * g[i];
mass += w;
for (let p = 0; p < len; p++) {
const c = b.codes[off + p];
uni[c] += w;
parr[p * A + c] += w;
if (p >= 1) biL[b.codes[off + p - 1] * A + c] += w;
if (p <= len - 2) biR[b.codes[off + p + 1] * A + c] += w;
if (p >= 2) {
triL[(b.codes[off + p - 2] * A + b.codes[off + p - 1]) * A + c] += w;
}
if (p <= len - 3) {
triR[(b.codes[off + p + 2] * A + b.codes[off + p + 1]) * A + c] += w;
}
}
progressDone++;
if (typeof onProgress === 'function' && progressDone >= nextProgress) {
onProgress(progressDone, progressTotal);
nextProgress += 25000;
}
}
}
if (typeof onProgress === 'function' && progressDone !== 0) {
onProgress(progressDone, progressTotal);
}
return {
// An untrained model returns a flat distribution from add-k smoothing alone,
// which looks identical to a confident uniform answer. Callers need to be
// able to tell those apart, so say so explicitly.
trained: mass > 0,
mass,
uni: rowNorm(uni, A, params.NGRAM_K),
biL: rowNorm(biL, A, params.NGRAM_K),
biR: rowNorm(biR, A, params.NGRAM_K),
triL: rowNorm(triL, A, params.NGRAM_K),
triR: rowNorm(triR, A, params.NGRAM_K),
pos: Object.fromEntries(Object.entries(pos).map(([l, arr]) => [l, rowNorm(arr, A, params.NGRAM_K)])),
};
}
/** Add-k normalise each contiguous run of A cells into a probability row. */
function rowNorm(arr, width, k) {
const rows = arr.length / width;
const out = new Float32Array(arr.length);
for (let r = 0; r < rows; r++) {
const off = r * width;
let sum = 0;
for (let c = 0; c < width; c++) sum += arr[off + c];
const denom = sum + k * width;
for (let c = 0; c < width; c++) out[off + c] = (arr[off + c] + k) / denom;
}
return out;
}
/**
* Backoff distribution over characters for one hidden position.
* Writes A floats into `out` and returns it (already normalised).
*/
function backoffDist(models, len, p, pat, params, out) {
out = out || new Float32Array(A);
out.fill(0);
const posArr = models.pos[len] || models.pos[String(len)];
const l1 = p >= 1 ? pat[p - 1] : WILD;
const l2 = p >= 2 ? pat[p - 2] : WILD;
const r1 = p <= len - 2 ? pat[p + 1] : WILD;
const r2 = p <= len - 3 ? pat[p + 2] : WILD;
const hasTriL = l1 !== WILD && l2 !== WILD;
const hasTriR = r1 !== WILD && r2 !== WILD;
const hasL = l1 !== WILD;
const hasR = r1 !== WILD;
// Renormalise the mixing weights over whichever tiers exist at this position.
let wTriL = hasTriL ? params.W_TRI : 0;
let wTriR = hasTriR ? params.W_TRI : 0;
let wL = hasL ? params.W_BI : 0;
let wR = hasR ? params.W_BI : 0;
let wPos = posArr ? params.W_POS : 0;
let wUni = params.W_UNI;
const tot = wTriL + wTriR + wL + wR + wPos + wUni;
wTriL /= tot; wTriR /= tot; wL /= tot; wR /= tot; wPos /= tot; wUni /= tot;
const triLOff = hasTriL ? (l2 * A + l1) * A : 0;
const triROff = hasTriR ? (r2 * A + r1) * A : 0;
const lOff = hasL ? l1 * A : 0;
const rOff = hasR ? r1 * A : 0;
const pOff = posArr ? p * A : 0;
for (let c = 0; c < A; c++) {
let v = wUni * models.uni[c];
if (hasTriL) v += wTriL * models.triL[triLOff + c];
if (hasTriR) v += wTriR * models.triR[triROff + c];
if (hasL) v += wL * models.biL[lOff + c];
if (hasR) v += wR * models.biR[rOff + c];
if (posArr) v += wPos * posArr[pOff + c];
out[c] = v;
}
return out;
}
/* ----------------------------------------------------- embedded-word vote */
/**
* Slide every dictionary word of length m <= len across the pattern and keep the
* alignments that are consistent with what has been revealed. Each surviving
* alignment votes for the characters it implies at the still-hidden positions
* inside its window.
*
* This is the tier that solves the XTRIDENT66 shape. Whole-string lookup finds
* nothing, the character model can only muster a weak preference, but TRIDENT
* aligned at offset 1 explains six revealed characters at once and pins the
* remaining gap to R.
*
* Returns accumulated weight per (position, character) plus the total mass, so
* the caller can decide how much to trust it.
*/
function affixVote(buckets, pat, allow, params) {
const len = pat.length;
const letterW = new Float64Array(len * A);
let mass = 0;
let alignments = 0;
let scanned = 0;
let bestK = 0;
const minLen = Math.max(2, params.AFFIX_MIN_LEN);
for (let m = len; m >= minLen; m--) {
const bucket = buckets[m] || buckets[String(m)];
if (!bucket || !bucket.count) continue;
const pads = len - m;
if (pads > params.AFFIX_MAX_PAD) continue;
const padPenalty = Math.pow(params.AFFIX_PAD_PENALTY, pads);
const codes = bucket.codes;
for (let offset = 0; offset <= pads; offset++) {
// Split the window into revealed positions (the constraints that make
// this cheap) and hidden positions (what we are trying to learn).
const constraints = [];
const hidden = [];
for (let j = 0; j < m; j++) {
if (pat[offset + j] === WILD) hidden.push(j);
else constraints.push(j);
}
if (constraints.length < params.AFFIX_MIN_REVEALED) continue;
if (!hidden.length) continue;
// An alignment explaining k revealed characters would happen by chance
// with probability about A^-k, so weight by A^k. An embedding that
// accounts for six revealed characters at once is overwhelmingly more
// credible than one scraping past on two, and this is what lets the real
// explanation outvote the noise.
const evidence = Math.pow(A, Math.min(constraints.length, 8));
scanned += bucket.count;
outer:
for (let i = 0; i < bucket.count; i++) {
const off = i * m;
for (let k = 0; k < constraints.length; k++) {
const j = constraints[k];
if (codes[off + j] !== pat[offset + j]) continue outer;
}
for (let k = 0; k < hidden.length; k++) {
const j = hidden[k];
if (!allow[(offset + j) * A + codes[off + j]]) continue outer;
}
const w = priorOf(bucket.rank[i], bucket.src[i], params) * padPenalty * evidence;
mass += w;
alignments++;
if (constraints.length > bestK) bestK = constraints.length;
for (let k = 0; k < hidden.length; k++) {
const j = hidden[k];
letterW[(offset + j) * A + codes[off + j]] += w;
}
}
}
}
/* How far to trust this, on a scale that does not depend on the absolute size
* of the priors. Expected number of alignments arising purely by chance is
* scanned * A^-bestK; when that is far below one, the alignment found is
* almost certainly the real explanation. */
const expectedByChance = alignments ? scanned * Math.pow(A, -bestK) : Infinity;
const trust = alignments ? params.AFFIX_MAX / (1 + expectedByChance) : 0;
return { letterW, mass, alignments, scanned, bestK, expectedByChance, trust };
}
/* ----------------------------------------------------------------- alpha */
/**
* P(the true password is present in our dictionary at all).
*
* `calib` is optional measured data: { hits, n } for this (length, level)
* cell, shrunk toward the cold-start prior. This is the loop that closes
* itself — the more cracks get logged, the less the hand-picked COVERAGE
* table matters.
*/
function coveragePrior(len, level, params, calib) {
if (params.DICT_DISABLED) return 0;
let base = params.COVERAGE[len];
if (base === undefined) base = params.COVERAGE_FALLBACK;
if (Number.isFinite(level) && level > 0) {
const f = Math.max(params.LEVEL_DECAY_FLOOR, 1 - params.LEVEL_DECAY * level);
base *= f;
}
if (calib && calib.n > 0) {
const k = params.CALIB_PRIOR_N;
base = (calib.hits + k * base) / (calib.n + k);
}
return Math.min(params.ALPHA_MAX, Math.max(0, base));
}
/* ----------------------------------------------------------------- query */
/**
* Build the per-position allow mask: allow[p * A + c] is 1 when character c
* is still possible at position p. Folding the pattern and the exclusions
* into one table turns the inner loop into a single array lookup per
* character with no branching.
*/
function buildAllow(pat, exclusions, params) {
const len = pat.length;
const allow = new Uint8Array(len * A).fill(1);
for (let p = 0; p < len; p++) {
const off = p * A;
if (pat[p] !== WILD) {
allow.fill(0, off, off + A);
allow[off + pat[p]] = 1;
continue;
}
// Accept either codes or characters. The worker hands us codes, the replay
// harness hands us characters, and silently ignoring one of them means
// exclusions stop working and the scorer re-guesses forever.
const ex = exclusions && exclusions[p];
if (ex) {
for (const raw of ex) {
const c = typeof raw === 'number' ? raw : codeOfChar(raw);
if (c >= 0 && c < A) allow[off + c] = 0;
}
}
}
return allow;
}
function insertTop(top, cand, max) {
let lo = 0, hi = top.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
const cur = top[mid];
if (cur.w > cand.w || (cur.w === cand.w && cur.i < cand.i)) lo = mid + 1;
else hi = mid;
}
if (lo >= max) return;
top.splice(lo, 0, cand);
if (top.length > max) top.pop();
}
/**
* The whole model in one call.
*
* opts:
* bucket packed bucket for this word length
* models output of buildModels
* pat Uint8Array(len), WILD where hidden
* allow output of buildAllow
* gobs Float32Array(bucket.count) global observation counts
* ctxObs Map<index, count> for the current target/service
* guessPositions positions the player can actually type into
* level crack level or null
* calib { hits, n } measured coverage for this cell, or null
* params, maxSug
*
* Returns per-position character distributions that already blend dictionary
* evidence with the backoff ladder, plus the single best guess.
*/
function query(opts) {
const {
bucket, buckets, models, pat, allow, gobs, ctxObs,
guessPositions, level, calib, maxSug,
} = opts;
const params = opts.params || DEFAULT_PARAMS;
const len = pat.length;
const open = [];
for (let p = 0; p < len; p++) if (pat[p] === WILD) open.push(p);
const empty = {
matchCount: 0, S: 0, alpha: 0, words: [], slots: [], best: null,
obsShare: 0, coverage: coveragePrior(len, level, params, calib),
modelTrained: !!(models && models.trained), affixMass: 0, affixAlignments: 0,
};
if (!bucket || !bucket.count || !open.length || params.DICT_DISABLED) {
// No dictionary for this length (or nothing hidden): still emit backoff
// estimates so the panel is never silent.
if (!open.length) return empty;
return finishBackoffOnly(opts, open, empty);
}
const { count, codes } = bucket;
const letterW = new Float64Array(len * A);
const top = [];
let S = 0, matchCount = 0, obsW = 0;
const max = Math.max(1, maxSug || 8);
// Check the revealed positions first: they eliminate ~97% of candidates on
// the first comparison, so ordering the checks this way is most of the
// reason a full linear scan is fast enough to keep the code simple.
const order = [];
for (let p = 0; p < len; p++) if (pat[p] !== WILD) order.push(p);
for (let p = 0; p < len; p++) if (pat[p] === WILD) order.push(p);
outer:
for (let i = 0; i < count; i++) {
const off = i * len;
for (let k = 0; k < order.length; k++) {
const p = order[k];
if (!allow[p * A + codes[off + p]]) continue outer;
}
const g = gobs ? gobs[i] : 0;
const c = ctxObs ? (ctxObs.get(i) || 0) : 0;
const w = priorOf(bucket.rank[i], bucket.src[i], params)
+ params.OBS_GLOBAL_W * g
+ params.OBS_CTX_W * c;
S += w;
matchCount++;
if (g > 0 || c > 0) obsW += w;
for (let k = 0; k < open.length; k++) {
const p = open[k];
letterW[p * A + codes[off + p]] += w;
}
insertTop(top, { i, w, g, c }, max);
}
if (matchCount === 0) return finishBackoffOnly(opts, open, empty);
// --- alpha: how much do we trust the dictionary here at all -------------
// The observation boost is scaled by how constrained the pattern actually is.
// A previously-seen word turning up among 200,000 candidates says almost
// nothing; the same word surviving among three says a great deal. Without this
// damping a single observed word pinned every row to the ALPHA_MAX ceiling,
// so a 6-character pattern with nothing revealed claimed 97% confidence.
const coverage = coveragePrior(len, level, params, calib);
const obsShare = S > 0 ? obsW / S : 0;
const revealed = len - open.length;
const constraint = len > 0 ? revealed / len : 0;
const boost = Math.min(1, obsShare * params.OBS_CONF) * constraint;
let alpha = coverage + (1 - coverage) * boost;
alpha = Math.min(params.ALPHA_MAX, Math.max(0, alpha));
const alphaCapped = alpha >= params.ALPHA_MAX - 1e-9;
// --- blend dictionary marginals with the backoff ladder -----------------
const scratch = new Float32Array(A);
const slots = [];
for (const p of open) {
const bo = backoffDist(models, len, p, pat, params, scratch);
// Renormalise the backoff over characters still allowed at this position.
let boSum = 0;
for (let c = 0; c < A; c++) if (allow[p * A + c]) boSum += bo[c];
const letters = [];
for (let c = 0; c < A; c++) {
if (!allow[p * A + c]) continue;
const pDict = S > 0 ? letterW[p * A + c] / S : 0;
const pBack = boSum > 0 ? bo[c] / boSum : 1 / A;
const prob = alpha * pDict + (1 - alpha) * pBack;
if (prob > 0) letters.push({ char: ALPHABET[c], code: c, p: prob, pDict, pBack });
}
letters.sort((x, y) => y.p - x.p || x.code - y.code);
slots.push({ position: p, letters });
}
// --- best single guess, restricted to slots the player can actually use --
const best = pickBest(slots, open, guessPositions, true, (slot, top1) => (
top1.pDict * alpha >= (1 - alpha) * top1.pBack ? 'dict' : 'ngram'
));
const words = top.map(t => ({
word: bucketWord(bucket, t.i),
// Scaled by alpha: this is P(word), not P(word | word is in the list).
// That distinction is the whole reason a lone survivor used to read 100%.
p: S > 0 ? alpha * (t.w / S) : 0,
rank: bucket.rank[t.i],
src: bucket.src[t.i],
obs: t.g,
ctxObs: t.c,
}));
return {
matchCount, S, alpha, alphaCapped, coverage, obsShare, words, slots, best,
modelTrained: !!(models && models.trained), affixMass: 0, affixAlignments: 0,
};
}
/**
* No whole-word dictionary support: estimates come from embedded-word
* alignments where any exist, otherwise from the character model. 1.3.1 printed
* "(no matches)" and gave up here, which is exactly where the player most needs
* a hint.
*/
function finishBackoffOnly(opts, open, empty) {
const { pat, allow, models, buckets, guessPositions, level, calib } = opts;
const params = opts.params || DEFAULT_PARAMS;
const len = pat.length;
const trained = !!(models && models.trained);
// DICT_DISABLED has to switch this off too: embedded-word alignment is
// dictionary-derived evidence, so leaving it on would let the dictionary back
// into an ablation meant to exclude it. Same class of leak as the COVERAGE one.
const affixOn = params.AFFIX_ENABLED && !params.DICT_DISABLED && buckets && trained;
const affix = affixOn
? affixVote(buckets, pat, allow, params)
: { letterW: null, mass: 0, alignments: 0, trust: 0 };
const beta = affix.trust || 0;
const scratch = new Float32Array(A);
const slots = [];
for (const p of open) {
const bo = backoffDist(models, len, p, pat, params, scratch);
let boSum = 0;
let affixSum = 0;
for (let c = 0; c < A; c++) {
if (!allow[p * A + c]) continue;
boSum += bo[c];
if (affix.letterW) affixSum += affix.letterW[p * A + c];
}
// Alignments that do not cover this position say nothing about it, so trust
// is scaled by the share of alignment mass that actually reaches here.
// On _T_IDENT66 both STRIDENT and TRIDENT vote for R at position 3, but only
// STRIDENT reaches position 1 — without this, position 1 claimed 90%
// confidence in S on the strength of a single alignment.
const share = affix.mass > 0 ? Math.min(1, affixSum / affix.mass) : 0;
const betaHere = beta * share;
const letters = [];
for (let c = 0; c < A; c++) {
if (!allow[p * A + c]) continue;
const pBack = boSum > 0 ? bo[c] / boSum : 1 / A;
const pAffix = affixSum > 0 ? affix.letterW[p * A + c] / affixSum : null;
const useBeta = pAffix === null ? 0 : betaHere;
const prob = useBeta * pAffix + (1 - useBeta) * pBack;
letters.push({
char: ALPHABET[c], code: c, p: prob,
pDict: 0, pBack, pAffix: pAffix === null ? 0 : pAffix,
});
}
letters.sort((x, y) => y.p - x.p || x.code - y.code);
slots.push({ position: p, letters, hasAffix: affixSum > 0 });
}
return {
...empty,
alpha: 0,
coverage: coveragePrior(len, level, params, calib),
modelTrained: trained,
affixMass: affix.mass,
affixAlignments: affix.alignments,
affixBestK: affix.bestK || 0,
affixTrust: beta,
slots,
best: pickBest(slots, open, guessPositions, trained, (slot) => (
slot.hasAffix && beta >= 0.5 ? 'affix' : 'ngram'
)),
};
}
/**
* Highest-probability (position, character) among the slots the player can
* actually type into.
*
* `guessPositions` being an empty ARRAY means there is nowhere to guess, and the
* honest answer is no recommendation. Only a missing/undefined value means "the
* caller did not say, use every open slot". 1.3.1 and the first 2.0 build
* conflated the two, which is why a row reporting NO OPEN SLOTS still showed a
* confident TRY chip for a slot that could not be typed into.
*/
function pickBest(slots, open, guessPositions, trained, sourceOf) {
if (!trained) return null;
const usable = Array.isArray(guessPositions) ? new Set(guessPositions) : new Set(open);
if (!usable.size) return null;
let best = null;
for (const slot of slots) {
if (!usable.has(slot.position)) continue;
const top = slot.letters[0];
if (!top) continue;
if (!best || top.p > best.p) {
best = {
position: slot.position,
char: top.char,
p: top.p,
source: sourceOf(slot, top),
};
}
}
return best;
}
/* ------------------------------------------------------------- decisions */
/**
* Probability of resolving every currently typeable slot before the remaining
* miss allowance is exhausted.
*
* A truth at rank k needs k misses followed by the correct guess. With G misses
* left, the total number of misses across the open slots must therefore be less
* than G: the Gth miss locks guessing before another correct character can be
* entered. Slot marginals are treated as independent here. Live play updates
* them after every result, so this deliberately stays a small, inspectable
* approximation rather than pretending to be a full password POMDP.
*/
function openClearChance(slots, guessesLeft) {
const usable = (slots || []).filter(slot => slot && slot.letters && slot.letters.length);
if (!usable.length) return 1;
if (!Number.isFinite(guessesLeft)) return null;
const budget = Math.max(0, Math.floor(guessesLeft));
if (budget === 0) return 0;
const maxMisses = budget - 1;
let missDist = new Float64Array(maxMisses + 1);
missDist[0] = 1;
for (const slot of usable) {
let total = 0;
for (const letter of slot.letters) total += Math.max(0, Number(letter.p) || 0);
if (total <= 0) return 0;
const next = new Float64Array(maxMisses + 1);
for (let already = 0; already <= maxMisses; already++) {
if (missDist[already] <= 0) continue;
const room = maxMisses - already;
for (let rank = 0; rank <= room && rank < slot.letters.length; rank++) {
const p = Math.max(0, Number(slot.letters[rank].p) || 0) / total;
next[already + rank] += missDist[already] * p;
}
}
missDist = next;
}
let chance = 0;
for (const p of missDist) chance += p;
return Math.max(0, Math.min(1, chance));
}
/**
* Expected brute-force actions needed to finish a number of work cycles.
*
* Torn stochastically rounds fractional brute-force strength on every action:
* strength 2.4 performs two cycles plus a 40% chance of a third. This
* dynamic program keeps that fractional value instead of rounding the user's
* rig down or pretending that every action always performs the mean exactly.
*/
function expectedBruteActions(workCycles, bruteStrength) {
const work = Number.isFinite(workCycles) ? Math.max(0, Math.floor(workCycles)) : 0;
if (work === 0) return 0;
let strength = Number(bruteStrength);
if (!Number.isFinite(strength) || strength <= 0) strength = 1;
const guaranteed = Math.floor(strength);
const extraP = Math.max(0, Math.min(1, strength - guaranteed));
// Below one cycle, an action is a Bernoulli trial. Each required success
// therefore takes 1/p actions on average.
if (guaranteed === 0) return work / extraP;
const expected = new Float64Array(work + 1);
for (let remaining = 1; remaining <= work; remaining++) {
const afterGuaranteed = Math.max(0, remaining - guaranteed);
if (extraP === 0 || afterGuaranteed === 0) {
expected[remaining] = 1 + expected[afterGuaranteed];
continue;
}
const afterExtra = Math.max(0, remaining - guaranteed - 1);
expected[remaining] = 1
+ (1 - extraP) * expected[afterGuaranteed]
+ extraP * expected[afterExtra];
}
return expected[work];
}
/**
* Whole-row action policy for Torn's actual cost model.
*
* Manual guesses cost no nerve. Three misses only lock further guessing; they
* do not remove brute force. Consequently the highest-P(hit) guess has free
* upside and leaves brute force available on a miss, so GUESS dominates
* BRUTE_FORCE whenever a modelled, typeable guess remains. Brute force is the
* action only when guessing is locked or every hidden slot is encrypted.
*/
function decisionPolicy(opts) {
opts = opts || {};
const positions = Array.isArray(opts.guessPositions)
? new Set(opts.guessPositions.map(Number))
: null;
const usable = (opts.slots || []).filter(slot => (
slot && slot.letters && slot.letters.length
&& (!positions || positions.has(slot.position))
));
let best = opts.best || null;
if (!best) {
for (const slot of usable) {
const top = slot.letters[0];
if (!top) continue;
if (!best || top.p > best.p) {
best = { position: slot.position, char: top.char, p: top.p };
}
}
}
const hiddenCharacters = Number.isFinite(opts.hiddenCharacters)
? Math.max(0, Math.floor(opts.hiddenCharacters))
: (opts.slots || []).length;
const encryptionLayers = Number.isFinite(opts.encryptionLayers)
? Math.max(0, Math.floor(opts.encryptionLayers))
: 0;
const workCycles = hiddenCharacters + encryptionLayers;
const guessesKnown = Number.isFinite(opts.guessesLeft);
const guessesLeft = guessesKnown ? Math.max(0, Math.floor(opts.guessesLeft)) : null;
const suppliedStrength = Number(opts.bruteStrength ?? opts.bruteCyclesPerAction);
const bruteStrengthKnown = Number.isFinite(suppliedStrength) && suppliedStrength > 0;
const bruteStrength = bruteStrengthKnown ? suppliedStrength : 1;
const bruteNerve = Math.max(0, Number(opts.bruteNerve) || 0);
const bruteActions = expectedBruteActions(workCycles, bruteStrength);
let action = 'WAIT';
let reason = 'No modelled action is available yet.';
if (workCycles <= 0) {
action = 'COMPLETE';
reason = 'No hidden characters or encryption layers remain.';
} else if ((guessesKnown && guessesLeft === 0) || !usable.length) {
action = 'BRUTE_FORCE';
reason = guessesKnown && guessesLeft === 0
? 'Guessing is locked; brute force remains available.'
: 'Every remaining hidden character is encrypted or not typeable.';
} else if (best) {
action = 'GUESS';
reason = 'The guess is free; a hit saves one brute-force cycle and a miss still leaves brute force available.';
}
return {
version: 2,
action,
reason,
best,
guessesLeft,
openSlots: usable.length,
openClearP: openClearChance(usable, guessesLeft),
nextHitP: best ? Math.max(0, Math.min(1, Number(best.p) || 0)) : null,
expectedCyclesSaved: action === 'GUESS' && best
? Math.max(0, Math.min(1, Number(best.p) || 0))
: 0,
hiddenCharacters,
encryptionLayers,
workCycles,
bruteStrength: bruteStrengthKnown ? bruteStrength : null,
bruteStrengthKnown,
// Backward-compatible name for exported telemetry written by 2.2.1.
bruteCyclesPerAction: bruteStrengthKnown ? bruteStrength : null,
expectedBruteActions: bruteActions,
bruteActions,
expectedBruteNerve: bruteActions * bruteNerve,
bruteNerve: bruteActions * bruteNerve,
};
}
/* ------------------------------------------------------------- indexing */
/**
* Parse a wordlist into packed buckets. Rank = line order, which for the
* base list is genuine popularity order and is now stored rather than
* inferred from where the word happens to sit in an array.
*/
function indexWordlist(text, opts) {
const minLen = opts.minLen, maxLen = opts.maxLen;
const src = opts.src === undefined ? SRC_BASE : opts.src;
const buckets = opts.buckets || {};
const seen = opts.seen || {};
let processed = 0, added = 0;
let rank = opts.startRank || 0;
const lines = String(text || '').split(/\r?\n/);
for (const raw of lines) {
processed++;
const word = raw.trim().toUpperCase();
if (!word) continue;
const len = word.length;
if (len < minLen || len > maxLen) continue;
const codes = encodeWord(word);
if (!codes) continue;
if (!seen[len]) seen[len] = new Set();
if (seen[len].has(word)) continue;
seen[len].add(word);
if (!buckets[len]) buckets[len] = makeBucket(len);
bucketAdd(buckets[len], codes, src === SRC_BASE ? rank : 0, src);
rank++;
added++;
}
return { buckets, seen, processed, added, endRank: rank };
}
/* ------------------------------------------------------------- telemetry */
/**
* Reliability curve: bucket predictions by stated probability and compare to
* the observed hit rate. If the model is honest these should track. This is
* the check that would have flagged the "100% but wrong" problem
* automatically instead of it being noticed by hand months later.
*/
function calibrationReport(guesses, bins) {
bins = bins || 10;
const out = [];
for (let b = 0; b < bins; b++) out.push({ lo: b / bins, hi: (b + 1) / bins, n: 0, hits: 0 });
for (const g of guesses || []) {
const p = Math.max(0, Math.min(0.999999, Number(g.p) || 0));
const b = out[Math.floor(p * bins)];
b.n++;
if (g.hit) b.hits++;
b.sumP = (b.sumP || 0) + p;
}
return out.map(b => ({
range: [b.lo, b.hi],
n: b.n,
predicted: b.n ? (b.sumP || 0) / b.n : 0,
actual: b.n ? b.hits / b.n : 0,
}));
}
/** Measured dictionary coverage per (length, level bucket). Feeds coveragePrior. */
function coverageReport(cracks, levelBucket) {
const bucketOf = levelBucket || (lv => (Number.isFinite(lv) ? Math.floor(lv / 10) * 10 : 'na'));
const cells = {};
for (const c of cracks || []) {
const key = `${c.len}|${bucketOf(c.level)}`;
if (!cells[key]) cells[key] = { len: c.len, level: bucketOf(c.level), n: 0, hits: 0 };
cells[key].n++;
if (c.wasInDict) cells[key].hits++;
}
return cells;
}
/** Distinct vs total observations, and the repeat histogram. */
function repeatReport(obs) {
const counts = Object.values(obs || {}).map(o => (typeof o === 'number' ? o : o.n) || 0);
const total = counts.reduce((a, b) => a + b, 0);
const hist = {};
for (const n of counts) hist[n] = (hist[n] || 0) + 1;
return {
distinct: counts.length,
total,
seenMoreThanOnce: counts.filter(n => n > 1).length,
avgPerWord: counts.length ? total / counts.length : 0,
hist,
};
}
return {
ALPHABET, A, WILD, codeOfChar, encodeWord, decodeCodes,
SRC_BASE, SRC_POOL, SRC_LOCAL, DEFAULT_PARAMS, mergeParams, priorOf,
makeBucket, bucketAdd, bucketFind, bucketWord, bucketExport, bucketImport,
buildModels, backoffDist, affixVote, coveragePrior, buildAllow, query,
openClearChance, expectedBruteActions, decisionPolicy, indexWordlist, calibrationReport, coverageReport,
repeatReport,
};
}
/* __CRACK_CORE_FACTORY_END__ */
/* ==================================================================== */
/* Engine — owns the packed dictionary, the character models, the */
/* observation counts, and their IndexedDB persistence. */
/* */
/* Written as a factory taking the core as an argument so that the exact */
/* same function can run in two places: inside a Worker (assembled with */
/* toString(), see makeWorkerSource) or directly on this thread when a */
/* Worker is unavailable. One implementation, no duplicated logic. */
/* ==================================================================== */
function CrackEngineFactory(Core, config, onNote) {
var DB_NAME = config.dbName;
var DB_VERSION = config.dbVersion;
var STORE = config.store;
var MIN_LENGTH = config.minLength;
var MAX_LENGTH = config.maxLength;
var SRC_BASE = Core.SRC_BASE;
var SRC_POOL = Core.SRC_POOL;
var SRC_LOCAL = Core.SRC_LOCAL;
/* ---------------------------------------------------------------- state */
var buckets = {}; // length -> packed bucket
var models = null; // character models, rebuilt lazily
var modelsDirty = true;
var observations = {}; // word -> { n, len, idx }
var contextObs = {}; // contextKey -> { word -> count }
var poolContextObs = {}; // contextKey -> { word -> shared support record }
var globalObs = {}; // length -> Float32Array aligned to the bucket
var calibration = {}; // "len|levelBucket" -> { n, hits }
var params = Core.mergeParams(null);
var revision = 0;
function note(msg) {
if (typeof onNote === 'function') onNote(msg);
}
function engineDuration(ms) {
var seconds = Math.max(0, Math.floor(ms / 1000));
var minutes = Math.floor(seconds / 60);
var rest = seconds % 60;
return minutes > 0 ? minutes + 'm ' + rest + 's' : rest + 's';
}
function engineProgress(label, completed, total, startedAt) {
var elapsed = Math.max(0, Date.now() - startedAt);
var percent = total > 0 ? Math.min(100, Math.floor(100 * completed / total)) : 0;
var remaining = 'time left: estimating…';
if (completed > 0 && total > completed && elapsed > 0) {
var estimate = Math.max(1000, elapsed * (total - completed) / completed);
remaining = '~' + engineDuration(estimate) + ' left';
} else if (total > 0 && completed >= total) {
remaining = '0s left';
}
return label + ' · ' + percent + '% · elapsed ' + engineDuration(elapsed) + ' · ' + remaining;
}
/* ------------------------------------------------------------ IndexedDB */
var dbPromise = null;
function openDatabase() {
if (dbPromise) return dbPromise;
dbPromise = new Promise(function (resolve, reject) {
var request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = function () {
var db = request.result;
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
};
request.onsuccess = function () { resolve(request.result); };
request.onerror = function () { reject(request.error); };
});
return dbPromise;
}
function dbGet(key) {
return openDatabase().then(function (db) {
return new Promise(function (resolve, reject) {
var req = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
});
}
function dbPut(key, value) {
return openDatabase().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(value, key);
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error); };
});
});
}
function dbDelete(key) {
return openDatabase().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(key);
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error); };
});
});
}
/* --------------------------------------------------------- bucket index */
/* Membership checks are deliberately linear over packed bytes. A lookup
* runs only for a completed crack or a small community snapshot, while a
* permanent Map would retain ~868k decoded JS strings and is enough to
* exhaust a mobile userscript process. */
function findIndex(word, knownCodes) {
var bucket = buckets[word.length];
if (!bucket) return -1;
var codes = knownCodes || Core.encodeWord(word);
return codes ? Core.bucketFind(bucket, codes) : -1;
}
function ensureObsArray(len) {
var bucket = buckets[len];
if (!bucket) return;
var existing = globalObs[len];
if (!existing || existing.length < bucket.cap) {
var grown = new Float32Array(bucket.cap);
if (existing) grown.set(existing);
globalObs[len] = grown;
}
}
/* Rebuild the aligned observation arrays from the durable word->count map.
* Indices are validated because a bucket rebuild could shift them. */
function rebuildObsArrays() {
globalObs = {};
var len;
for (len in buckets) globalObs[len] = new Float32Array(buckets[len].cap);
for (var word in observations) {
var rec = observations[word];
var bucket = buckets[rec.len];
var arr = globalObs[rec.len];
if (!bucket || !arr) continue;
var idx = rec.idx;
if (idx == null || idx < 0 || idx >= bucket.count || Core.bucketWord(bucket, idx) !== word) {
idx = findIndex(word);
rec.idx = idx;
}
if (idx >= 0) arr[idx] += rec.n;
}
}
function ensureModels(force) {
if (models && !modelsDirty && !force) return;
var startedAt = Date.now();
models = Core.buildModels(buckets, globalObs, params, function (completed, total) {
note(engineProgress('Building character model (first run)', completed, total, startedAt));
});
modelsDirty = false;
}
/* --------------------------------------------------------- persistence */
function savePack(len) {
if (!buckets[len]) return Promise.resolve();
return dbPut('pack_' + len, Core.bucketExport(buckets[len]));
}
/** Commit a set of changed buckets together. If the tab is refreshed
* during the write, IndexedDB aborts the transaction instead of leaving a
* half-updated community snapshot for the next startup. */
function savePacks(lengths) {
var listed = Array.from(new Set((lengths || []).map(Number)))
.filter(function (len) { return buckets[len]; });
if (!listed.length) return Promise.resolve();
return openDatabase().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(STORE, 'readwrite');
var store = tx.objectStore(STORE);
for (var i = 0; i < listed.length; i++) {
var len = listed[i];
store.put(Core.bucketExport(buckets[len]), 'pack_' + len);
}
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error); };
tx.onabort = function () { reject(tx.error || new Error('dictionary save aborted')); };
});
});
}
function saveAllPacks() {
var pending = [];
for (var len in buckets) pending.push(savePack(len));
return Promise.all(pending);
}
function replaceAllPacks() {
return openDatabase().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(STORE, 'readwrite');
var store = tx.objectStore(STORE);
for (var len = MIN_LENGTH; len <= MAX_LENGTH; len++) {
store.delete('pack_' + len);
}
for (var key in buckets) {
store.put(Core.bucketExport(buckets[key]), 'pack_' + key);
}
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error); };
tx.onabort = function () { reject(tx.error || new Error('dictionary save aborted')); };
});
});
}
function saveObservations() {
return Promise.all([dbPut('obs', observations), dbPut('ctxObs', contextObs)]);
}
function savePoolContexts() {
return dbPut('poolCtxObs', poolContextObs);
}
/* ------------------------------------------------------ adding words */
/* Upgrade in place when the word is already known. Migration cannot tell a
* community-pool word from a base-list word, so a later sync has to be able
* to repair the tier rather than skip the word as a duplicate. */
function addOrUpgrade(word, src) {
var len = word.length;
if (len < MIN_LENGTH || len > MAX_LENGTH) return { added: false, idx: -1 };
var codes = Core.encodeWord(word);
if (!codes) return { added: false, idx: -1 };
if (!buckets[len]) buckets[len] = Core.makeBucket(len);
var bucket = buckets[len];
var idx = Core.bucketFind(bucket, codes);
if (idx < 0) {
idx = Core.bucketAdd(bucket, codes, 0, src);
ensureObsArray(len);
revision++;
return { added: true, idx: idx };
}
if (src > bucket.src[idx]) {
bucket.src[idx] = src;
bucket.rank[idx] = 0;
revision++;
}
return { added: false, idx: idx };
}
function hashCodes(codes, offset, len) {
var hash = 2166136261;
for (var i = 0; i < len; i++) {
hash ^= codes[offset + i];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
/**
* Merge a small remote word set into the large packed base dictionary.
*
* The former code called bucketFind once (and context sync twice) per
* community word. With ~1,400 remote words that meant hundreds of complete
* base-bucket scans. Here the remote words form a tiny hash table and each
* affected packed bucket is scanned exactly once, without retaining a Map
* of the 868k-word base list.
*/
function mergePoolTargets(targets) {
var byLength = {};
var unique = Object.create(null);
for (var i = 0; i < targets.length; i++) {
var target = targets[i];
if (!target || !target.codes) continue;
var word = String(target.word || '');
var len = word.length;
var uniqueKey = len + '|' + word;
if (unique[uniqueKey]) continue;
unique[uniqueKey] = true;
target.found = false;
if (!byLength[len]) byLength[len] = [];
byLength[len].push(target);
}
var added = 0;
var updated = 0;
var changedLengths = {};
var lengths = Object.keys(byLength).map(Number).sort(function (a, b) { return a - b; });
var totalScan = 0;
var completedScan = 0;
var scanStartedAt = Date.now();
for (var wi = 0; wi < lengths.length; wi++) {
var workBucket = buckets[lengths[wi]];
if (workBucket) totalScan += workBucket.count;
}
for (var li = 0; li < lengths.length; li++) {
var length = lengths[li];
var listed = byLength[length];
if (!buckets[length]) buckets[length] = Core.makeBucket(length);
var bucket = buckets[length];
var hashes = new Map();
for (var ti = 0; ti < listed.length; ti++) {
var targetHash = hashCodes(listed[ti].codes, 0, length);
var collision = hashes.get(targetHash);
if (!collision) { collision = []; hashes.set(targetHash, collision); }
collision.push(listed[ti]);
}
var progressLabel = 'Indexing community pool (' + (li + 1) + '/' + lengths.length + ')';
note(engineProgress(progressLabel, completedScan, totalScan, scanStartedAt));
var nextProgress = 25000;
for (var bi = 0; bi < bucket.count; bi++) {
if (bi >= nextProgress) {
note(engineProgress(progressLabel, completedScan + bi, totalScan, scanStartedAt));
nextProgress += 25000;
}
var offset = bi * length;
var candidates = hashes.get(hashCodes(bucket.codes, offset, length));
if (!candidates) continue;
for (var ci = 0; ci < candidates.length; ci++) {
var candidate = candidates[ci];
if (candidate.found) continue;
var same = true;
for (var p = 0; p < length; p++) {
if (bucket.codes[offset + p] !== candidate.codes[p]) { same = false; break; }
}
if (!same) continue;
candidate.found = true;
if (bucket.src[bi] < SRC_POOL) {
bucket.src[bi] = SRC_POOL;
bucket.rank[bi] = 0;
updated++;
changedLengths[length] = true;
}
break;
}
}
completedScan += bucket.count;
for (ti = 0; ti < listed.length; ti++) {
if (listed[ti].found) continue;
Core.bucketAdd(bucket, listed[ti].codes, 0, SRC_POOL);
added++;
changedLengths[length] = true;
}
ensureObsArray(length);
}
if (totalScan > 0) {
note(engineProgress('Indexing community pool', completedScan, totalScan, scanStartedAt));
}
if (added || updated) revision += added + updated;
return { added: added, updated: updated, changedLengths: changedLengths };
}
function indexPoolText(text) {
var lines = String(text || '').split(/\r?\n/);
var targets = [];
var seen = Object.create(null);
for (var i = 0; i < lines.length; i++) {
var word = lines[i].trim().toUpperCase();
if (!word || seen[word]) continue;
seen[word] = true;
if (word.length < MIN_LENGTH || word.length > MAX_LENGTH) continue;
var codes = Core.encodeWord(word);
if (!codes) continue;
targets.push({ word: word, codes: codes });
}
var merged = mergePoolTargets(targets);
if (!models) modelsDirty = true;
return savePacks(Object.keys(merged.changedLengths)).then(function () {
return { added: merged.added, updated: merged.updated };
});
}
/* Parse a wordlist. For the base list, line order is genuine popularity
* order, so it is stored as the rank rather than inferred later from where
* the word happens to sit in an array. */
function indexText(text, src) {
if (src === SRC_POOL) return indexPoolText(text);
var lines = String(text || '').split(/\r?\n/);
var rank = 0;
var added = 0;
var updated = 0;
for (var i = 0; i < lines.length; i++) {
var word = lines[i].trim().toUpperCase();
if (!word) continue;
var len = word.length;
if (len < MIN_LENGTH || len > MAX_LENGTH) continue;
var codes = Core.encodeWord(word);
if (!codes) continue;
if (!buckets[len]) buckets[len] = Core.makeBucket(len);
var bucket = buckets[len];
var known = Core.bucketFind(bucket, codes);
if (known >= 0) {
if (src > bucket.src[known]) {
bucket.src[known] = src;
bucket.rank[known] = 0;
updated++;
}
rank++;
continue;
}
var idx = Core.bucketAdd(bucket, codes, src === SRC_BASE ? rank : 0, src);
rank++;
added++;
}
for (var key in buckets) ensureObsArray(key);
revision++;
if (src === SRC_BASE || !models) modelsDirty = true;
rebuildObsArrays();
return saveAllPacks().then(function () { return { added: added, updated: updated }; });
}
/* Replace only the generic source-tier-0 dictionary. Community-confirmed
* and locally observed words survive, along with every telemetry map. */
function replaceBase(text) {
var retained = {};
var len;
for (len in buckets) {
var oldBucket = buckets[len];
for (var i = 0; i < oldBucket.count; i++) {
var src = oldBucket.src[i];
if (src > SRC_BASE) {
var word = Core.bucketWord(oldBucket, i);
if (!retained[len]) retained[len] = [];
retained[len].push({ word: word, src: src });
}
}
}
buckets = {};
globalObs = {};
for (len in retained) {
for (var j = 0; j < retained[len].length; j++) {
addOrUpgrade(retained[len][j].word, retained[len][j].src);
}
}
rebuildObsArrays();
var deletes = [];
for (len = MIN_LENGTH; len <= MAX_LENGTH; len++) deletes.push(dbDelete('pack_' + len));
return Promise.all(deletes).then(function () { return indexText(text, SRC_BASE); });
}
/* A mobile install must not materialise the complete base snapshot as one
* response string. The host calls these three operations with bounded text
* chunks. Persistence is committed only after every chunk validates, so a
* failed attempt cannot replace a previously working on-disk dictionary. */
var baseLoad = null;
function beginBaseReplace() {
var retained = [];
for (var len in buckets) {
var oldBucket = buckets[len];
for (var i = 0; i < oldBucket.count; i++) {
var src = oldBucket.src[i];
if (src > SRC_BASE) {
var word = Core.bucketWord(oldBucket, i);
retained.push({
word: word,
src: src,
});
}
}
}
buckets = {};
globalObs = {};
var retainedSeen = {};
for (var r = 0; r < retained.length; r++) {
var rec = retained[r];
var codes = Core.encodeWord(rec.word);
if (!codes) continue;
var recLen = rec.word.length;
if (!buckets[recLen]) buckets[recLen] = Core.makeBucket(recLen);
if (!retainedSeen[recLen]) retainedSeen[recLen] = new Map();
if (!retainedSeen[recLen].has(rec.word)) {
var retainedIdx = Core.bucketAdd(buckets[recLen], codes, 0, rec.src);
retainedSeen[recLen].set(rec.word, retainedIdx);
}
}
baseLoad = {
nextRank: 0,
added: 0,
retained: retained.length,
retainedSeen: retainedSeen,
};
modelsDirty = true;
return Promise.resolve({ retained: retained.length, nextRank: 0 });
}
function appendBaseChunk(text, startRank) {
if (!baseLoad) return Promise.reject(new Error('base install was not started'));
startRank = Number(startRank);
if (!isFinite(startRank) || startRank !== baseLoad.nextRank) {
return Promise.reject(new Error('base chunk rank mismatch'));
}
var lines = String(text || '').split(/\r?\n/);
var valid = 0;
var added = 0;
for (var i = 0; i < lines.length; i++) {
var word = lines[i].trim().toUpperCase();
if (!word) continue;
var len = word.length;
if (len < MIN_LENGTH || len > MAX_LENGTH) {
return Promise.reject(new Error('invalid base word length'));
}
var codes = Core.encodeWord(word);
if (!codes) return Promise.reject(new Error('invalid base word'));
if (!buckets[len]) buckets[len] = Core.makeBucket(len);
var bucket = buckets[len];
// The published base snapshot is already de-duplicated. The only
// expected match is a retained pool/local word, whose stronger tier
// must survive the base replacement.
var retainedMap = baseLoad.retainedSeen[len];
if (!retainedMap || !retainedMap.has(word)) {
Core.bucketAdd(bucket, codes, startRank + valid, SRC_BASE);
added++;
}
valid++;
}
baseLoad.nextRank += valid;
baseLoad.added += added;
return Promise.resolve({ valid: valid, added: added, nextRank: baseLoad.nextRank });
}
function finishBaseReplace(expectedCount) {
if (!baseLoad) return Promise.reject(new Error('base install was not started'));
expectedCount = Number(expectedCount);
if (!isFinite(expectedCount) || baseLoad.nextRank !== expectedCount) {
return Promise.reject(new Error('base word count mismatch'));
}
var result = { added: baseLoad.added, updated: 0 };
baseLoad = null;
rebuildObsArrays();
revision++;
modelsDirty = true;
return replaceAllPacks().then(function () {
return Object.assign(result, stats());
});
}
function recordObservation(word, contextKey) {
var len = word.length;
var result = addOrUpgrade(word, SRC_LOCAL);
if (result.idx < 0) return Promise.resolve({ n: 0 });
var rec = observations[word] || { n: 0, len: len, idx: result.idx };
rec.n++;
rec.len = len;
rec.idx = result.idx;
observations[word] = rec;
ensureObsArray(len);
if (globalObs[len]) globalObs[len][result.idx] = rec.n;
if (contextKey) {
if (!contextObs[contextKey]) contextObs[contextKey] = {};
contextObs[contextKey][word] = (contextObs[contextKey][word] || 0) + 1;
}
return Promise.all([savePack(len), saveObservations()]).then(function () {
return { n: rec.n, repeat: Core.repeatReport(observations) };
});
}
function normalizeContextPart(value) {
return String(value || '').trim().replace(/\s+/g, ' ').toUpperCase().slice(0, 80);
}
/* Replace the complete remote association snapshot. Remote associations
* are kept separate from this browser's own sightings and each gets the
* same fixed weight, so duplicate reports cannot inflate a suggestion. */
function replacePoolContexts(associations) {
var next = {};
var poolTargets = [];
var poolTargetSeen = Object.create(null);
var imported = 0;
var listed = Array.isArray(associations) ? associations : [];
for (var i = 0; i < listed.length; i++) {
var item = listed[i] || {};
var word = String(item.word || '').trim().toUpperCase();
var codes = Core.encodeWord(word);
if (!codes || word.length < MIN_LENGTH || word.length > MAX_LENGTH) continue;
var title = normalizeContextPart(item.title);
var service = normalizeContextPart(item.service);
if (!title && !service) continue;
var support = 1;
var levelMin = Number(item.level_min);
var levelMax = Number(item.level_max);
if (!isFinite(levelMin) || levelMin < 1 || levelMin > 100) levelMin = null;
if (!isFinite(levelMax) || levelMax < 1 || levelMax > 100) levelMax = null;
if (!poolTargetSeen[word]) {
poolTargetSeen[word] = true;
poolTargets.push({ word: word, codes: codes });
}
var contextKey = title + '||' + service;
if (!next[contextKey]) next[contextKey] = {};
var previous = next[contextKey][word];
if (!previous || support > previous.support) {
next[contextKey][word] = {
support: support,
levelMin: levelMin,
levelMax: levelMax,
};
}
imported++;
}
poolContextObs = next;
var merged = mergePoolTargets(poolTargets);
return Promise.all([
savePoolContexts(),
savePacks(Object.keys(merged.changedLengths)),
]).then(function () {
return { imported: imported, contexts: Object.keys(poolContextObs).length };
});
}
function lookup(word) {
var len = word.length;
if (!buckets[len]) return { found: false };
var idx = findIndex(word);
if (idx < 0) return { found: false };
return {
found: true,
src: buckets[len].src[idx],
rank: buckets[len].rank[idx],
obs: (observations[word] && observations[word].n) || 0,
};
}
/* ------------------------------------------------- migration from 1.3.1 */
/* Converts the old string arrays into packed buckets in place, so the ~869k
* words already cached are not downloaded again. Every locally observed
* word is promoted to the LOCAL tier at the same time, which lifts every
* password this client has cracked out of the bottom of the rank order. */
function migrateLegacy() {
return dbGet('migrated_v2').then(function (alreadyDone) {
if (alreadyDone) return false;
note('Migrating cached dictionary…');
var chain = Promise.resolve();
var foundAny = false;
for (var len = 3; len <= 24; len++) {
chain = chain.then(makeLegacyStep(len));
}
function makeLegacyStep(len) {
return function () {
return dbGet('len_' + len).then(function (arr) {
if (!arr || !arr.length) return null;
foundAny = true;
if (!buckets[len]) buckets[len] = Core.makeBucket(len, arr.length);
var bucket = buckets[len];
var seen = new Set();
for (var i = 0; i < arr.length; i++) {
var word = String(arr[i] || '').toUpperCase();
if (word.length !== len || seen.has(word)) continue;
var codes = Core.encodeWord(word);
if (!codes) continue;
seen.add(word);
Core.bucketAdd(bucket, codes, i, SRC_BASE);
}
return dbDelete('len_' + len);
});
};
}
return chain
.then(function () {
return Promise.all([dbGet('word_observations'), dbGet('context_word_observations')]);
})
.then(function (legacy) {
var words = legacy[0] || {};
contextObs = legacy[1] || {};
for (var word in words) {
var n = Number(words[word]) || 0;
if (n <= 0) continue;
var len = word.length;
if (len < MIN_LENGTH || len > MAX_LENGTH) continue;
var codes = Core.encodeWord(word);
if (!codes) continue;
var idx = buckets[len] ? Core.bucketFind(buckets[len], codes) : -1;
if (idx < 0) {
if (!buckets[len]) buckets[len] = Core.makeBucket(len);
idx = Core.bucketAdd(buckets[len], codes, 0, SRC_LOCAL);
} else {
buckets[len].src[idx] = SRC_LOCAL;
buckets[len].rank[idx] = 0;
}
observations[word] = { n: n, len: len, idx: idx };
}
return Promise.all([
dbDelete('word_observations'),
dbDelete('context_word_observations'),
]);
})
.then(saveAllPacks)
.then(saveObservations)
.then(function () { return dbPut('migrated_v2', true); })
.then(function () { return foundAny; });
});
}
/* ------------------------------------------------------------ loading */
function loadEverything() {
return migrateLegacy()
.then(function () {
var chain = Promise.resolve();
for (var len = MIN_LENGTH; len <= MAX_LENGTH; len++) {
chain = chain.then(makeLoadStep(len));
}
return chain;
})
.then(function () {
return Promise.all([dbGet('obs'), dbGet('ctxObs'), dbGet('poolCtxObs'), dbGet('calib')]);
})
.then(function (stored) {
if (stored[0]) observations = stored[0];
if (stored[1]) contextObs = stored[1];
if (stored[2]) poolContextObs = stored[2];
if (stored[3]) calibration = stored[3];
rebuildObsArrays();
modelsDirty = true;
return stats();
});
function makeLoadStep(len) {
return function () {
if (buckets[len]) return null;
return dbGet('pack_' + len).then(function (rec) {
if (rec && rec.count) buckets[len] = Core.bucketImport(rec);
});
};
}
}
function stats() {
var perLength = {};
var total = 0;
for (var len in buckets) {
perLength[len] = buckets[len].count;
total += buckets[len].count;
}
return {
perLength: perLength,
total: total,
repeat: Core.repeatReport(observations),
contexts: Object.keys(contextObs).length,
poolContexts: Object.keys(poolContextObs).length,
revision: revision,
};
}
/* ------------------------------------------------------------- querying */
function levelBucketOf(level) {
return (typeof level === 'number' && isFinite(level)) ? Math.floor(level / 10) * 10 : 'na';
}
function runQuery(request) {
var patternText = String(request.pattern || '');
var len = patternText.length;
if (len < MIN_LENGTH || len > MAX_LENGTH) return { unsupported: true, len: len };
ensureModels();
var pattern = new Uint8Array(len);
for (var i = 0; i < len; i++) {
pattern[i] = patternText[i] === '*' ? Core.WILD : Core.codeOfChar(patternText[i]);
}
var exclusions = [];
for (var p = 0; p < len; p++) {
var set = new Set();
var listed = (request.exclusions && request.exclusions[p]) || [];
for (var k = 0; k < listed.length; k++) {
var code = Core.codeOfChar(listed[k]);
if (code !== 255) set.add(code);
}
exclusions[p] = set;
}
var allow = Core.buildAllow(pattern, exclusions, params);
var bucket = buckets[len] || null;
/* Contextual counts are sparse, so they travel as a small index->count
* map rather than an array the size of the bucket. */
var contextMap = null;
var counts = request.contextKey ? contextObs[request.contextKey] : null;
var poolCounts = request.contextKey ? poolContextObs[request.contextKey] : null;
if ((counts || poolCounts) && bucket) {
contextMap = new Map();
if (counts) {
for (var localWord in counts) {
var localIdx = findIndex(localWord);
if (localIdx >= 0) contextMap.set(localIdx, counts[localWord]);
}
}
if (poolCounts) {
for (var poolWord in poolCounts) {
var poolIdx = findIndex(poolWord);
if (poolIdx < 0) continue;
// Every unique remote association contributes the same cautious
// half-sighting, regardless of how often it was submitted.
var pseudoCount = 0.5;
contextMap.set(poolIdx, (contextMap.get(poolIdx) || 0) + pseudoCount);
}
}
}
var cell = calibration[len + '|' + levelBucketOf(request.level)] || null;
var result = Core.query({
bucket: bucket,
buckets: buckets,
models: models,
pat: pattern,
allow: allow,
gobs: globalObs[len] || null,
ctxObs: contextMap,
guessPositions: request.guessPositions || [],
level: request.level,
calib: cell,
params: params,
maxSug: request.maxSug || 8,
});
result.policy = Core.decisionPolicy({
slots: result.slots,
best: result.best,
guessPositions: request.guessPositions || [],
guessesLeft: request.guessesLeft,
hiddenCharacters: request.hiddenCharacters,
encryptionLayers: request.encryptionLayers,
bruteStrength: request.bruteStrength,
bruteNerve: params.BRUTE_NERVE,
});
result.revision = revision;
result.dictSize = bucket ? bucket.count : 0;
return result;
}
/* -------------------------------------------------------------- handler */
function handle(message) {
switch (message.type) {
case 'init':
return loadEverything();
case 'indexText':
return indexText(message.text, message.src).then(function (r) {
return Object.assign(r, stats());
});
case 'replaceBase':
return replaceBase(message.text).then(function (r) {
rebuildObsArrays();
return Object.assign(r, stats());
});
case 'beginBaseReplace':
return beginBaseReplace();
case 'appendBaseChunk':
return appendBaseChunk(message.text, message.startRank);
case 'finishBaseReplace':
return finishBaseReplace(message.expectedCount);
case 'addWord': {
var word = String(message.word || '').toUpperCase();
var added = addOrUpgrade(word, message.src);
return savePack(word.length).then(function () { return added; });
}
case 'recordObs':
return recordObservation(String(message.word || '').toUpperCase(), message.contextKey || '');
case 'replacePoolContexts':
return replacePoolContexts(message.associations || []);
case 'has':
return Promise.resolve(lookup(String(message.word || '').toUpperCase()));
case 'query':
return Promise.resolve(runQuery(message));
case 'stats':
return Promise.resolve(stats());
case 'setParams':
params = Core.mergeParams(message.params || null);
modelsDirty = true;
return Promise.resolve({ ok: true });
case 'setCalib':
calibration = message.calib || {};
return dbPut('calib', calibration).then(function () { return { ok: true }; });
case 'dumpObs':
return Promise.resolve({ obs: observations, ctxObs: contextObs, poolCtxObs: poolContextObs });
case 'resetObs':
observations = {};
contextObs = {};
rebuildObsArrays();
modelsDirty = true;
return saveObservations().then(stats);
default:
return Promise.reject(new Error('unknown message type: ' + message.type));
}
}
return { handle: handle };
}
/* ==================================================================== */
/* Worker bootstrap */
/* */
/* Runs inside the Worker. Stringified with toString() and concatenated */
/* with the two factories above to form the Worker source, so the code */
/* that runs in the Worker is the same readable code you see here. */
/* ==================================================================== */
function CrackWorkerBootstrap() {
var config = self.CRACK_CONFIG;
var core = CrackCoreFactory();
var engine = CrackEngineFactory(core, config, function (message) {
self.postMessage({ note: message });
});
self.onmessage = function (event) {
var message = event.data || {};
Promise.resolve()
.then(function () { return engine.handle(message); })
.then(function (data) {
self.postMessage({ id: message.id, ok: true, data: data });
})
.catch(function (error) {
self.postMessage({
id: message.id,
ok: false,
error: String((error && error.message) || error),
});
});
};
}
/* ==================================================================== */
/* Engine host — a Worker when one can be created, otherwise the same */
/* engine on this thread. */
/* ==================================================================== */
const ENGINE_CONFIG = {
dbName: DB_NAME,
dbVersion: DB_VERSION,
store: STORE,
minLength: MIN_LENGTH,
maxLength: MAX_LENGTH,
};
function makeWorkerSource() {
return [
'self.CRACK_CONFIG = ' + JSON.stringify(ENGINE_CONFIG) + ';',
CrackCoreFactory.toString(),
CrackEngineFactory.toString(),
CrackWorkerBootstrap.toString(),
'CrackWorkerBootstrap();',
].join('\n\n');
}
const Engine = (function () {
let worker = null;
let local = null;
let mode = 'idle';
let seq = 0;
const pending = new Map();
function startWorker() {
const blob = new Blob([makeWorkerSource()], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const w = new Worker(url);
URL.revokeObjectURL(url);
w.onmessage = (event) => {
const data = event.data || {};
if (data.note) { setEngineStatus(data.note); return; }
const entry = pending.get(data.id);
if (!entry) return;
pending.delete(data.id);
if (data.ok) entry.resolve(data.data);
else entry.reject(new Error(data.error || 'engine error'));
};
w.onerror = (event) => {
log('worker error, falling back to this thread', event);
for (const entry of pending.values()) entry.reject(new Error('worker crashed'));
pending.clear();
worker = null;
mode = 'local';
setStatus('Worker unavailable — running on the page thread');
};
return w;
}
function startLocal() {
// A blocked Blob worker used to be a dead end. The engine is written as a
// plain factory, so the identical code just runs here instead — slower on
// the initial index, otherwise equivalent.
return CrackEngineFactory(CrackCoreFactory(), ENGINE_CONFIG, setEngineStatus);
}
function call(type, payload) {
if (mode === 'idle') {
try {
worker = startWorker();
mode = 'worker';
} catch (e) {
log('worker unavailable, using the page thread', e);
mode = 'local';
}
}
if (mode === 'worker' && worker) {
const id = ++seq;
const message = Object.assign({ id, type }, payload || {});
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage(message);
});
}
if (!local) local = startLocal();
return Promise.resolve()
.then(() => local.handle(Object.assign({ type }, payload || {})));
}
return { call, get mode() { return mode; } };
})();
/* ==================================================================== */
/* Status badge + injected stylesheet */
/* ==================================================================== */
let statusEl = null;
const statusSinks = new Set();
let lastStatus = 'Idle';
let statusResetTimer = null;
/* One stylesheet, injected once. 1.3.1 rewrote dozens of inline styles per
* row on every tick; here a theme change is a handful of CSS variable writes.
* Kept as a readable template literal rather than packed strings. */
const STYLESHEET = `
:root {
--crk-bg: #000;
--crk-fg: #0f0;
--crk-bd: #0f0;
--crk-sug-bg: #000;
--crk-sug-fg: #0f0;
--crk-box: #111;
--crk-font: 10px;
}
.__crk_panel {
position: absolute;
z-index: 9999;
box-sizing: border-box;
text-align: center;
overflow: visible;
background: transparent;
color: var(--crk-sug-fg);
font-size: var(--crk-font);
border: 0;
pointer-events: none;
}
.__crk_list {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0;
}
.__crk_launcher {
min-height: 34px;
max-width: 190px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 9px;
border: 1px solid var(--crk-bd);
border-radius: 8px;
background: var(--crk-sug-bg);
color: var(--crk-sug-fg);
box-shadow: 0 2px 8px rgba(0, 0, 0, .45);
font: 700 11px/1.05 monospace;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
pointer-events: auto;
}
.__crk_launcher:hover,
.__crk_launcher:focus-visible {
outline: 2px solid var(--crk-bd);
outline-offset: 2px;
}
.__crk_launcher[disabled] { cursor: wait; opacity: .72; }
.__crk_launcher[data-state="locked"] { color: #ffd75a; }
.__crk_launcher[data-state="complete"] { color: #82c91e; }
.__crk_launcher_hint {
opacity: .7;
font-size: 9px;
}
.__crk_guess_ov {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 10px;
background: rgba(0, 0, 0, .72);
overscroll-behavior: contain;
}
.__crk_guess_box {
width: min(720px, 96vw);
max-height: calc(100dvh - 20px);
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
padding: 14px;
border: 1px solid var(--crk-bd);
border-radius: 12px;
background: var(--crk-box);
color: var(--crk-fg);
box-shadow: 0 12px 36px rgba(0, 0, 0, .72);
font-family: monospace;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.__crk_guess_head {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
}
.__crk_guess_title {
flex: 1 1 auto;
min-width: 0;
font-size: 17px;
font-weight: 800;
}
.__crk_guess_subtitle {
margin-top: 3px;
opacity: .75;
font-size: 11px;
overflow-wrap: anywhere;
}
.__crk_guess_close {
flex: 0 0 42px;
width: 42px;
height: 42px;
padding: 0;
border: 1px solid var(--crk-bd);
border-radius: 9px;
background: var(--crk-bg);
color: var(--crk-fg);
font: 800 22px/1 monospace;
cursor: pointer;
}
.__crk_guess_meta {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: 0 0 10px;
}
.__crk_guess_badge {
padding: 4px 7px;
border: 1px solid var(--crk-bd);
border-radius: 999px;
font-size: 10px;
}
.__crk_clone {
--crk-slot-count: 8;
display: grid;
grid-template-columns: repeat(var(--crk-slot-count), minmax(0, 1fr));
gap: 4px;
margin: 8px 0 12px;
}
.__crk_clone_slot {
position: relative;
min-width: 0;
min-height: clamp(58px, 13vw, 82px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 13px 2px 4px;
border: 1px solid var(--crk-bd);
border-radius: 7px;
background: var(--crk-bg);
color: var(--crk-fg);
font-family: monospace;
overflow: hidden;
}
button.__crk_clone_slot {
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
button.__crk_clone_slot:hover,
button.__crk_clone_slot:focus-visible {
outline: 2px solid var(--crk-bd);
outline-offset: 1px;
}
.__crk_clone_slot[data-best="true"] {
border-width: 2px;
box-shadow: 0 0 10px var(--crk-bd);
}
.__crk_clone_slot[data-state="locked"] { color: #ffd75a; opacity: .86; }
.__crk_clone_slot[data-state="revealed"] { opacity: .75; }
.__crk_clone_num {
position: absolute;
top: 3px;
left: 4px;
font-size: 8px;
opacity: .65;
}
.__crk_clone_char {
max-width: 100%;
font-size: clamp(18px, 5vw, 28px);
font-weight: 900;
line-height: 1;
}
.__crk_clone_pct {
max-width: 100%;
margin-top: 3px;
font-size: clamp(7px, 2.2vw, 10px);
line-height: 1;
white-space: nowrap;
}
.__crk_guess_help {
margin: 6px 0 10px;
font-size: 11px;
line-height: 1.35;
opacity: .8;
}
.__crk_slot_tabs,
.__crk_choice_grid,
.__crk_word_grid,
.__crk_attempt_grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.__crk_slot_tab,
.__crk_choice {
min-width: 46px;
min-height: 42px;
padding: 5px 8px;
border: 1px solid var(--crk-bd);
border-radius: 8px;
background: var(--crk-bg);
color: var(--crk-fg);
font-family: monospace;
cursor: pointer;
touch-action: manipulation;
}
.__crk_slot_tab[aria-pressed="true"] {
background: var(--crk-fg);
color: var(--crk-bg);
}
.__crk_choice {
min-width: 62px;
min-height: 50px;
font-size: 15px;
font-weight: 900;
}
.__crk_choice small {
display: block;
margin-top: 2px;
font-size: 9px;
font-weight: 500;
opacity: .75;
}
.__crk_modal_section {
margin-top: 12px;
font-size: 12px;
font-weight: 800;
}
.__crk_word {
padding: 4px 6px;
border-radius: 5px;
background: var(--crk-bg);
font-size: 10px;
opacity: .86;
}
.__crk_attempt {
padding: 5px 7px;
border: 1px solid #ff6b6b;
border-radius: 6px;
background: var(--crk-bg);
color: #ff8a8a;
font-size: 10px;
font-weight: 800;
}
.__crk_guess_toast {
position: fixed;
left: 50%;
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 2147483647;
transform: translateX(-50%);
max-width: min(90vw, 420px);
padding: 9px 12px;
border: 1px solid var(--crk-bd);
border-radius: 999px;
background: var(--crk-bg);
color: var(--crk-fg);
box-shadow: 0 4px 16px rgba(0, 0, 0, .6);
font: 700 12px/1.2 monospace;
text-align: center;
}
.__crk_chip {
padding: 2px 4px;
margin: 0 2px;
display: inline-block;
border-radius: 3px;
color: var(--crk-sug-fg);
}
.__crk_chip[data-msg] {
background: transparent;
border: none;
}
.__crk_chip[data-msg="no-match"] { color: #ff5555; }
.__crk_chip[data-msg="warn"] { color: #ffd75a; }
.__crk_chip[data-msg="loading"] { color: #ffd75a; }
.__crk_chip[data-msg="complete"] {
color: #82c91e;
font-weight: bold;
}
#__crack_status {
position: fixed;
right: 10px;
bottom: 40px;
z-index: 10000;
padding: 6px 8px;
font-size: 11px;
font-family: monospace;
opacity: 0.9;
border-radius: 6px;
background: var(--crk-bg);
color: var(--crk-fg);
border: 1px solid var(--crk-bd);
cursor: pointer;
user-select: none;
}
#__crack_status:hover,
#__crack_status:focus-visible {
outline: 2px solid var(--crk-bd);
outline-offset: 2px;
opacity: 1;
}
#__crack_menu_btn {
font-size: 10px;
text-align: left;
z-index: 9999;
cursor: pointer;
padding: 4px 6px;
background: var(--crk-bg);
color: var(--crk-fg);
border: 1px solid var(--crk-bd);
border-radius: 4px;
}
.__crk_ov {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2147483647;
padding: 12px;
box-sizing: border-box;
overscroll-behavior: contain;
}
.__crk_box {
background: var(--crk-box);
color: var(--crk-fg);
padding: 18px;
border: 1px solid var(--crk-bd);
border-radius: 10px;
text-align: left;
width: min(560px, 92vw);
box-sizing: border-box;
max-height: calc(100vh - 24px);
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
scrollbar-width: thin;
scrollbar-color: var(--crk-bd) var(--crk-bg);
}
.__crk_box::-webkit-scrollbar { width: 10px; }
.__crk_box::-webkit-scrollbar-track {
background: var(--crk-bg);
border-left: 1px solid var(--crk-bd);
}
.__crk_box::-webkit-scrollbar-thumb {
background: var(--crk-bd);
border: 2px solid var(--crk-bg);
border-radius: 999px;
}
.__crk_box::-webkit-scrollbar-thumb:hover { filter: brightness(1.35); }
.__crk_settings_head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.__crk_settings_title {
flex: 1 1 auto;
min-width: 0;
font-size: 19px;
text-align: left;
}
.__crk_tools {
display: flex;
align-items: center;
gap: 5px;
flex: 0 0 auto;
}
.__crk_box .__crk_tool {
width: 32px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font: 700 17px/1 monospace;
}
.__crk_tool[aria-pressed="false"] { opacity: 0.5; }
.__crk_tool[data-busy="true"] { cursor: wait; opacity: 0.65; }
.__crk_confirm {
display: none;
margin: 0 0 10px;
padding: 10px;
border: 1px solid #d33;
border-radius: 7px;
background: color-mix(in srgb, #a00 24%, var(--crk-box));
}
.__crk_confirm[data-open="true"] { display: block; }
.__crk_confirm_msg { font-size: 12px; margin-bottom: 8px; }
.__crk_confirm_actions {
display: flex;
justify-content: flex-end;
gap: 7px;
}
.__crk_row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 6px 0;
flex-wrap: wrap;
}
.__crk_settings_group {
margin: 8px 0;
border: 1px solid color-mix(in srgb, var(--crk-bd) 55%, transparent);
border-radius: 7px;
overflow: hidden;
}
.__crk_settings_group > summary {
padding: 8px 10px;
background: color-mix(in srgb, var(--crk-bg) 72%, transparent);
font-size: 12px;
font-weight: 800;
cursor: pointer;
user-select: none;
}
.__crk_settings_group_body { padding: 4px 10px 7px; }
.__crk_settings_group_body .__crk_row { margin: 4px 0; }
.__crk_pool_toggle_row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 7px 0;
font: 700 11px/1 monospace;
text-transform: uppercase;
}
.__crk_switch {
position: relative;
width: 42px;
height: 22px;
display: inline-block;
flex: 0 0 42px;
}
.__crk_switch input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.__crk_switch_slider {
position: absolute;
inset: 0;
border: 1px solid var(--crk-bd);
border-radius: 999px;
background: var(--crk-bg);
cursor: pointer;
}
.__crk_switch_slider::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 2px;
top: 2px;
border-radius: 50%;
background: var(--crk-fg);
transition: transform .16s ease;
}
.__crk_switch input:checked + .__crk_switch_slider {
background: color-mix(in srgb, #00d43b 30%, var(--crk-bg));
}
.__crk_switch input:checked + .__crk_switch_slider::after { transform: translateX(20px); }
.__crk_switch input:focus-visible + .__crk_switch_slider { outline: 2px solid var(--crk-bd); }
.__crk_stats { line-height: 1.45; }
.__crk_stat_line {
display: grid;
grid-template-columns: minmax(145px, 42%) minmax(0, 1fr);
gap: 8px;
}
.__crk_stat_line[data-sub="true"] > span:first-child { padding-left: 12px; }
.__crk_stat_per_length {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
}
.__crk_stat_compact {
border-bottom: 1px dotted currentColor;
cursor: help;
}
.__crk_stat_gap { height: 7px; }
.__crk_stat_note { margin-top: 8px; opacity: .86; }
.__crk_row label,
.__crk_row > div:first-child {
font-size: 12px;
opacity: 0.95;
flex: 1 1 auto;
min-width: 0;
}
.__crk_box input,
.__crk_box select,
.__crk_box textarea {
background: var(--crk-bg);
color: var(--crk-fg);
border: 1px solid var(--crk-bd);
border-radius: 4px;
padding: 4px 6px;
font-size: 12px;
}
.__crk_box button {
background: var(--crk-bg);
color: var(--crk-fg);
border: 1px solid var(--crk-bd);
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
}
.__crk_box button.danger {
background: #a00;
color: #fff;
border: none;
}
.__crk_box hr {
border: none;
border-top: 1px solid var(--crk-bd);
margin: 12px 0;
}
.__crk_mono {
font-family: monospace;
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 700px) {
.__crk_ov {
align-items: flex-start;
font-size: 13px;
padding: 10px;
}
.__crk_guess_ov {
align-items: flex-start;
padding: 6px;
}
.__crk_guess_box {
width: 100%;
max-height: calc(100dvh - 12px);
padding: 11px;
border-radius: 10px;
}
.__crk_guess_title { font-size: 15px; }
.__crk_launcher { max-width: 43vw; padding: 5px 7px; }
.__crk_launcher_hint { display: none; }
#__crack_status {
left: 10px;
right: 10px;
bottom: max(10px, env(safe-area-inset-bottom));
box-sizing: border-box;
font-size: 10px;
text-align: center;
}
.__crk_row {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.__crk_box input,
.__crk_box select,
.__crk_box textarea,
.__crk_box button {
width: 100%;
box-sizing: border-box;
}
.__crk_box .__crk_switch input { width: 1px; }
.__crk_settings_head { align-items: flex-start; }
.__crk_settings_title { font-size: 16px; }
.__crk_box .__crk_tool { width: 30px; }
.__crk_tools .__crk_tool { flex: 0 0 30px; }
}
`;
function injectStyle() {
if (document.getElementById('__crack_style')) {
refreshStyleVars();
return;
}
const style = document.createElement('style');
style.id = '__crack_style';
style.textContent = STYLESHEET;
document.head.appendChild(style);
refreshStyleVars();
}
function refreshStyleVars() {
const t = theme();
const r = document.documentElement.style;
r.setProperty('--crk-bg', t.uiBg);
r.setProperty('--crk-fg', t.uiText);
r.setProperty('--crk-bd', t.uiBorder);
r.setProperty('--crk-sug-bg', t.sugBg);
r.setProperty('--crk-sug-fg', t.sugText);
r.setProperty('--crk-box', t.boxBg);
r.setProperty('--crk-font', (isCompact() ? Math.min(t.sugFontPx, 9) : t.sugFontPx) + 'px');
}
function ensureBadge() {
if (statusEl && statusEl.isConnected) return statusEl;
statusEl = document.createElement('div');
statusEl.id = '__crack_status';
statusEl.textContent = 'Dictionary: ' + lastStatus;
statusEl.tabIndex = 0;
statusEl.setAttribute('role', 'button');
statusEl.setAttribute('aria-label', 'Open cRaCked settings');
statusEl.title = 'Open cRaCked settings';
statusEl.onclick = () => showSettings();
statusEl.onkeydown = event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
showSettings();
};
document.body.appendChild(statusEl);
statusEl.style.display = getBool(PREF.badge, true) ? 'block' : 'none';
return statusEl;
}
function setStatus(msg) {
if (statusResetTimer) {
clearTimeout(statusResetTimer);
statusResetTimer = null;
}
lastStatus = msg;
const text = 'Dictionary: ' + msg;
const b = ensureBadge();
if (b.textContent !== text) b.textContent = text;
statusSinks.forEach(el => { if (el.textContent !== text) el.textContent = text; });
log('STATUS', msg);
}
function setTransientStatus(msg, duration = 10000) {
setStatus(msg);
statusResetTimer = setTimeout(() => {
statusResetTimer = null;
restoreReadyStatus(msg).catch(e => log('ready status refresh failed', e));
}, duration);
}
function setEngineStatus(msg) {
if (/^Indexing community pool\b.*100%/.test(msg)) setTransientStatus(msg);
else setStatus(msg);
}
/* ==================================================================== */
/* DOM reading */
/* ==================================================================== */
const VALID_CHAR = /^[A-Z0-9]$/;
const RIG_STATUS_CACHE_MS = 2000;
const RIG_STRENGTH_SESSION_KEY = 'crack_rig_strength_v1';
let rigStatusCache = { at: 0, strength: readStoredBruteStrength() };
let rigObserver = null;
let rigCaptureTimer = null;
function readStoredBruteStrength() {
try {
const parsed = Number(sessionStorage.getItem(RIG_STRENGTH_SESSION_KEY));
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
} catch (e) { return null; }
}
function storeBruteStrength(strength) {
try {
if (Number.isFinite(strength) && strength > 0) {
sessionStorage.setItem(RIG_STRENGTH_SESSION_KEY, String(strength));
} else {
sessionStorage.removeItem(RIG_STRENGTH_SESSION_KEY);
}
} catch (e) { /* per-tab memory still works when storage is unavailable */ }
}
/** Read Torn's CSS-module statistic from the supplied rig DOM. */
function inspectRigBruteStrength(root) {
const scope = root && typeof root.querySelectorAll === 'function' ? root : document;
const statistics = [];
if (scope.nodeType === 1 && scope.matches(SEL.bruteStrength)) statistics.push(scope);
for (const element of scope.querySelectorAll(SEL.bruteStrength)) statistics.push(element);
for (const statistic of statistics) {
const label = statistic.querySelector(SEL.statisticLabel);
if (!/^Brute\s*forc(?:e|ing)\s*strength\s*:?/i.test(String(label && label.textContent || '').trim())) {
continue;
}
const rig = statistic.closest(SEL.rigStatus);
const state = rig && rig.querySelector(SEL.rigState);
const stateText = String(state && state.textContent || '').trim();
if (/offline/i.test(stateText)) return { found: true, strength: null };
const value = statistic.querySelector(SEL.statisticValue);
const match = String(value && value.textContent || '').match(/([0-9]+(?:\.[0-9]+)?)/);
const strength = match ? Number(match[1]) : NaN;
if (Number.isFinite(strength) && strength > 0) return { found: true, strength };
return { found: true, strength: null, pending: true };
}
return { found: false, strength: null };
}
function captureRigBruteStrength() {
const detected = inspectRigBruteStrength(document);
if (!detected.found || detected.pending) return false;
const changed = detected.strength !== rigStatusCache.strength;
rigStatusCache = { at: Date.now(), strength: detected.strength };
storeBruteStrength(detected.strength);
if (changed && runtimeStarted && isCrackingPage()) scanAll('rig-strength');
return true;
}
function nodeIsRigOrInside(node) {
const element = node && (node.nodeType === 1 ? node : node.parentElement);
if (!element) return false;
return element.matches(SEL.rigStatus) || element.matches(SEL.bruteStrength)
|| !!element.closest(SEL.rigStatus);
}
function nodeContainsRig(node) {
const element = node && (node.nodeType === 1 ? node : node.parentElement);
if (!element) return false;
return nodeIsRigOrInside(element)
|| !!element.querySelector(SEL.rigStatus)
|| !!element.querySelector(SEL.bruteStrength);
}
function scheduleRigCapture() {
if (rigCaptureTimer) return;
rigCaptureTimer = setTimeout(() => {
rigCaptureTimer = null;
captureRigBruteStrength();
}, 50);
}
function startRigStrengthObserver() {
captureRigBruteStrength();
if (rigObserver || !document.body) return;
rigObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
// Do not query every mutation target's complete subtree: when the
// target is <body>, that turns unrelated Torn updates into repeated
// whole-page scans. Only added subtrees need a descendant search.
if (nodeIsRigOrInside(mutation.target)
|| Array.from(mutation.addedNodes || []).some(nodeContainsRig)) {
scheduleRigCapture();
break;
}
}
});
rigObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
}
/**
* Return the value most recently observed in this tab. The short throttle
* avoids repeating DOM scans once per visible crack row.
*/
function readBruteStrength() {
const now = Date.now();
if (now - rigStatusCache.at < RIG_STATUS_CACHE_MS) return rigStatusCache.strength;
if (!captureRigBruteStrength()) rigStatusCache.at = now;
return rigStatusCache.strength;
}
function readSlot(slot, index) {
const disc = slot.querySelector(SEL.discoveredChar);
let ch = String((disc && disc.textContent) || '').trim().toUpperCase();
if (!VALID_CHAR.test(ch)) {
// A revealed character we cannot represent would silently corrupt the
// pattern, so make some noise rather than quietly writing '*'.
if (ch) log('unsupported revealed character', JSON.stringify(ch));
ch = '*';
}
const cls = typeof slot.className === 'string' ? slot.className : String(slot.getAttribute('class') || '');
const aria = String(slot.getAttribute('aria-label') || '');
const m = aria.match(/\b(\d+)\s+encryption\b/i);
const layers = m ? Math.max(0, Number(m[1]) || 0) : (cls.indexOf(SEL.encryptionClass) !== -1 ? 1 : 0);
const encrypted = layers > 0 || cls.indexOf(SEL.encryptionClass) !== -1;
const hidden = ch === '*';
const hasInput = !!slot.querySelector('input[type="text"]');
return {
index,
char: ch,
hidden,
encrypted,
layers,
hasInput,
open: hidden && !encrypted,
guessable: hidden && !encrypted && hasInput,
};
}
function readGuessState(row) {
const s = row.querySelector(SEL.guessesSection);
if (!s) return { left: null, pattern: null };
const label = String(s.getAttribute('aria-label') || '').trim();
let left = null;
if (/^No guesses\b/i.test(label)) left = 0;
const m = label.match(/^(\d+)\s+guess(?:es)?\b/i);
if (m) left = Number(m[1]);
// Torn's accessible summary carries the guess count and password pattern
// together. Comparing it with the slot DOM tells persistence whether both
// halves of the row have finished the same React hydration pass.
let pattern = null;
const summary = label.match(/,\s*(\d+)\s+slots?,\s*(.*?)\s*,\s*\d+\s+total encryption\b/i);
if (summary) {
const expectedLength = Number(summary[1]);
const tokens = summary[2].trim().split(/\s+/).filter(Boolean);
if (expectedLength === tokens.length) {
const chars = tokens.map(token => {
const ch = String(token).toUpperCase();
return token === '.' ? '*' : (VALID_CHAR.test(ch) ? ch : '');
});
if (!chars.includes('')) pattern = chars.join('');
}
}
return { left, pattern };
}
// CONFIRM: level selector unverified. Every strategy here fails soft.
function readLevel(row) {
const el = row.querySelector(SEL.level);
if (el) {
const n = parseInt(String(el.textContent || '').replace(/[^\d]/g, ''), 10);
if (Number.isFinite(n) && n > 0) return n;
}
const aria = String(row.getAttribute('aria-label') || '');
let m = aria.match(/level\s*[:\s]\s*(\d+)/i);
if (m) return Number(m[1]);
m = String(row.textContent || '').match(/\blevel\s*(\d+)\b/i);
if (m) return Number(m[1]);
return null;
}
function readContext(row) {
const wrap = row.querySelector(SEL.typeAndService);
const spans = wrap ? Array.from(wrap.querySelectorAll('span')) : [];
const title = String(((spans[0]) || {}).textContent || '').replace(/\s+/g, ' ').trim();
const service = String(((spans[1]) || {}).textContent || '').replace(/\s+/g, ' ').trim();
const norm = s => s.replace(/\s+/g, ' ').trim().toUpperCase();
const key = (norm(title) || norm(service)) ? norm(title) + '||' + norm(service) : '';
return { title, service, key };
}
/* ==================================================================== */
/* Row state — WeakMap keyed on the DOM node, so virtual-list recycling */
/* collects it for us. No rowKeys, no sessionStorage growth. */
/* ==================================================================== */
const rows = new WeakMap();
function stateOf(row, len) {
let st = rows.get(row);
if (!st) {
st = freshState(len);
rows.set(row, st);
}
return st;
}
function freshState(len) {
return {
len,
chars: new Array(len).fill('*'),
slots: [],
exclusions: Array.from({ length: len }, () => new Set()),
guessesLeft: null,
bruteStrength: null,
contextKey: '',
level: null,
pending: null,
everHadHidden: false, // the fix for inflated observation counts
completedWord: null,
lastResult: null,
wrongCount: 0,
guessLog: [],
maxEncryptionLayersSeen: 0,
panel: null,
observer: null,
queryToken: 0,
signature: '',
updateTimer: null,
identityReady: false,
memorySignature: '',
memoryCleared: false,
};
}
function resetState(row, len, ctxKey, level) {
if (activeSuggestionModal && activeSuggestionModal.row === row) closeSuggestionModal(false);
const st = freshState(len);
st.contextKey = ctxKey;
st.level = level;
const old = rows.get(row);
if (old) {
if (old.observer) old.observer.disconnect();
if (old.updateTimer) clearTimeout(old.updateTimer);
if (old.panel && old.panel.parentNode) old.panel.parentNode.removeChild(old.panel);
}
rows.set(row, st);
return st;
}
/* ==================================================================== */
/* Crack-memory persistence — one bounded entry per (context, length). */
/* It preserves exclusions and their original probabilities across a */
/* refresh/revisit, then expires or clears them when the crack changes. */
/* ==================================================================== */
const CRACK_MEMORY_KEY = 'crack_attempts_v3';
const LEGACY_EXCL_KEY = 'crack_excl_v2';
const CRACK_MEMORY_TTL_MS = 48 * 60 * 60 * 1000;
const CRACK_MEMORY_MAX = 60;
function crackMemoryId(ctxKey, len) { return ctxKey + '|' + len; }
function readCrackMemoryStore() {
try {
const parsed = JSON.parse(localStorage.getItem(CRACK_MEMORY_KEY) || '{}');
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
}
catch (e) { return {}; }
}
function writeCrackMemoryStore(all) {
try { localStorage.setItem(CRACK_MEMORY_KEY, JSON.stringify(all)); }
catch (e) { /* storage full or disabled; in-memory state still works */ }
}
function forgetCrackMemory(ctxKey, len) {
if (!ctxKey) return;
const id = crackMemoryId(ctxKey, len);
const all = readCrackMemoryStore();
if (all[id]) {
delete all[id];
writeCrackMemoryStore(all);
}
try {
const legacy = JSON.parse(sessionStorage.getItem(LEGACY_EXCL_KEY) || '{}');
if (legacy[id]) {
delete legacy[id];
sessionStorage.setItem(LEGACY_EXCL_KEY, JSON.stringify(legacy));
}
} catch (e) { /* ignore */ }
}
function crackMemoryPatternState(previous, current) {
if (previous.length !== current.length) return 'conflict';
let incomplete = false;
for (let i = 0; i < previous.length; i++) {
// During a refresh Torn can mount every slot as hidden, then hydrate the
// already-revealed characters a moment later. A hidden value on either
// side is therefore unknown, not evidence that the password changed.
if (previous[i] === '*') continue;
if (current[i] === '*') incomplete = true;
else if (previous[i] !== current[i]) return 'conflict';
}
return incomplete ? 'incomplete' : 'match';
}
function patternMatchesMemory(previous, current) {
return crackMemoryPatternState(previous, current) !== 'conflict';
}
function loadCrackMemory(ctxKey, len, pattern, guessesLeft, identitySettled = true) {
if (!ctxKey) return null;
const id = crackMemoryId(ctxKey, len);
const all = readCrackMemoryStore();
let rec = all[id];
// Bring forward exclusions stored by 2.1.9. They did not yet have the
// probabilities required for the incorrect-attempt history.
if (!rec) {
try {
const legacy = JSON.parse(sessionStorage.getItem(LEGACY_EXCL_KEY) || '{}');
if (legacy[id]) rec = { ...legacy[id], attempts: [], updatedAt: Date.now() };
} catch (e) { /* ignore */ }
}
if (!rec) return null;
const expired = Number.isFinite(rec.updatedAt)
&& Date.now() - rec.updatedAt > CRACK_MEMORY_TTL_MS;
const resetGuessCount = Number.isFinite(guessesLeft)
&& Number.isFinite(rec.guessesLeft) && guessesLeft > rec.guessesLeft;
const patternState = crackMemoryPatternState(String(rec.pattern || ''), pattern);
if (expired) {
forgetCrackMemory(ctxKey, len);
return null;
}
// A reset guess count or conflicting revealed character identifies a
// different crack once Torn has finished hydrating. Do not delete here:
// both values are briefly incomplete/defaulted while a refreshed row is
// mounting. processRow retries this lookup whenever that identity changes.
// Once every formerly revealed position is visible, either condition is
// definitive enough to retire the old password's record.
if (identitySettled
&& (patternState === 'conflict' || (resetGuessCount && patternState === 'match'))) {
forgetCrackMemory(ctxKey, len);
return null;
}
// Do not restore while one or more characters that fingerprinted the
// saved password are absent from the current DOM. A fresh password with
// the same target and length must not inherit an old exclusion merely
// because it has now reached the same remaining-guess count.
if (!identitySettled || resetGuessCount || patternState !== 'match') return null;
const exclusions = Array.from({ length: len }, (_, pos) => {
const listed = Array.isArray(rec.excl && rec.excl[pos]) ? rec.excl[pos] : [];
return new Set(listed.filter(ch => VALID_CHAR.test(String(ch || '').toUpperCase())));
});
const attempts = (Array.isArray(rec.attempts) ? rec.attempts : []).filter(item => (
item && Number.isInteger(item.pos) && item.pos >= 0 && item.pos < len
&& VALID_CHAR.test(String(item.char || '').toUpperCase()) && item.hit === false
)).map(item => ({
...item,
char: String(item.char).toUpperCase(),
p: Number.isFinite(item.p) ? item.p : null,
hit: false,
}));
return { exclusions, attempts };
}
function saveCrackMemory(st) {
if (!st.contextKey) return;
const attempts = st.guessLog.filter(item => item && item.hit === false).slice(-12);
if (!attempts.length) return;
const all = readCrackMemoryStore();
const id = crackMemoryId(st.contextKey, st.len);
all[id] = {
pattern: st.chars.join(''),
excl: st.exclusions.map(set => Array.from(set)),
attempts,
guessesLeft: st.guessesLeft,
updatedAt: Date.now(),
};
const keys = Object.keys(all).sort((a, b) => (
Number((all[b] && all[b].updatedAt) || 0) - Number((all[a] && all[a].updatedAt) || 0)
));
for (const key of keys.slice(CRACK_MEMORY_MAX)) delete all[key];
writeCrackMemoryStore(all);
}
/* ==================================================================== */
/* Telemetry */
/* ==================================================================== */
let telemetry = { cracks: [], guesses: [] };
let telemetryLoaded = false;
let telemetrySaveTimer = null;
async function loadTelemetry() {
if (telemetryLoaded) return telemetry;
const t = await idbGet('telemetry');
if (t && Array.isArray(t.cracks)) telemetry = t;
telemetryLoaded = true;
return telemetry;
}
function saveTelemetrySoon() {
if (telemetrySaveTimer) return;
telemetrySaveTimer = setTimeout(async () => {
telemetrySaveTimer = null;
if (telemetry.cracks.length > TELEMETRY_MAX_CRACKS) {
telemetry.cracks = telemetry.cracks.slice(-TELEMETRY_MAX_CRACKS);
}
if (telemetry.guesses.length > TELEMETRY_MAX_GUESSES) {
telemetry.guesses = telemetry.guesses.slice(-TELEMETRY_MAX_GUESSES);
}
try { await idbSet('telemetry', telemetry); } catch (e) { log('telemetry save failed', e); }
pushCalibration();
}, 3000);
}
/** Recompute measured coverage and hand it to the worker. Closes the loop. */
async function pushCalibration() {
try {
await loadTelemetry();
const cells = {};
for (const c of telemetry.cracks) {
const lb = Number.isFinite(c.level) ? Math.floor(c.level / 10) * 10 : 'na';
const key = c.len + '|' + lb;
if (!cells[key]) cells[key] = { n: 0, hits: 0 };
cells[key].n++;
if (c.foundSrc !== null && c.foundSrc !== undefined) cells[key].hits++;
}
await Engine.call('setCalib', { calib: cells });
} catch (e) { log('calibration push failed', e); }
}
function recordGuess(st, pos, ch, hit) {
const r = st.lastResult;
let p = null, source = null;
if (r && r.slots) {
const slot = r.slots.find(s => s.position === pos);
if (slot) {
const L = slot.letters.find(x => x.char === ch);
if (L) { p = L.p; source = (r.alpha * L.pDict >= (1 - r.alpha) * L.pBack) ? 'dict' : 'ngram'; }
}
}
const rec = {
t: Date.now(), ctxKey: st.contextKey, level: st.level, len: st.len,
pos, char: ch, hit: !!hit, p, source,
alpha: r ? r.alpha : null, matchCount: r ? r.matchCount : null,
revealed: st.chars.filter(c => c !== '*').length,
guessesLeft: r && r.policy && Number.isFinite(r.policy.guessesLeft)
? r.policy.guessesLeft : st.guessesLeft,
policyAction: r && r.policy ? r.policy.action : null,
openClearP: r && r.policy ? r.policy.openClearP : null,
workCycles: r && r.policy ? r.policy.workCycles : null,
expectedCyclesSaved: r && r.policy ? r.policy.expectedCyclesSaved : null,
bruteStrength: r && r.policy ? r.policy.bruteStrength : null,
expectedBruteActions: r && r.policy ? r.policy.expectedBruteActions : null,
expectedBruteNerve: r && r.policy ? r.policy.expectedBruteNerve : null,
};
st.guessLog.push(rec);
telemetry.guesses.push(rec);
if (!hit) {
st.wrongCount++;
saveCrackMemory(st);
}
saveTelemetrySoon();
}
function recordCrack(st, word, foundSrc) {
telemetry.cracks.push({
t: Date.now(), ctxKey: st.contextKey, level: st.level, len: st.len,
word, foundSrc: foundSrc === undefined ? null : foundSrc,
wrong: st.wrongCount, guesses: st.guessLog.length,
manualHits: st.guessLog.filter(g => g && g.hit).length,
lockedOut: st.guessesLeft === 0,
encryptionLayers: st.maxEncryptionLayersSeen || 0,
bruteStrength: st.lastResult && st.lastResult.policy
? st.lastResult.policy.bruteStrength : null,
});
saveTelemetrySoon();
}
/* ==================================================================== */
/* Optional network verdict hook */
/* ==================================================================== */
// CONFIRM: the crimes ajax endpoint and response shape are unverified, so
// this is off by default and the MutationObserver path is authoritative.
// Once the shape is known this becomes the race-free way to read a verdict.
const AJAX_MATCH = /sid=crimes|crimes\.php|crackData/i;
const netVerdicts = [];
function parseGuessResponse(url, text) {
try {
const j = JSON.parse(text);
// Shape unknown. Look for anything that plainly reads as a verdict.
const correct = j.correct !== undefined ? !!j.correct
: j.success !== undefined ? !!j.success
: j.isCorrect !== undefined ? !!j.isCorrect : null;
if (correct === null) return null;
return { correct, at: Date.now() };
} catch (e) { return null; }
}
function installNetHook() {
if (!getBool(PREF.netHook, false) || window.__crackNetHooked) return;
window.__crackNetHooked = true;
const origFetch = window.fetch;
if (origFetch) {
window.fetch = function (...args) {
const p = origFetch.apply(this, args);
try {
const url = String((args[0] && args[0].url) || args[0] || '');
if (AJAX_MATCH.test(url)) {
p.then(res => res.clone().text()).then(txt => {
const v = parseGuessResponse(url, txt);
if (v) netVerdicts.push(v);
}).catch(() => {});
}
} catch (e) { /* never let instrumentation break the page */ }
return p;
};
}
const origSend = XMLHttpRequest.prototype.send;
const origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
this.__crackUrl = String(url || '');
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
try {
if (AJAX_MATCH.test(this.__crackUrl || '')) {
this.addEventListener('load', () => {
const v = parseGuessResponse(this.__crackUrl, this.responseText);
if (v) netVerdicts.push(v);
});
}
} catch (e) { /* ignore */ }
return origSend.apply(this, arguments);
};
}
function takeNetVerdict(sinceMs) {
for (let i = netVerdicts.length - 1; i >= 0; i--) {
if (netVerdicts[i].at >= sinceMs) {
const v = netVerdicts[i];
netVerdicts.length = 0;
return v;
}
}
return null;
}
/* ==================================================================== */
/* Panel */
/* ==================================================================== */
function ensurePanel(row, st) {
if (st.panel && st.panel.isConnected) return st.panel;
const panel = document.createElement('div');
panel.className = '__crk_panel';
const list = document.createElement('div');
list.className = '__crk_list';
panel.appendChild(list);
st.panel = panel;
row.prepend(panel);
return panel;
}
function chip(list, text, kind, title, attrs) {
const sp = document.createElement('span');
sp.className = '__crk_chip';
sp.dataset.kind = kind || 'sug';
if (attrs) for (const k in attrs) if (attrs[k] != null) sp.dataset[k] = attrs[k];
sp.textContent = text;
if (title) sp.title = title;
list.appendChild(sp);
return sp;
}
function positionPanel(row, panel) {
const first = row.querySelector(SEL.charSlot);
if (!first) return;
const rr = row.getBoundingClientRect();
const sr = first.getBoundingClientRect();
if (!rr.width || !rr.height || !sr.width) return;
if (getComputedStyle(row).position === 'static') row.style.position = 'relative';
const compact = isCompact();
const gutter = Math.floor(sr.left - rr.left - 8);
panel.dataset.layout = compact ? 'compact' : 'desktop';
if (compact) {
Object.assign(panel.style, {
left: '4px', right: 'auto', top: '4px', bottom: 'auto',
width: 'auto', maxWidth: '43vw', maxHeight: 'none',
});
return;
}
Object.assign(panel.style, {
left: '4px', right: 'auto', top: '4px', bottom: 'auto',
width: 'fit-content', maxWidth: Math.min(190, Math.max(100, gutter - 4)) + 'px',
maxHeight: Math.max(30, Math.floor(rr.height - 8)) + 'px',
});
}
function tierName(src) { return src === 2 ? 'local' : src === 1 ? 'pool' : 'base'; }
let activeSuggestionModal = null;
let guessToastTimer = null;
function modalNode(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined && text !== null) node.textContent = text;
return node;
}
function showGuessToast(message) {
let toast = document.querySelector('.__crk_guess_toast');
if (!toast) {
toast = modalNode('div', '__crk_guess_toast');
toast.setAttribute('role', 'status');
document.body.appendChild(toast);
}
toast.textContent = message;
if (guessToastTimer) clearTimeout(guessToastTimer);
guessToastTimer = setTimeout(() => {
guessToastTimer = null;
if (toast.parentNode) toast.remove();
}, 2600);
}
function closeSuggestionModal(restoreFocus) {
const active = activeSuggestionModal;
activeSuggestionModal = null;
if (!active) return;
if (active.overlay && active.overlay.parentNode) active.overlay.remove();
if (restoreFocus && active.trigger && active.trigger.isConnected) {
try { active.trigger.focus({ preventScroll: true }); } catch (e) { /* ignore */ }
}
}
async function copySuggestedCharacter(ch) {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(ch);
return true;
}
} catch (e) { /* fall through to the selection-copy path */ }
const ta = document.createElement('textarea');
ta.value = ch;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
document.body.appendChild(ta);
ta.select();
let copied = false;
try { copied = document.execCommand('copy'); } catch (e) { /* ignore */ }
ta.remove();
return copied;
}
async function placeSuggestedCharacter(row, position, character) {
const ch = String(character || '').toUpperCase();
if (!VALID_CHAR.test(ch) || !row || !row.isConnected) {
showGuessToast('That crack row is no longer available');
closeSuggestionModal(false);
return false;
}
const slots = Array.from(row.querySelectorAll(SEL.charSlot));
const slot = slots[position];
if (!slot) {
showGuessToast('Slot ' + (position + 1) + ' is no longer available');
closeSuggestionModal(false);
return false;
}
const current = readSlot(slot, position);
const input = slot.querySelector('input[type="text"]');
if (!current.open || !input || input.disabled || input.readOnly) {
const copied = await copySuggestedCharacter(ch);
showGuessToast((copied ? 'Copied ' : 'Could not place ') + ch + ' for slot ' + (position + 1));
closeSuggestionModal(false);
return copied;
}
try { input.focus({ preventScroll: true }); } catch (e) { input.focus(); }
// Torn uses controlled inputs. Calling the native setter and dispatching a
// real input-shaped event reaches React's change handler while preserving
// this script's existing pending-guess/exclusion capture path.
const previous = input.value;
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
if (descriptor && descriptor.set) descriptor.set.call(input, ch);
else input.value = ch;
if (input._valueTracker && typeof input._valueTracker.setValue === 'function') {
input._valueTracker.setValue(previous);
}
let inputEvent;
try {
inputEvent = new InputEvent('input', {
bubbles: true,
composed: true,
inputType: 'insertText',
data: ch,
});
} catch (e) {
inputEvent = new Event('input', { bubbles: true, composed: true });
}
input.dispatchEvent(inputEvent);
showGuessToast('Sent ' + ch + ' to slot ' + (position + 1));
closeSuggestionModal(false);
return true;
}
function renderLauncher(list, row, st, res, positions, options) {
const opts = options || {};
const btn = modalNode('button', '__crk_launcher');
btn.type = 'button';
btn.dataset.state = opts.state || 'ready';
btn.disabled = !!opts.disabled;
btn.setAttribute('aria-label', opts.aria || opts.label || 'Open crack suggestions');
if (opts.title) btn.title = opts.title;
btn.appendChild(modalNode('span', '__crk_launcher_main', opts.label || 'CRACK'));
if (!opts.disabled) btn.appendChild(modalNode('span', '__crk_launcher_hint', 'OPEN'));
btn.addEventListener('pointerdown', event => event.stopPropagation());
btn.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (!res) return;
openSuggestionModal(row, st, res, positions || [], btn);
});
list.appendChild(btn);
return btn;
}
function openSuggestionModal(row, st, res, positions, trigger) {
closeSuggestionModal(false);
const overlay = modalNode('div', '__crk_guess_ov');
const box = modalNode('div', '__crk_guess_box');
box.setAttribute('role', 'dialog');
box.setAttribute('aria-modal', 'true');
box.setAttribute('aria-label', 'Crack character suggestions');
overlay.appendChild(box);
const head = modalNode('div', '__crk_guess_head');
const headText = modalNode('div', '__crk_guess_title', 'Password suggestions');
const context = [st.title, st.service].filter(Boolean).join(' · ');
headText.appendChild(modalNode('div', '__crk_guess_subtitle', context || 'Current cracking job'));
const close = modalNode('button', '__crk_guess_close', '×');
close.type = 'button';
close.setAttribute('aria-label', 'Close suggestions');
close.onclick = () => closeSuggestionModal(true);
head.appendChild(headText);
head.appendChild(close);
box.appendChild(head);
const meta = modalNode('div', '__crk_guess_meta');
if (Number.isFinite(st.guessesLeft)) {
meta.appendChild(modalNode('span', '__crk_guess_badge', st.guessesLeft
+ ' guess' + (st.guessesLeft === 1 ? '' : 'es') + ' left'));
}
if (res.matchCount > 0) {
meta.appendChild(modalNode('span', '__crk_guess_badge', 'In dictionary ' + formatPct(res.alpha)));
meta.appendChild(modalNode('span', '__crk_guess_badge', res.matchCount.toLocaleString() + ' matches'));
} else if (res.best && res.best.source === 'affix') {
meta.appendChild(modalNode('span', '__crk_guess_badge', 'Embedded word ' + formatPct(res.affixTrust)));
} else {
meta.appendChild(modalNode('span', '__crk_guess_badge', 'Character model'));
}
if (res.policy) {
const actionLabel = res.policy.action === 'GUESS' ? 'Guess first'
: res.policy.action === 'BRUTE_FORCE' ? 'Brute force'
: res.policy.action.toLowerCase();
meta.appendChild(modalNode('span', '__crk_guess_badge', actionLabel));
if (Number.isFinite(res.policy.openClearP) && res.policy.openSlots > 0) {
meta.appendChild(modalNode('span', '__crk_guess_badge',
'Open clear ' + formatPct(res.policy.openClearP)));
}
if (res.policy.workCycles > 0) {
meta.appendChild(modalNode('span', '__crk_guess_badge',
'BF work ' + res.policy.workCycles + ' cycle' + (res.policy.workCycles === 1 ? '' : 's')));
}
if (Number.isFinite(res.policy.bruteStrength) && res.policy.bruteStrength > 0) {
meta.appendChild(modalNode('span', '__crk_guess_badge',
'BFS ' + String(Number(res.policy.bruteStrength.toFixed(4)))));
} else {
meta.appendChild(modalNode('span', '__crk_guess_badge', 'BFS unavailable'));
}
if (res.policy.workCycles > 0 && Number.isFinite(res.policy.bruteStrength)
&& Number.isFinite(res.policy.expectedBruteActions)) {
const actions = res.policy.expectedBruteActions;
const nerve = res.policy.expectedBruteNerve;
const approximate = Number.isInteger(actions) ? '' : '≈';
const actionText = Number.isInteger(actions) ? String(actions) : actions.toFixed(2);
const nerveText = Number.isInteger(nerve) ? String(nerve) : nerve.toFixed(1);
meta.appendChild(modalNode('span', '__crk_guess_badge',
'BF ' + approximate + actionText + ' action' + (actions === 1 ? '' : 's')
+ ' · ' + approximate + nerveText + ' nerve'));
}
}
box.appendChild(meta);
const resultByPosition = new Map();
for (const slotResult of (res.slots || [])) resultByPosition.set(slotResult.position, slotResult);
const actionable = [];
const clone = modalNode('div', '__crk_clone');
clone.style.setProperty('--crk-slot-count', String(Math.max(1, st.slots.length)));
for (let i = 0; i < st.slots.length; i++) {
const actual = st.slots[i];
const predicted = resultByPosition.get(i);
const top = predicted && predicted.letters && predicted.letters[0];
const canUse = actual.open && top && st.guessesLeft !== 0;
const slotBox = modalNode(canUse ? 'button' : 'div', '__crk_clone_slot');
if (canUse) slotBox.type = 'button';
slotBox.dataset.best = String(!!(res.best && res.best.position === i));
slotBox.appendChild(modalNode('span', '__crk_clone_num', String(i + 1)));
if (!actual.hidden) {
slotBox.dataset.state = 'revealed';
slotBox.appendChild(modalNode('span', '__crk_clone_char', actual.char));
slotBox.appendChild(modalNode('span', '__crk_clone_pct', 'REVEALED'));
} else if (actual.encrypted) {
slotBox.dataset.state = 'locked';
slotBox.appendChild(modalNode('span', '__crk_clone_char', '🔒'));
slotBox.appendChild(modalNode('span', '__crk_clone_pct', top
? top.char + ' ' + formatPct(top.p)
: 'LOCKED'));
} else if (top) {
slotBox.dataset.state = 'open';
slotBox.appendChild(modalNode('span', '__crk_clone_char', top.char));
slotBox.appendChild(modalNode('span', '__crk_clone_pct', formatPct(top.p)));
slotBox.setAttribute('aria-label', 'Send ' + top.char + ' to slot ' + (i + 1)
+ ', ' + formatPct(top.p));
slotBox.onclick = () => placeSuggestedCharacter(row, i, top.char);
actionable.push(i);
} else {
slotBox.dataset.state = 'open';
slotBox.appendChild(modalNode('span', '__crk_clone_char', '?'));
slotBox.appendChild(modalNode('span', '__crk_clone_pct', 'NO ESTIMATE'));
}
clone.appendChild(slotBox);
}
box.appendChild(clone);
const incorrect = st.guessLog.filter(item => item && item.hit === false);
if (incorrect.length) {
box.appendChild(modalNode('div', '__crk_modal_section', 'Incorrect attempts'));
const attempts = modalNode('div', '__crk_attempt_grid');
for (const item of incorrect) {
const probability = Number.isFinite(item.p) ? formatPct(item.p) : 'probability unavailable';
const attempt = modalNode('span', '__crk_attempt',
'Slot ' + (item.pos + 1) + ': × ' + item.char + ' · ' + probability);
attempt.title = item.char + ' was tried in slot ' + (item.pos + 1)
+ ' when it was estimated at ' + probability + '.';
attempts.appendChild(attempt);
}
box.appendChild(attempts);
}
const implied = impliedWord(res, st);
if (implied && implied.filled) {
meta.appendChild(modalNode('span', '__crk_guess_badge', 'Likely ' + implied.text.toUpperCase()));
}
if (actionable.length) {
box.appendChild(modalNode('div', '__crk_guess_help',
'Tap a suggested letter in the cloned password to send it to that exact slot. '
+ 'Use the alternatives below for another choice. '
+ (res.policy && res.policy.action === 'GUESS'
? 'Guessing first is the optimal action: it costs no nerve, and brute force remains available after a miss.'
: '')));
box.appendChild(modalNode('div', '__crk_modal_section', 'Choose a slot'));
const tabs = modalNode('div', '__crk_slot_tabs');
const choices = modalNode('div', '__crk_choice_grid');
const tabButtons = new Map();
const renderChoices = position => {
choices.innerHTML = '';
for (const [pos, tab] of tabButtons) tab.setAttribute('aria-pressed', String(pos === position));
const slotResult = resultByPosition.get(position);
for (const letter of ((slotResult && slotResult.letters) || []).slice(0, maxSugPref())) {
const choice = modalNode('button', '__crk_choice', letter.char);
choice.type = 'button';
choice.appendChild(modalNode('small', '', formatPct(letter.p)));
choice.setAttribute('aria-label', 'Send ' + letter.char + ' to slot ' + (position + 1)
+ ', ' + formatPct(letter.p));
choice.onclick = () => placeSuggestedCharacter(row, position, letter.char);
choices.appendChild(choice);
}
};
for (const position of actionable) {
const tab = modalNode('button', '__crk_slot_tab', 'Slot ' + (position + 1));
tab.type = 'button';
tab.setAttribute('aria-pressed', 'false');
tab.onclick = () => renderChoices(position);
tabButtons.set(position, tab);
tabs.appendChild(tab);
}
box.appendChild(tabs);
box.appendChild(modalNode('div', '__crk_modal_section', 'Character choices'));
box.appendChild(choices);
const preferred = actionable.indexOf(res.best && res.best.position) !== -1
? res.best.position
: actionable[0];
renderChoices(preferred);
} else {
box.appendChild(modalNode('div', '__crk_guess_help',
st.guessesLeft === 0
? 'No guesses remain for this password.'
: 'Every hidden slot is encrypted. Predicted letters are shown for planning, but cannot be sent until the slot is open.'));
}
if (res.words && res.words.length) {
box.appendChild(modalNode('div', '__crk_modal_section', 'Matching passwords'));
const words = modalNode('div', '__crk_word_grid');
for (const word of res.words) {
const contextHint = Number(word.ctxObs) > 0 ? ' · type match' : '';
words.appendChild(modalNode('span', '__crk_word',
word.word + ' ' + formatPct(word.p) + contextHint));
}
box.appendChild(words);
}
overlay.addEventListener('click', event => {
if (event.target === overlay) closeSuggestionModal(true);
});
overlay.addEventListener('keydown', event => {
if (event.key === 'Escape') closeSuggestionModal(true);
});
document.body.appendChild(overlay);
activeSuggestionModal = { overlay: overlay, row: row, trigger: trigger };
close.focus({ preventScroll: true });
}
async function renderPanel(row, st) {
const pattern = st.chars.join('');
const complete = pattern.indexOf('*') === -1;
if (complete) {
st.queryToken++;
if (activeSuggestionModal && activeSuggestionModal.row === row) closeSuggestionModal(false);
if (st.panel && st.panel.parentNode) st.panel.parentNode.removeChild(st.panel);
st.panel = null;
return;
}
const panel = ensurePanel(row, st);
const list = panel.firstChild;
positionPanel(row, panel);
const slots = st.slots || [];
const guessPositions = [];
if (st.guessesLeft !== 0) {
// Only slots that actually accept typing. 1.3.1 disagreed with itself
// here: with a finite guess count it recommended any unencrypted hidden
// slot, including ones with no input to type into.
for (let i = 0; i < slots.length; i++) if (slots[i].guessable) guessPositions.push(i);
if (!guessPositions.length) {
for (let i = 0; i < slots.length; i++) if (slots[i].open) guessPositions.push(i);
}
}
const token = ++st.queryToken;
let res;
try {
res = await Engine.call('query', {
pattern,
exclusions: st.exclusions.map(s => Array.from(s)),
guessPositions,
guessesLeft: st.guessesLeft,
hiddenCharacters: slots.filter(s => s.hidden).length,
encryptionLayers: slots.reduce((sum, s) => sum + (s.hidden ? s.layers : 0), 0),
bruteStrength: st.bruteStrength,
level: st.level,
contextKey: st.contextKey,
maxSug: maxSugPref(),
});
} catch (e) {
if (token !== st.queryToken) return;
list.innerHTML = '';
renderLauncher(list, row, st, null, [], {
label: 'SCORER ERROR',
aria: 'Crack scorer error',
title: String(e.message || e),
state: 'locked',
disabled: true,
});
return;
}
if (token !== st.queryToken) return;
// The model rebuild announces itself and nothing put the badge back, so it
// sat on "Building character model…" indefinitely once a crack completed.
if (readyStatus && /Building character model/.test(lastStatus)) setStatus(readyStatus);
st.lastResult = res;
list.innerHTML = '';
if (res.unsupported) {
renderLauncher(list, row, st, null, [], {
label: 'LENGTH ' + res.len,
aria: 'Length ' + res.len + ' is not indexed',
title: 'Outside the indexed range ' + MIN_LENGTH + '-' + MAX_LENGTH + '.',
state: 'locked',
disabled: true,
});
return;
}
// An untrained model returns a flat distribution, and printing "A 2.6%" for
// every slot makes a still-loading dictionary indistinguishable from a
// genuine out-of-vocabulary word. 1.3.1 had a loading state; 2.0.0 dropped
// it by mistake. Say which it is.
if (!res.modelTrained || !res.dictSize) {
renderLauncher(list, row, st, null, [], {
label: 'CRACK LOADING…',
aria: dictLoadState || 'Dictionary loading',
title: 'The dictionary is not ready yet. Progress is shown in the status badge and settings.',
disabled: true,
});
return;
}
// No typeable slot means no recommendation. The core returns best = null for
// an empty guessPositions array; rows that reported NO OPEN SLOTS used to
// show a confident TRY chip for a slot the player could not use.
if (!res.best) {
const noneLeft = st.guessesLeft === 0;
const policy = res.policy || {};
const work = Number(policy.workCycles) || 0;
renderLauncher(list, row, st, res, guessPositions, {
label: policy.action === 'BRUTE_FORCE' ? '⚙ BRUTE FORCE · VIEW' : '🔒 VIEW LOCKED',
aria: policy.action === 'BRUTE_FORCE'
? 'Brute force recommended; open password details'
: 'Open locked password predictions',
title: (policy.reason || (noneLeft
? 'Torn reports no guesses remaining on this job.'
: 'Every remaining hidden slot is encrypted.'))
+ (work ? ' Remaining brute-force work: ' + work + ' cycles.' : ''),
state: 'locked',
});
return;
}
renderLauncher(list, row, st, res, guessPositions, {
label: '★ ' + (res.best.position + 1) + ':' + res.best.char + ' ' + formatPct(res.best.p),
aria: 'Best guess ' + res.best.char + ' for slot ' + (res.best.position + 1)
+ ', ' + formatPct(res.best.p) + '; open all suggestions',
title: (res.policy ? res.policy.reason + ' ' : '')
+ 'Open the cloned password and tappable character choices.',
state: 'ready',
});
}
/**
* The pattern with confident gaps filled in, e.g. BRUSHY11??. Worth showing
* even when no slot can be typed into, because it tells you whether the
* decryption is worth paying for.
*/
function impliedWord(res, st) {
if (!res.slots || !res.slots.length) return null;
const chars = st.chars.slice();
let filled = 0;
for (const slot of res.slots) {
const top = slot.letters && slot.letters[0];
if (top && top.p >= IMPLIED_MIN_P) {
chars[slot.position] = top.char.toLowerCase();
filled++;
} else {
chars[slot.position] = '?';
}
}
return filled ? { text: chars.join(''), filled } : null;
}
function lettersChip(list, res, positions, label) {
const ranked = res.slots
.filter(s => s.letters && s.letters.length && positions.indexOf(s.position) !== -1)
.sort((a, b) => b.letters[0].p - a.letters[0].p);
if (!ranked.length) return;
const shown = ranked.slice(0, LETTERS_SHOWN);
const parts = shown.map(s => (s.position + 1) + ':' + s.letters[0].char + ' ' + formatPct(s.letters[0].p));
const detail = ranked.map(s => 'Position ' + (s.position + 1) + ': '
+ s.letters.slice(0, 6).map(x => x.char + ' ' + formatPct(x.p)).join(' \u00b7 ')).join('\n');
const more = ranked.length - shown.length;
chip(list, label + ' ' + parts.join(' \u00b7 ') + (more > 0 ? ' +' + more : ''), 'pos', detail);
}
function renderWordChips(list, res) {
const sep = document.createElement('span');
sep.textContent = '|';
sep.style.opacity = '0.65';
list.appendChild(sep);
for (const w of res.words) {
const bits = ['source ' + tierName(w.src)];
if (w.src === 0) bits.push('list rank ' + (w.rank + 1).toLocaleString());
if (w.obs) bits.push('seen globally ' + w.obs + 'x');
if (w.ctxObs) bits.push('seen for this target/service ' + w.ctxObs + 'x');
chip(list, w.word + ' ' + formatPct(w.p), 'sug',
bits.join(' \u00b7 ')
+ '\nProbability already includes the chance the password is not in the dictionary.',
{ tier: tierName(w.src) });
}
}
function scheduleRender(row, st) {
if (st.updateTimer) clearTimeout(st.updateTimer);
st.updateTimer = setTimeout(() => {
st.updateTimer = null;
renderPanel(row, st).catch(e => log('render failed', e));
}, 40);
}
/* ==================================================================== */
/* Feedback capture */
/* ==================================================================== */
function attachRowObserver(row, st) {
if (st.observer) return;
// Watching class attributes means the transient incorrect-guess flash
// cannot slip between polls, which is what used to lose exclusions.
const obs = new MutationObserver(() => { processRow(row, 'mutation'); });
obs.observe(row, {
subtree: true, childList: true, characterData: true,
attributes: true, attributeFilter: ['class', 'aria-label'],
});
st.observer = obs;
if (row.dataset.crkInput === '1') return;
row.dataset.crkInput = '1';
const remember = (ev) => {
const t = ev.target;
if (!t || !t.matches || !t.matches('input[type="text"]')) return;
const slot = t.closest(SEL.charSlot);
if (!slot || !row.contains(slot)) return;
const slots = Array.from(row.querySelectorAll(SEL.charSlot));
const idx = slots.indexOf(slot);
if (idx < 0) return;
const raw = ev.type === 'keydown' ? ev.key : t.value;
const ch = String(raw || '').slice(-1).toUpperCase();
if (!VALID_CHAR.test(ch)) return;
const cur = rows.get(row);
if (!cur) return;
cur.pending = {
pos: idx, char: ch, at: performance.now(), wallAt: Date.now(),
guessesBefore: cur.guessesLeft,
};
};
row.addEventListener('keydown', remember, true);
row.addEventListener('input', remember, true);
}
function resolvePending(row, st, slots) {
const p = st.pending;
if (!p) return;
if (p.pos < 0 || p.pos >= slots.length) { st.pending = null; return; }
const age = performance.now() - p.at;
const slot = slots[p.pos];
const cls = String(slot.el.className || '');
const flashed = cls.indexOf(SEL.flashClass) !== -1;
const dropped = Number.isFinite(p.guessesBefore) && Number.isFinite(st.guessesLeft)
&& st.guessesLeft < p.guessesBefore;
const net = getBool(PREF.netHook, false) ? takeNetVerdict(p.wallAt) : null;
if (net) {
if (net.correct) { recordGuess(st, p.pos, p.char, true); }
else { recordMiss(row, st, p.pos, p.char); }
st.pending = null;
return;
}
if (slot.char !== '*') {
// Server returned a character, so the guess landed.
recordGuess(st, p.pos, p.char, slot.char === p.char);
st.pending = null;
return;
}
if (dropped || flashed) {
recordMiss(row, st, p.pos, p.char);
st.pending = null;
return;
}
if (age > PENDING_TIMEOUT_MS) st.pending = null;
}
function addExclusion(st, pos, ch) {
if (!st.exclusions[pos]) st.exclusions[pos] = new Set();
if (st.exclusions[pos].has(ch)) return false;
st.exclusions[pos].add(ch);
return true;
}
function invalidateSuggestionResult(row, st) {
// A query launched before the exclusion was known must never be allowed to
// publish afterwards. Invalidate its token immediately instead of waiting
// for the debounced replacement query to start.
st.queryToken++;
st.lastResult = null;
if (activeSuggestionModal && activeSuggestionModal.row === row) {
closeSuggestionModal(false);
}
if (st.panel && st.panel.isConnected && st.panel.firstChild) {
const list = st.panel.firstChild;
list.innerHTML = '';
renderLauncher(list, row, st, null, [], {
label: 'UPDATING…',
aria: 'Updating password suggestions after failed attempt',
state: 'locked',
disabled: true,
});
}
scheduleRender(row, st);
}
function recordMiss(row, st, pos, ch) {
// Preserve the probability from the result that proposed this character,
// and save the new exclusion, before clearing that now-stale result.
addExclusion(st, pos, ch);
recordGuess(st, pos, ch, false);
invalidateSuggestionResult(row, st);
}
/* ==================================================================== */
/* Main row processing */
/* ==================================================================== */
function processRow(row, why) {
const slotEls = Array.from(row.querySelectorAll(SEL.charSlot));
if (!slotEls.length) return;
const slots = slotEls.map((el, i) => {
const s = readSlot(el, i);
s.el = el;
return s;
});
const len = slots.length;
const chars = slots.map(s => s.char);
const pattern = chars.join('');
const ctx = readContext(row);
const level = readLevel(row);
const guessState = readGuessState(row);
const guessesLeft = guessState.left;
const memoryIdentitySettled = !guessState.pattern || guessState.pattern === pattern;
const bruteStrength = readBruteStrength();
let st = stateOf(row, len);
// --- identity: has this recycled node become a different job? --------
const lengthChanged = st.len !== len;
const contextChanged = !!(st.contextKey && ctx.key && st.contextKey !== ctx.key);
const revealedNow = chars.filter(c => c !== '*').length;
const revealedBefore = st.chars.filter(c => c !== '*').length;
// A drop in revealed characters means this cannot be the same password.
// 1.3.1 only caught length/context changes and complete -> fresh, so a
// partially revealed row replaced by a same-shape job inherited stale
// exclusions and actively poisoned the new word.
const revealedDropped = revealedNow < revealedBefore;
if (!st.identityReady || lengthChanged || contextChanged || revealedDropped) {
st = resetState(row, len, ctx.key, level);
st.identityReady = true;
}
// Context, revealed characters, and the remaining-guess count arrive in
// separate React renders after a refresh. Retry a rejected restore when
// any of those identity fields changes instead of making the first,
// possibly skeletal render the only chance to recover failed attempts.
const memorySignature = [ctx.key, len, pattern,
Number.isFinite(guessesLeft) ? guessesLeft : '?',
guessState.pattern || '?'].join('::');
if (ctx.key && !st.guessLog.length && st.memorySignature !== memorySignature) {
st.memorySignature = memorySignature;
const restored = loadCrackMemory(
ctx.key, len, pattern, guessesLeft, memoryIdentitySettled,
);
if (restored) {
st.exclusions = restored.exclusions;
st.guessLog = restored.attempts;
st.wrongCount = restored.attempts.length;
invalidateSuggestionResult(row, st);
}
}
st.len = len;
st.contextKey = ctx.key;
st.title = ctx.title;
st.service = ctx.service;
if (Number.isFinite(level)) st.level = level;
st.guessesLeft = guessesLeft;
st.bruteStrength = bruteStrength;
st.slots = slots;
st.maxEncryptionLayersSeen = Math.max(
st.maxEncryptionLayersSeen || 0,
slots.reduce((sum, slot) => sum + (slot.hidden ? slot.layers : 0), 0),
);
if (chars.some(c => c === '*')) st.everHadHidden = true;
resolvePending(row, st, slots);
st.chars = chars;
attachRowObserver(row, st);
// --- completion ------------------------------------------------------
const complete = pattern.indexOf('*') === -1;
if (complete && !st.memoryCleared) {
forgetCrackMemory(st.contextKey, st.len);
st.memoryCleared = true;
} else if (!complete) {
st.memoryCleared = false;
}
if (complete && st.everHadHidden && st.completedWord !== pattern) {
// everHadHidden is the fix for 1.3.1 counting an already-finished row
// as a fresh observation on every single page load.
st.completedWord = pattern;
if (VALID_CHAR.test(pattern[0]) && /^[A-Z0-9]+$/.test(pattern)) {
onCrackCompleted(row, st, pattern);
} else {
log('completed word has unsupported characters, skipping:', pattern);
}
}
// --- redraw only when something the panel depends on changed ----------
const sig = [
pattern,
guessesLeft === null ? '?' : guessesLeft,
ctx.key,
st.level,
st.exclusions.map(s => Array.from(s).sort().join('')).join('|'),
slots.map(s => s.layers + (s.hasInput ? 'i' : '')).join(''),
Number.isFinite(bruteStrength) ? bruteStrength : '?',
].join('::');
if (sig !== st.signature) {
st.signature = sig;
scheduleRender(row, st);
} else if (!st.panel || !st.panel.isConnected) {
scheduleRender(row, st);
} else if (why === 'resize') {
positionPanel(row, st.panel);
}
}
async function onCrackCompleted(row, st, word) {
try {
// Ask before adding, so "was it already known" is answerable. This is
// both the dictionary-coverage measurement and the repeat-rate
// measurement, and they are different questions: base means the generic
// list had it, pool/local means we have genuinely seen it before.
const found = await Engine.call('has', { word });
const foundSrc = found.found ? found.src : null;
recordCrack(st, word, foundSrc);
await Engine.call('recordObs', { word, contextKey: st.contextKey });
if (getBool(PREF.upload, true)) {
await enqueueOutbox(word, {
title: st.title,
service: st.service,
level: st.level,
});
}
scheduleRender(row, st);
} catch (e) {
log('completion handling failed', e);
}
}
/* ==================================================================== */
/* Page scanning — observer driven, slow interval only as a safety net */
/* ==================================================================== */
let listObserver = null;
let safetyTimer = null;
let rootObserver = null;
function scanAll(why) {
if (!runtimeStarted || !isCrackingPage()) return;
const current = document.querySelector(SEL.currentCrime);
if (!current) return;
const container = current.querySelector(SEL.virtualList);
if (!container) return;
if (!listObserver) {
listObserver = new MutationObserver(() => scanAll('list'));
listObserver.observe(container, { childList: true, subtree: false });
}
for (const row of container.querySelectorAll(SEL.crimeOption)) {
try { processRow(row, why); } catch (e) { log('row failed', e); }
}
}
function startObservers() {
if (!rootObserver) {
// Torn's SPA may mount the cracking UI after we boot, so watch for it
// rather than relying on the hash listener alone.
rootObserver = new MutationObserver(() => {
if (isCrackingPage()) { startRuntime(); scanAll('root'); injectMenuButton(); }
});
rootObserver.observe(document.body, { childList: true, subtree: true });
}
if (!safetyTimer) safetyTimer = setInterval(() => scanAll('safety'), SAFETY_SCAN_MS);
window.addEventListener('resize', onResize, { passive: true });
}
let resizeTimer = null;
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { refreshStyleVars(); scanAll('resize'); }, 150);
}
/* ==================================================================== */
/* Dictionary bootstrap + community sync */
/* ==================================================================== */
let dictReady = false;
let dictLoadState = 'DICTIONARY LOADING\u2026';
let readyStatus = '';
function readyStatusFromStats(stats) {
const total = Math.max(0, Number(stats && stats.total) || 0);
const observed = Math.max(0, Number(stats && stats.repeat && stats.repeat.distinct) || 0);
return 'Ready (' + total.toLocaleString() + ' words'
+ (observed ? ', ' + observed.toLocaleString() + ' observed' : '') + ')';
}
async function restoreReadyStatus(expectedStatus) {
if (expectedStatus && lastStatus !== expectedStatus) return;
try {
readyStatus = readyStatusFromStats(await Engine.call('stats'));
} catch (e) {
if (!readyStatus) throw e;
}
if (!expectedStatus || lastStatus === expectedStatus) setStatus(readyStatus || 'Ready');
}
async function bootstrapDictionary() {
setStatus('Opening cache…');
let s;
try {
s = await Engine.call('init');
} catch (e) {
setStatus('Scorer unavailable');
return;
}
await applyStoredParams();
const installedBase = await idbGet('base_snapshot_id');
if (!s.total || installedBase !== BASE_SNAPSHOT_ID) {
dictLoadState = 'DOWNLOADING WORDLIST\u2026';
setStatus('Downloading base wordlist…');
const ok = await downloadBaseList();
if (!ok) {
dictLoadState = 'WORDLIST DOWNLOAD FAILED — RETRYING';
setTimeout(() => bootstrapDictionary().catch(() => {}), 60000);
return;
}
s = await Engine.call('stats');
}
dictReady = true;
dictLoadState = '';
readyStatus = readyStatusFromStats(s);
setStatus(readyStatus);
await pushCalibration();
scheduleAutoSync();
}
async function downloadBaseList() {
let progressTimer = null;
try {
const manifestText = await fetchBasePart(BASE_MANIFEST_URL, 'manifest');
const manifest = JSON.parse(manifestText);
const chunks = Array.isArray(manifest.chunks) ? manifest.chunks : [];
const expectedCount = Number(manifest.count);
if (manifest.format_version !== 1 || manifest.snapshot_id !== BASE_SNAPSHOT_ID) {
throw new Error('base manifest version mismatch');
}
if (!Number.isInteger(expectedCount) || expectedCount <= 0 || expectedCount > 2000000) {
throw new Error('invalid base manifest count');
}
if (!chunks.length || chunks.length > 100) throw new Error('invalid base chunk list');
let nextRank = 0;
for (const chunk of chunks) {
if (!chunk || !/^chunks\/base-\d{3}\.txt$/.test(String(chunk.path || ''))) {
throw new Error('invalid base chunk path');
}
if (Number(chunk.start_rank) !== nextRank
|| !Number.isInteger(Number(chunk.line_count))
|| Number(chunk.line_count) <= 0
|| Number(chunk.line_count) > 50000) {
throw new Error('invalid base chunk metadata');
}
nextRank += Number(chunk.line_count);
}
if (nextRank !== expectedCount) throw new Error('base manifest total mismatch');
await Engine.call('beginBaseReplace');
const progressStartedAt = Date.now();
let completedWords = 0;
let activePart = 1;
const refreshProgress = (force = false) => {
if (!force && /failed — retrying in/i.test(lastStatus)) return;
const progress = formatProgressStatus(
'Indexing dictionary ' + activePart + '/' + chunks.length,
completedWords,
expectedCount,
progressStartedAt,
);
dictLoadState = progress.toUpperCase();
setStatus(progress);
};
refreshProgress();
progressTimer = setInterval(refreshProgress, 1000);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const part = i + 1;
activePart = part;
refreshProgress();
const text = await fetchBasePart(COMMUNITY_POOL_ORIGIN + '/base/' + chunk.path, part + '/' + chunks.length);
if (!text) throw new Error('empty base chunk');
refreshProgress(true);
const indexed = await Engine.call('appendBaseChunk', {
text: text,
startRank: Number(chunk.start_rank),
});
if (Number(indexed.valid) !== Number(chunk.line_count)) {
throw new Error('base chunk line count mismatch');
}
completedWords += Number(chunk.line_count);
refreshProgress(true);
}
clearInterval(progressTimer);
progressTimer = null;
dictLoadState = 'SAVING WORDLIST\u2026';
setStatus('Saving base dictionary · elapsed '
+ formatDuration(Date.now() - progressStartedAt) + '…');
const r = await Engine.call('finishBaseReplace', { expectedCount: expectedCount });
await idbSet('base_snapshot_id', BASE_SNAPSHOT_ID);
setStatus('Indexed ' + r.total.toLocaleString() + ' words');
return true;
} catch (e) {
log('base dictionary install failed', e);
setStatus('Failed to fetch base dictionary (will retry)');
return false;
} finally {
if (progressTimer) clearInterval(progressTimer);
}
}
async function fetchBasePart(url, label) {
const DELAYS = [0, 3000, 10000, 30000];
let lastError = null;
for (let attempt = 0; attempt < DELAYS.length; attempt++) {
const wait = DELAYS[attempt];
if (wait) {
setStatus('Dictionary part ' + label + ' failed — retrying in '
+ Math.ceil(wait / 1000) + 's…');
await new Promise(resolve => setTimeout(resolve, wait));
}
try {
const res = await gmRequest({ method: 'GET', url: url, timeout: 30000, responseType: 'text' });
if (res.status < 200 || res.status >= 300) throw new Error('status ' + res.status);
const text = res.responseText || '';
if (!text) throw new Error('empty response');
return text;
} catch (e) {
lastError = e;
}
}
throw lastError || new Error('dictionary part failed');
}
function isGzip(p) { return /\.gz$/i.test(String(p || '').split('?')[0]); }
async function gunzipToText(buf) {
if (typeof DecompressionStream !== 'function') {
throw new Error('DecompressionStream unavailable — serve an uncompressed snapshot');
}
const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream('gzip'));
return new Response(stream).text();
}
async function fetchRemoteMeta(force) {
if (!COMMUNITY_SYNC_ENABLED) return null;
const cached = (await idbGet('cf_metadata')) || {};
const last = Number(await idbGet('cf_last_meta_check_ts')) || 0;
if (!force && Date.now() - last < SYNC_CHECK_INTERVAL_MS) return cached;
const checkedAt = Date.now();
// Record attempts as well as successes so an outage cannot create a tight
// automatic retry loop. Keep the old key current for users upgrading from
// 2.1.6 and for existing diagnostic snippets.
await idbSet('cf_last_meta_check_ts', checkedAt);
await idbSet('cf_last_sync_ts', checkedAt);
try {
const url = METADATA_URL + '?cb=' + (force ? Date.now() : Math.floor(Date.now() / 60000));
const res = await gmRequest({ method: 'GET', url, timeout: 10000 });
if (res.status !== 200) throw new Error('metadata status ' + res.status);
const meta = JSON.parse(res.responseText || '{}');
const rec = {
count: meta.count || 0,
snapshot_path: meta.snapshot_path || meta.latest_path || null,
diff_path: meta.diff_path || null,
generated_at: meta.generated_at || null,
context_snapshot_path: meta.context_snapshot_path || null,
context_count: Number(meta.context_count) || 0,
context_etag: meta.context_etag || null,
context_generated_at: meta.context_generated_at || null,
};
await idbSet('cf_metadata', rec);
await idbSet('cf_remote_count', rec.count);
return rec;
} catch (e) {
log('meta fetch failed', e);
if (force) throw e;
return cached;
}
}
async function syncCommunityContexts(meta, force) {
if (!CONTEXT_SNAPSHOT_URL || !meta || !meta.context_snapshot_path) {
return { status: 'unsupported', imported: 0 };
}
if (!/^contexts\/[A-Za-z0-9._-]+$/.test(String(meta.context_snapshot_path))) {
throw new Error('invalid context snapshot path');
}
const storedEtag = String((await idbGet('cf_context_etag')) || '');
if (!force && meta.context_etag && storedEtag === meta.context_etag) {
return { status: 'unchanged', imported: Number(await idbGet('cf_context_count')) || 0 };
}
const res = await gmRequest({
method: 'GET',
url: CF_ORIGIN + '/' + meta.context_snapshot_path,
headers: storedEtag ? { 'If-None-Match': storedEtag } : {},
timeout: 20000,
responseType: 'text',
});
if (res.status === 304) {
if (meta.context_etag) await idbSet('cf_context_etag', meta.context_etag);
await idbSet('cf_context_sync_ts', Date.now());
return { status: 'unchanged', imported: Number(await idbGet('cf_context_count')) || 0 };
}
if (res.status !== 200) throw new Error('context snapshot status ' + res.status);
const payload = JSON.parse(res.responseText || '{}');
const associations = Array.isArray(payload.associations) ? payload.associations : null;
if (payload.format_version !== 1 || !associations || associations.length > 500000) {
throw new Error('invalid context snapshot');
}
if (Number(payload.count) !== associations.length) {
throw new Error('context snapshot count mismatch');
}
const imported = await Engine.call('replacePoolContexts', { associations });
const responseEtag = headerOf(res.responseHeaders, 'ETag');
await idbSet('cf_context_etag', responseEtag || meta.context_etag || '');
await idbSet('cf_context_count', Number(imported.imported) || 0);
await idbSet('cf_context_sync_ts', Date.now());
return { status: 'downloaded', imported: Number(imported.imported) || 0 };
}
async function syncCommunity(force) {
if (!COMMUNITY_SYNC_ENABLED) return { status: 'disabled', added: 0, updated: 0, changed: 0 };
let lastDownload = Number(await idbGet('cf_last_download_ts')) || 0;
if (!lastDownload) {
// 2.1.6 only stored the last metadata check. Seed the new download clock
// once so the compatibility migration cannot postpone small updates on
// every 15-minute check.
lastDownload = Number(await idbGet('cf_last_sync_ts')) || Date.now();
await idbSet('cf_last_download_ts', lastDownload);
}
const meta = await fetchRemoteMeta(force);
if (!meta || !meta.snapshot_path) throw new Error('community metadata unavailable');
let contextSync = { status: 'unsupported', imported: 0 };
try {
contextSync = await syncCommunityContexts(meta, force);
} catch (e) {
log('context sync failed', e);
contextSync = { status: 'failed', imported: 0 };
}
const lastCount = Number(await idbGet('cf_last_downloaded_count')) || 0;
const delta = Math.max(0, (meta.count || 0) - lastCount);
const overdue = delta > 0 && (!lastDownload || Date.now() - lastDownload >= SYNC_MAX_WAIT_MS);
if (!force && delta < DOWNLOAD_MIN_DELTA && !overdue) {
await idbSet('cf_pending_delta', delta);
return {
status: 'pending', pending: delta, added: 0, updated: 0, changed: 0,
contexts: contextSync,
};
}
setStatus(force ? 'Manual sync…' : 'Syncing (+' + delta + ')…');
const etag = (await idbGet('cf_remote_etag')) || '';
const gz = isGzip(meta.snapshot_path);
let res;
try {
res = await gmRequest({
method: 'GET',
url: CF_STORAGE_BASE + '/' + meta.snapshot_path,
headers: etag ? { 'If-None-Match': etag } : {},
timeout: 45000,
responseType: gz ? 'arraybuffer' : 'text',
});
} catch (e) {
log('snapshot fetch failed', e);
throw new Error('community snapshot download failed');
}
const newEtag = headerOf(res.responseHeaders, 'ETag');
if (newEtag) await idbSet('cf_remote_etag', newEtag);
if (res.status === 304) {
await idbSet('cf_last_downloaded_count', meta.count || 0);
await idbSet('cf_last_download_ts', Date.now());
await idbSet('cf_pending_delta', 0);
setTransientStatus('0 words added/updated');
return {
status: 'unchanged', pending: 0, added: 0, updated: 0, changed: 0,
contexts: contextSync,
};
}
if (res.status !== 200) throw new Error('community snapshot status ' + res.status);
const text = gz ? await gunzipToText(res.response) : (res.responseText || '');
if (!text) throw new Error('empty community snapshot');
setStatus('Indexing community snapshot…');
// src 1 = pool. Words already present from the v1 migration get their tier
// upgraded rather than skipped, which repairs their prior.
const r = await Engine.call('indexText', { text, src: 1 });
await idbSet('cf_last_downloaded_count', meta.count || 0);
await idbSet('cf_last_download_ts', Date.now());
await idbSet('cf_pending_delta', 0);
const added = Number(r.added) || 0;
const updated = Number(r.updated) || 0;
const changed = added + updated;
setTransientStatus(changed.toLocaleString() + ' words added/updated');
return {
status: 'downloaded', pending: 0, added, updated, changed, total: r.total,
contexts: contextSync,
};
}
let autoSyncTimer = null;
async function scheduleAutoSync() {
if (!COMMUNITY_SYNC_ENABLED) return;
if (autoSyncTimer) clearTimeout(autoSyncTimer);
const last = Number(await idbGet('cf_last_meta_check_ts')) || 0;
// Metadata is tiny, so check it periodically. The full snapshot is only
// downloaded at the 25-word threshold or when the 24-hour cap is reached.
const wait = Math.max(30000, last + SYNC_CHECK_INTERVAL_MS - Date.now());
autoSyncTimer = setTimeout(async () => {
autoSyncTimer = null;
if (runtimeStarted && isCrackingPage()) {
try {
await syncCommunity(false);
} catch (e) {
log('auto sync failed', e);
setStatus('Community sync failed (will retry)');
}
}
scheduleAutoSync();
}, wait);
}
/* ==================================================================== */
/* Outbox */
/* ==================================================================== */
let outboxBusy = false;
let outboxTimer = null;
let outboxFailures = 0;
let outboxChain = Promise.resolve();
let clientRegistrationPromise = null;
/** All queue mutations go through one chain, so no read-modify-write races. */
function withQueue(fn) {
outboxChain = outboxChain.then(async () => {
const q = (await idbGet('cf_outbox')) || [];
const next = await fn(q);
if (next !== undefined) await idbSet('cf_outbox', next.slice(0, OUTBOX_MAX_QUEUE));
}).catch(e => log('outbox queue op failed', e));
return outboxChain;
}
function normalizeOutboxItem(raw) {
const source = typeof raw === 'string' ? { word: raw } : (raw || {});
const word = String(source.word || '').trim().toUpperCase();
if (!/^[A-Z0-9]{4,10}$/.test(word)) return null;
const title = String(source.title || '').trim().replace(/\s+/g, ' ').slice(0, 80);
const service = String(source.service || '').trim().replace(/\s+/g, ' ').slice(0, 80);
const rawLevel = Number(source.level);
const level = Number.isInteger(rawLevel) && rawLevel >= 1 && rawLevel <= 100 ? rawLevel : null;
return { word, title, service, level };
}
function outboxItemKey(raw) {
const item = normalizeOutboxItem(raw);
return item ? item.word + '\u001f' + item.title.toUpperCase() + '\u001f' + item.service.toUpperCase() : '';
}
async function enqueueOutbox(word, details) {
if (!COMMUNITY_SYNC_ENABLED || !getBool(PREF.upload, true) || !word) return;
const item = normalizeOutboxItem(Object.assign({ word }, details || {}));
if (!item) return;
const key = outboxItemKey(item);
let queued = 0;
await withQueue(q => {
const normalized = q.map(normalizeOutboxItem).filter(Boolean);
const existing = normalized.findIndex(entry => outboxItemKey(entry) === key);
if (existing >= 0) normalized[existing] = item;
else normalized.push(item);
queued = normalized.length;
return normalized;
});
if (queued >= OUTBOX_BATCH_SIZE) {
if (outboxTimer) clearTimeout(outboxTimer);
outboxTimer = null;
flushOutbox().catch(e => log('outbox flush failed', e));
} else if (!outboxTimer) {
outboxTimer = setTimeout(flushOutbox, OUTBOX_FLUSH_DELAY_MS);
}
}
async function registerClient(force) {
if (!COMMUNITY_SYNC_ENABLED || !CF_REGISTER_CLIENT_URL) return '';
if (!force) {
const stored = String((await idbGet(CLIENT_ID_STORE_KEY)) || '');
if (CLIENT_ID_RE.test(stored)) return stored;
}
if (clientRegistrationPromise) return clientRegistrationPromise;
clientRegistrationPromise = (async () => {
if (force) await idbSet(CLIENT_ID_STORE_KEY, '');
const res = await gmRequest({
method: 'POST',
url: CF_REGISTER_CLIENT_URL,
headers: { 'Content-Type': 'application/json' },
data: '{}',
timeout: 15000,
});
if (res.status < 200 || res.status >= 300) {
throw new Error('client registration status ' + res.status);
}
const data = JSON.parse(res.responseText || '{}');
const clientId = String(data.client_id || '');
if (data.ok !== true || !CLIENT_ID_RE.test(clientId)) {
throw new Error('invalid client registration response');
}
await idbSet(CLIENT_ID_STORE_KEY, clientId);
return clientId;
})().finally(() => { clientRegistrationPromise = null; });
return clientRegistrationPromise;
}
async function postBatch(observations, retried) {
const clientId = await registerClient(false);
const headers = { 'Content-Type': 'application/json' };
if (clientId) headers['X-cRaCked-Client-ID'] = clientId;
headers['X-cRaCked-Version'] = CLIENT_VERSION;
headers['X-cRaCked-Build-ID'] = CLIENT_BUILD_ID;
const res = await gmRequest({
method: 'POST',
url: CF_ADD_WORD_URL,
headers,
data: JSON.stringify({ observations }),
timeout: 15000,
});
if ((res.status === 400 || res.status === 401) && clientId && !retried) {
let data = {};
try { data = JSON.parse(res.responseText || '{}'); } catch (e) {}
if (data.error === 'invalid client ID' || data.error === 'client registration required') {
await registerClient(true);
return postBatch(observations, true);
}
}
if (res.status < 200 || res.status >= 300) throw new Error('status ' + res.status);
return res;
}
async function flushOutbox() {
outboxTimer = null;
if (outboxBusy) return; // no concurrent flushes
if (!COMMUNITY_SYNC_ENABLED || !getBool(PREF.upload, true)) {
await withQueue(() => []);
return;
}
outboxBusy = true;
try {
while (true) {
let batch = [];
await withQueue(q => {
batch = q.slice(0, OUTBOX_BATCH_SIZE);
return q; // do not remove yet
});
if (!batch.length) break;
try {
await postBatch(batch);
// Only drop from the queue once the server has accepted them.
const sent = new Set(batch.map(outboxItemKey));
await withQueue(q => q.filter(item => !sent.has(outboxItemKey(item))));
outboxFailures = 0;
log('outbox flushed', batch.length);
} catch (e) {
outboxFailures++;
log('outbox batch failed', outboxFailures, e);
if (outboxFailures >= OUTBOX_MAX_CONSECUTIVE_FAILURES) {
// Back off instead of spinning. Nothing is discarded.
setStatus('Upload paused (retrying later)');
outboxTimer = setTimeout(flushOutbox, OUTBOX_BACKOFF_MS);
break;
}
outboxTimer = setTimeout(flushOutbox, OUTBOX_POST_INTERVAL_MS * outboxFailures);
break;
}
await new Promise(r => setTimeout(r, OUTBOX_POST_INTERVAL_MS));
}
} finally {
outboxBusy = false;
}
}
/* ==================================================================== */
/* Params */
/* ==================================================================== */
async function applyStoredParams() {
let over = null;
try {
const raw = getStr(PREF.params, '');
if (raw) over = JSON.parse(raw);
} catch (e) { log('bad params json', e); }
try { await Engine.call('setParams', { params: over }); } catch (e) { log('setParams failed', e); }
}
/* ==================================================================== */
/* Settings */
/* ==================================================================== */
function el(tag, props, css) {
const e = document.createElement(tag);
if (props) Object.assign(e, props);
if (css) e.style.cssText = css;
return e;
}
function addRow(parent, label, control) {
const row = el('div');
row.className = '__crk_row';
row.appendChild(el('div', { textContent: label }));
if (control) row.appendChild(control);
parent.appendChild(row);
return row;
}
function addCheck(parent, label, key, def, onChange) {
const wrap = el('label', {}, 'cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;margin:8px 0;');
const cb = el('input');
cb.type = 'checkbox';
cb.checked = getBool(key, def);
cb.onchange = () => { setBool(key, cb.checked); if (onChange) onChange(cb.checked); };
wrap.appendChild(cb);
wrap.appendChild(el('span', { textContent: label }));
parent.appendChild(wrap);
return cb;
}
async function showSettings() {
injectStyle();
if (document.querySelector('.__crk_ov')) return;
const overlay = el('div');
overlay.className = '__crk_ov';
overlay.style.background = theme().overlayBg;
const box = el('div');
box.className = '__crk_box';
overlay.appendChild(box);
const settingsHead = el('div');
settingsHead.className = '__crk_settings_head';
const settingsTitle = el('div', { textContent: 'cRaCked 2.2.8 — Settings' });
settingsTitle.className = '__crk_settings_title';
settingsHead.appendChild(settingsTitle);
const tools = el('div');
tools.className = '__crk_tools';
settingsHead.appendChild(tools);
box.appendChild(settingsHead);
function toolButton(glyph, title, danger) {
const b = el('button', { textContent: glyph, title });
b.className = '__crk_tool' + (danger ? ' danger' : '');
b.setAttribute('aria-label', title);
tools.appendChild(b);
return b;
}
let bSync = null;
if (COMMUNITY_SYNC_ENABLED) {
bSync = toolButton('↻', 'Sync community words now', false);
bSync.onclick = async () => {
if (bSync.dataset.busy === 'true') return;
bSync.dataset.busy = 'true';
bSync.disabled = true;
setStatus('Syncing community words…');
try {
await syncCommunity(true);
await refreshStats();
await refreshNextSync();
} catch (e) {
setStatus('Sync failed: ' + (e.message || e));
} finally {
bSync.disabled = false;
bSync.dataset.busy = 'false';
}
};
}
const bResetObs = toolButton('↺', 'Reset observation counts', true);
const bClear = toolButton('⌫', 'Clear wordlist cache', true);
const confirmPanel = el('div');
confirmPanel.className = '__crk_confirm';
const confirmMsg = el('div');
confirmMsg.className = '__crk_confirm_msg';
const confirmActions = el('div');
confirmActions.className = '__crk_confirm_actions';
const bConfirmCancel = el('button', { textContent: 'Cancel' });
const bConfirmAction = el('button', { textContent: 'Confirm' });
bConfirmAction.className = 'danger';
confirmActions.appendChild(bConfirmCancel);
confirmActions.appendChild(bConfirmAction);
confirmPanel.appendChild(confirmMsg);
confirmPanel.appendChild(confirmActions);
box.appendChild(confirmPanel);
let pendingConfirmAction = null;
const closeConfirm = () => {
pendingConfirmAction = null;
confirmPanel.dataset.open = 'false';
confirmMsg.textContent = '';
bConfirmAction.disabled = false;
bConfirmAction.textContent = 'Confirm';
};
const openConfirm = (message, label, action) => {
pendingConfirmAction = action;
confirmMsg.textContent = message;
bConfirmAction.textContent = label;
confirmPanel.dataset.open = 'true';
bConfirmCancel.focus();
};
bConfirmCancel.onclick = closeConfirm;
bConfirmAction.onclick = async () => {
if (!pendingConfirmAction) return;
const action = pendingConfirmAction;
pendingConfirmAction = null;
bConfirmAction.disabled = true;
bConfirmAction.textContent = 'Working…';
try { await action(); } finally { closeConfirm(); }
};
bResetObs.onclick = async () => {
const stats = await Engine.call('stats').catch(() => null);
const n = stats && stats.repeat ? stats.repeat.distinct : 0;
openConfirm(
'Reset sighting counts for ' + n + ' observed words? Words stay indexed and keep their priority.',
'Reset observations',
async () => {
const after = await Engine.call('resetObs');
setStatus('Observation counts reset; ' + after.total.toLocaleString() + ' words retained');
await refreshStats();
},
);
};
bClear.onclick = () => {
openConfirm(
'Clear the complete local wordlist cache? cRaCked will reload and download/index the dictionary again.',
'Clear cache',
async () => {
const clientId = await idbGet(CLIENT_ID_STORE_KEY);
await idbClearAll();
if (CLIENT_ID_RE.test(String(clientId || ''))) {
await idbSet(CLIENT_ID_STORE_KEY, clientId);
}
location.reload();
},
);
};
box.appendChild(el('hr'));
const statusLine = el('div', { textContent: 'Dictionary: ' + lastStatus },
'font-size:12px;text-align:center;margin-bottom:6px;');
box.appendChild(statusLine);
statusSinks.add(statusLine);
const nextSync = el('div', { textContent: '…' }, 'font-size:12px;text-align:center;margin-bottom:6px;');
box.appendChild(nextSync);
const statsDiv = el('div', { textContent: 'Loading stats…' });
statsDiv.className = '__crk_mono';
statsDiv.style.cssText = 'margin:8px 0;';
box.appendChild(statsDiv);
box.appendChild(el('hr'));
// ---- display -------------------------------------------------------
const appearance = el('details');
appearance.className = '__crk_settings_group';
appearance.open = getBool(PREF.appearanceOpen, false);
const appearanceSummary = el('summary', { textContent: 'Appearance & suggestions' });
const appearanceBody = el('div');
appearanceBody.className = '__crk_settings_group_body';
appearance.appendChild(appearanceSummary);
appearance.appendChild(appearanceBody);
appearance.addEventListener('toggle', () => setBool(PREF.appearanceOpen, appearance.open));
box.appendChild(appearance);
const themeSel = el('select');
themeSel.innerHTML = '<option value="dark">Black (default)</option><option value="light">White</option>';
themeSel.value = themeName();
themeSel.onchange = () => {
const p = THEME_PRESETS[themeSel.value === 'light' ? 'light' : 'dark'];
setStr(PREF.theme, themeSel.value);
setStr(PREF.uiBg, p.uiBg); setStr(PREF.uiText, p.uiText); setStr(PREF.uiBorder, p.uiBorder);
setStr(PREF.sugBg, p.sugBg); setStr(PREF.sugText, p.sugText); setStr(PREF.boxBg, p.boxBg);
// No reload needed — CSS variables update live.
refreshStyleVars();
overlay.style.background = theme().overlayBg;
};
addRow(appearanceBody, 'Theme preset', themeSel);
const maxSugIn = el('input');
maxSugIn.type = 'number'; maxSugIn.min = '1'; maxSugIn.max = String(MAX_MAX_SUGGESTIONS);
maxSugIn.value = String(maxSugPref());
maxSugIn.oninput = () => {
const n = clampInt(maxSugIn.value, MIN_MAX_SUGGESTIONS, MAX_MAX_SUGGESTIONS, DEFAULT_MAX_SUGGESTIONS);
setInt(PREF.maxSug, n);
};
addRow(appearanceBody, 'Max suggestions', maxSugIn);
const fontIn = el('input');
fontIn.type = 'number'; fontIn.min = '5'; fontIn.max = '20';
fontIn.value = String(getInt(PREF.sugFont, 10));
fontIn.oninput = () => { setInt(PREF.sugFont, fontIn.value); refreshStyleVars(); };
addRow(appearanceBody, 'Suggestion font size (px)', fontIn);
const colorFields = [
['Suggestion text', PREF.sugText, 'sugText'],
['Suggestion background', PREF.sugBg, 'sugBg'],
['UI text', PREF.uiText, 'uiText'],
['UI background', PREF.uiBg, 'uiBg'],
['UI border', PREF.uiBorder, 'uiBorder'],
['Settings panel background', PREF.boxBg, 'boxBg'],
];
for (const [label, key, presetKey] of colorFields) {
const inp = el('input');
inp.type = 'text';
inp.value = getStr(key, preset()[presetKey]);
inp.oninput = () => { setStr(key, inp.value.trim()); refreshStyleVars(); };
addRow(appearanceBody, label, inp);
}
addCheck(appearanceBody, 'Show status badge', PREF.badge, true,
on => { ensureBadge().style.display = on ? 'block' : 'none'; });
box.appendChild(el('hr'));
// ---- data / privacy -------------------------------------------------
box.appendChild(el('div', {
textContent: 'Community pool',
}, 'font-size:13px;margin-bottom:4px;'));
const poolToggleRow = el('div');
poolToggleRow.className = '__crk_pool_toggle_row';
const poolToggle = el('input');
poolToggle.type = 'checkbox';
poolToggle.checked = getBool(PREF.upload, true);
poolToggle.setAttribute('aria-label', 'Share cracked passwords with the community pool');
const poolSwitch = el('label');
poolSwitch.className = '__crk_switch';
poolSwitch.appendChild(poolToggle);
poolSwitch.appendChild(el('span', { className: '__crk_switch_slider' }));
poolToggleRow.appendChild(el('span', { textContent: 'Off' }));
poolToggleRow.appendChild(poolSwitch);
poolToggleRow.appendChild(el('span', { textContent: 'On' }));
box.appendChild(poolToggleRow);
poolToggle.onchange = () => {
setBool(PREF.upload, poolToggle.checked);
setTransientStatus('Community sharing ' + (poolToggle.checked ? 'enabled' : 'disabled'));
};
box.appendChild(el('div', {
textContent: 'The switch controls sharing: when On, passwords you crack are uploaded to the shared pool at '
+ (CF_ORIGIN || 'the configured endpoint') + ' with the Torn target/type, service, '
+ 'and crack level so matching jobs can rank them more accurately. '
+ 'Off keeps your cracks local; you still receive the pool on sync.',
}, 'font-size:11px;opacity:.8;margin-bottom:6px;'));
box.appendChild(el('hr'));
// ---- model ----------------------------------------------------------
box.appendChild(el('div', { textContent: 'Model' }, 'font-size:13px;margin-bottom:4px;'));
addCheck(box, 'Read guess verdicts from network responses (experimental)', PREF.netHook, false,
() => setStatus('Reload the page to apply the network hook'));
const paramsTa = el('textarea');
paramsTa.rows = 6;
paramsTa.className = '__crk_mono';
paramsTa.style.cssText = 'width:100%;box-sizing:border-box;';
paramsTa.placeholder = '{ "OBS_CTX_W": 3.0, "COVERAGE": { "10": 0.3 } }';
paramsTa.value = getStr(PREF.params, '');
const paramsMsg = el('div', {}, 'font-size:11px;opacity:.8;margin-top:2px;');
box.appendChild(el('div', {
textContent: 'Parameter overrides (JSON). Sweep these with the offline replay harness rather than by feel.',
}, 'font-size:11px;opacity:.8;margin:6px 0 2px;'));
box.appendChild(paramsTa);
box.appendChild(paramsMsg);
paramsTa.onchange = async () => {
const raw = paramsTa.value.trim();
if (raw) {
try { JSON.parse(raw); } catch (e) { paramsMsg.textContent = 'Invalid JSON: ' + e.message; return; }
}
setStr(PREF.params, raw);
await applyStoredParams();
paramsMsg.textContent = 'Applied.';
scanAll('params');
};
box.appendChild(el('hr'));
// ---- buttons --------------------------------------------------------
const btns = el('div', {}, 'display:flex;gap:8px;justify-content:center;flex-wrap:wrap;');
box.appendChild(btns);
const bExport = el('button', { textContent: 'Export telemetry (JSON)' });
bExport.onclick = async () => {
await loadTelemetry();
let dump = {};
try { dump = await Engine.call('dumpObs'); } catch (e) { /* ignore */ }
const payload = {
exportedAt: new Date().toISOString(),
version: '2.2.8',
params: getStr(PREF.params, ''),
cracks: telemetry.cracks,
guesses: telemetry.guesses,
observations: dump.obs || {},
contextObservations: dump.ctxObs || {},
communityContextObservations: dump.poolCtxObs || {},
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const a = el('a');
a.href = URL.createObjectURL(blob);
a.download = 'cracked-telemetry-' + Date.now() + '.json';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
};
btns.appendChild(bExport);
const bClose = el('button', { textContent: 'Close' });
btns.appendChild(bClose);
box.appendChild(el('hr'));
box.appendChild(el('div', {
textContent: COMMUNITY_SYNC_ENABLED
? 'Packed dictionary + calibrated probability model — SirAua [3785905], MoDuL [4022159], TheDno [4443016]'
: 'Local mode — set COMMUNITY_POOL_ORIGIN to enable the shared pool. SirAua [3785905], MoDuL [4022159], TheDno [4443016]',
}, 'font-size:11px;text-align:center;opacity:.9;'));
document.body.appendChild(overlay);
/* stats */
async function refreshStats() {
try {
const s = await Engine.call('stats');
await loadTelemetry();
const rep = s.repeat || { distinct: 0, total: 0, seenMoreThanOnce: 0, avgPerWord: 0 };
const remote = await idbGet('cf_remote_count');
const remoteContexts = Number(await idbGet('cf_context_count')) || 0;
// These are browser-local measurements. The remote pool has one row
// per word, so another device can add a sighting but cannot duplicate
// a word in the downloaded dictionary.
const cracks = telemetry.cracks;
const n = cracks.length;
const inBase = cracks.filter(c => c.foundSrc === 0).length;
const repeat = cracks.filter(c => c.foundSrc === 1 || c.foundSrc === 2).length;
const wrong = cracks.reduce((a, c) => a + (c.wrong || 0), 0);
const covered = inBase + repeat;
const coverageRate = n ? covered / n : 0;
const percentage = value => n ? (100 * value / n).toFixed(1) + '%' : '—';
const addLine = (label, value, options = {}) => {
const line = el('div');
line.className = '__crk_stat_line';
if (options.sub) line.dataset.sub = 'true';
const name = el('span', { textContent: label });
const result = value && value.nodeType ? value : el('span', { textContent: String(value) });
if (options.title) {
name.title = options.title;
result.title = options.title;
}
line.appendChild(name);
line.appendChild(result);
statsDiv.appendChild(line);
};
const addGap = () => statsDiv.appendChild(el('div', { className: '__crk_stat_gap' }));
const addNote = text => statsDiv.appendChild(el('div', {
className: '__crk_stat_note', textContent: text,
}));
statsDiv.replaceChildren();
statsDiv.className = '__crk_mono __crk_stats';
const perLength = el('span');
perLength.className = '__crk_stat_per_length';
for (const length of Object.keys(s.perLength).sort((a, b) => Number(a) - Number(b))) {
const absolute = Math.max(0, Number(s.perLength[length]) || 0);
const item = el('span');
item.appendChild(document.createTextNode(length + ': '));
item.appendChild(el('span', {
className: '__crk_stat_compact',
textContent: formatCompactNumber(absolute),
title: absolute.toLocaleString() + ' indexed words',
}));
perLength.appendChild(item);
}
addLine('Stored per length', perLength,
{ title: 'Hover each compact count for its absolute value.' });
addLine('Total indexed', Number(s.total || 0).toLocaleString()
+ (remote ? ' remote pool: ' + Number(remote).toLocaleString() : ''));
if (remoteContexts) addLine('Shared type links', remoteContexts.toLocaleString());
addGap();
addLine('Observed on device', rep.distinct.toLocaleString() + ' distinct / '
+ rep.total.toLocaleString() + ' sightings'
+ (rep.distinct ? ' (avg ' + rep.avgPerWord.toFixed(2) + ')' : ''), {
title: 'Distinct indexed passwords seen by this browser and their total sightings.',
});
addLine('Seen more than 1x', rep.seenMoreThanOnce.toLocaleString()
+ (rep.distinct ? ' (' + (100 * rep.seenMoreThanOnce / rep.distinct).toFixed(1)
+ '% of distinct)' : ''));
addGap();
addLine('Passwords cracked', n.toLocaleString(), {
title: 'Completed passwords recorded in crack telemetry in this browser.',
});
addLine('Known before cracking', n ? covered.toLocaleString() + ' (' + percentage(covered) + ')' : '—', {
title: 'Subtotal: passwords already present before their crack completed. The two indented rows are its breakdown.',
});
addLine('Base dictionary', n ? inBase.toLocaleString() + ' (' + percentage(inBase) + ')' : '—', {
sub: true,
title: 'Known beforehand from the bundled base dictionary.',
});
addLine('Shared / learned', n ? repeat.toLocaleString() + ' (' + percentage(repeat) + ')' : '—', {
sub: true,
title: 'Known beforehand from the community pool or from an earlier crack learned on this device.',
});
if (n) addLine('Mean wrong guesses', (wrong / n).toFixed(2) + ' per password');
const assessment = n
? 'Before solving, cRaCked already knew ' + (100 * coverageRate).toFixed(1)
+ '% of these passwords. This is prior coverage, not current storage: a new password '
+ 'counts as unknown for that crack and is learned only after completion, so this value is not expected to be 100%.'
: 'No completed passwords have been recorded in this browser yet.';
addNote(assessment + (n > 0 && n < 30
? ' The sample is still small; about 30 cracks will make the percentage more useful.' : ''));
addNote('“Known before cracking” is the subtotal; “Base dictionary” and “Shared / learned” are its breakdown.');
addNote('Crack metrics are local to this browser; shared words and type links are de-duplicated remotely.');
} catch (e) {
statsDiv.textContent = 'Stats unavailable: ' + (e.message || e);
}
}
async function refreshNextSync() {
if (!COMMUNITY_SYNC_ENABLED) { nextSync.textContent = 'Community sync: disabled'; return; }
const lastCheck = Number(await idbGet('cf_last_meta_check_ts')) || 0;
const lastDownload = Number(await idbGet('cf_last_download_ts')) || 0;
const remain = lastCheck + SYNC_CHECK_INTERVAL_MS - Date.now();
const pend = Number(await idbGet('cf_pending_delta')) || 0;
const forcedIn = lastDownload ? lastDownload + SYNC_MAX_WAIT_MS - Date.now() : 0;
nextSync.textContent = (remain <= 0 ? 'Next pool check: now' : 'Next pool check in ' + formatDuration(remain))
+ (pend ? ' (+' + pend + ' pending; update by '
+ (forcedIn <= 0 ? 'next check' : formatDuration(forcedIn)) + ')' : '');
}
await refreshStats();
await refreshNextSync();
const t1 = setInterval(refreshNextSync, 1000);
const t2 = setInterval(refreshStats, 15000);
bClose.onclick = () => {
statusSinks.delete(statusLine);
clearInterval(t1); clearInterval(t2);
overlay.remove();
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) bClose.onclick(); });
}
function injectMenuButton() {
if (!runtimeStarted || !isCrackingPage()) return;
if (document.getElementById('__crack_menu_btn')) return;
const header = document.querySelector(SEL.appHeader);
if (!header) return;
const btn = el('button', {
id: '__crack_menu_btn',
textContent: 'cRaCked 2.2.8 — tap-to-slot suggestions (click for settings)',
});
btn.onclick = showSettings;
header.appendChild(btn);
ensureBadge();
}
/* ==================================================================== */
/* Runtime */
/* ==================================================================== */
let runtimeStarted = false;
function cleanupView() {
closeSuggestionModal(false);
const btn = document.getElementById('__crack_menu_btn');
if (btn) btn.remove();
document.querySelectorAll('.__crk_panel').forEach(p => p.remove());
if (listObserver) { listObserver.disconnect(); listObserver = null; }
if (statusEl) { statusEl.remove(); statusEl = null; }
}
function startRuntime() {
if (runtimeStarted) return;
runtimeStarted = true;
// 1.3.1 stored rig-planner preferences that no longer exist.
['crack_rig_skill_level', 'crack_rig_inv_HPCPU', 'crack_rig_inv_ECPU', 'crack_rig_inv_CPU',
'crack_rig_inv_FAN', 'crack_rig_inv_WB', 'crack_rig_inv_HS', 'crack_rig_inv_PSU']
.forEach(k => localStorage.removeItem(k));
injectStyle();
ensureBadge();
setStatus('Initializing…');
installNetHook();
bootstrapDictionary().catch(e => { log('bootstrap failed', e); setStatus('Init failed'); });
startObservers();
scanAll('start');
injectMenuButton();
}
function stopRuntime() {
runtimeStarted = false;
if (safetyTimer) { clearInterval(safetyTimer); safetyTimer = null; }
cleanupView();
}
function onRouteChange() {
if (isCrackingPage()) startRuntime();
else stopRuntime();
}
startRigStrengthObserver();
onRouteChange();
window.addEventListener('hashchange', onRouteChange);
window.addEventListener('popstate', onRouteChange);
const pushState = history.pushState;
const replaceState = history.replaceState;
history.pushState = function () {
const r = pushState.apply(this, arguments);
setTimeout(onRouteChange, 0);
return r;
};
history.replaceState = function () {
const r = replaceState.apply(this, arguments);
setTimeout(onRouteChange, 0);
return r;
};
})();