speedhack.js
| 1 | // ==UserScript== |
| 2 | // @name Speedhack Panel |
| 3 | // @version 1.1.0 |
| 4 | // @description Floating, movable, resizable, minimizable time-scaling panel with an autoclicker and a Cheat-Engine-style memory scanner. Scales the page's JS timing functions by a chosen factor. Runs inside iframes. By default only the TOP frame shows a panel and broadcasts settings to child frames; any frame can be detached for its own panel. The autoclicker clicks at the cursor and auto-targets whichever (i)frame the cursor is in. The Scan tab finds/edits values in a game's WebAssembly heap or JS object graph, and can target any (i)frame from the top panel. Only the per-URL "closed" state and saved scans are remembered. |
| 5 | // @match *://*/* |
| 6 | // @run-at document-start |
| 7 | // @grant GM_getValue |
| 8 | // @grant GM_setValue |
| 9 | // @grant GM_deleteValue |
| 10 | // @grant unsafeWindow |
| 11 | // ==/UserScript== |
| 12 | |
| 13 | /* eslint-disable no-empty */ |
| 14 | /* eslint-disable no-unused-vars */ |
| 15 | |
| 16 | (() => { |
| 17 | 'use strict'; |
| 18 | |
| 19 | if (window.__SPEEDHACK_PANEL__) return; // guard against double-injection in the same frame |
| 20 | window.__SPEEDHACK_PANEL__ = true; |
| 21 | |
| 22 | /* ------------------------------------------------------------------ * |
| 23 | * The real page window. With @grant set we are sandboxed, so window |
| 24 | * !== unsafeWindow. Patch unsafeWindow so the game actually sees it. |
| 25 | * ------------------------------------------------------------------ */ |
| 26 | const hasUnsafe = (typeof unsafeWindow !== 'undefined') && unsafeWindow && unsafeWindow !== window; |
| 27 | const pageWin = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; |
| 28 | |
| 29 | /* ------------------------------------------------------------------ * |
| 30 | * Capture originals from the PAGE window, before patching. |
| 31 | * ------------------------------------------------------------------ */ |
| 32 | const RealDate = pageWin.Date; |
| 33 | const origDateNow = RealDate.now.bind(RealDate); |
| 34 | const origPerfNow = pageWin.performance?.now |
| 35 | ? pageWin.performance.now.bind(pageWin.performance) |
| 36 | : origDateNow; |
| 37 | const origSetTimeout = pageWin.setTimeout.bind(pageWin); |
| 38 | const origSetInterval = pageWin.setInterval.bind(pageWin); |
| 39 | const origClearTimeout = pageWin.clearTimeout.bind(pageWin); |
| 40 | const origClearInterval= pageWin.clearInterval.bind(pageWin); |
| 41 | const origRAF = (pageWin.requestAnimationFrame || |
| 42 | (cb => origSetTimeout(() => cb(origPerfNow()), 16)) |
| 43 | ).bind(pageWin); |
| 44 | |
| 45 | /* ------------------------------------------------------------------ * |
| 46 | * WebAssembly memory capture (for the Scan tab). |
| 47 | * Browser JS has no raw process memory, but WASM games (Unity, Emscripten, |
| 48 | * Godot, C/C++) keep their state in a WebAssembly.Memory linear buffer. |
| 49 | * We patch the page's WebAssembly constructors HERE — at document-start, |
| 50 | * before the game instantiates — so we capture every Memory it creates and |
| 51 | * can scan it like Cheat Engine. Each entry is a LIVE handle: memory.grow() |
| 52 | * detaches the old ArrayBuffer, so readers must always re-read memory.buffer. |
| 53 | * ------------------------------------------------------------------ */ |
| 54 | const wasmMemories = []; // [{ memory, label }] — deduped, in creation order |
| 55 | (function hookWasm() { |
| 56 | const W = pageWin.WebAssembly; |
| 57 | if (!W) return; |
| 58 | const record = (m, label) => { |
| 59 | try { |
| 60 | if (!(m instanceof W.Memory)) return; |
| 61 | if (wasmMemories.some(e => e.memory === m)) return; |
| 62 | wasmMemories.push({ memory: m, label: label || ('mem#' + wasmMemories.length) }); |
| 63 | } catch (e) {} |
| 64 | }; |
| 65 | const scanExports = (res) => { |
| 66 | // res is an instantiate result ({ module, instance }) or a bare Instance. |
| 67 | try { |
| 68 | const ex = (res?.instance ?? res)?.exports; |
| 69 | if (ex) for (const k in ex) { try { if (ex[k] instanceof W.Memory) record(ex[k], k); } catch (e) {} } |
| 70 | } catch (e) {} |
| 71 | return res; |
| 72 | }; |
| 73 | const wrapInstantiate = (orig) => function () { |
| 74 | const p = orig.apply(this, arguments); |
| 75 | return (typeof p?.then === 'function') ? p.then(scanExports) : p; |
| 76 | }; |
| 77 | if (typeof W.instantiate === 'function') W.instantiate = wrapInstantiate(W.instantiate); |
| 78 | if (typeof W.instantiateStreaming === 'function') W.instantiateStreaming = wrapInstantiate(W.instantiateStreaming); |
| 79 | const patchCtor = (Orig, onNew) => { |
| 80 | function Patched(...args) { |
| 81 | const obj = Reflect.construct(Orig, args, new.target || Patched); |
| 82 | onNew(obj); |
| 83 | return obj; |
| 84 | } |
| 85 | Patched.prototype = Orig.prototype; |
| 86 | return Patched; |
| 87 | }; |
| 88 | if (typeof W.Instance === 'function') { try { W.Instance = patchCtor(W.Instance, scanExports); } catch (e) {} } |
| 89 | if (typeof W.Memory === 'function') { try { W.Memory = patchCtor(W.Memory, m => record(m, 'Memory()')); } catch (e) {} } |
| 90 | })(); |
| 91 | |
| 92 | /* ------------------------------------------------------------------ * |
| 93 | * Input isolation. So clicking/typing in the panel doesn't also drive the |
| 94 | * game, we wrap the page's input listeners on window/document/<html>/<body> |
| 95 | * (the only ancestors of the panel host) so an event whose composedPath |
| 96 | * includes the panel host is NOT delivered to the page's own handlers. This |
| 97 | * beats capture-phase listeners (which a per-element shield can't), while the |
| 98 | * panel's own handlers — attached to its shadow nodes, not these targets — |
| 99 | * are untouched. Installed at document-start so we wrap before the game does. |
| 100 | * `panelHost` is read at event time (set once the panel is built). |
| 101 | * ------------------------------------------------------------------ */ |
| 102 | const SHIELD_TYPES = new Set(['keydown', 'keyup', 'keypress', 'pointerdown', 'pointerup', |
| 103 | 'mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu', |
| 104 | 'wheel', 'touchstart', 'touchend']); |
| 105 | const shieldedTargets = new WeakSet(); |
| 106 | function eventInPanel(ev) { |
| 107 | try { return !!(panelHost && ev?.composedPath && ev.composedPath().includes(panelHost)); } catch (e) { return false; } |
| 108 | } |
| 109 | function shieldInputTarget(target) { |
| 110 | if (!target || typeof target.addEventListener !== 'function' || shieldedTargets.has(target)) return; |
| 111 | shieldedTargets.add(target); |
| 112 | const origAdd = target.addEventListener, origRemove = target.removeEventListener; |
| 113 | const wrappers = new WeakMap(); // handler -> { typeKey -> wrapper } |
| 114 | const captureOf = (opts) => (typeof opts === 'object' && opts) ? !!opts.capture : !!opts; |
| 115 | try { |
| 116 | target.addEventListener = function (type, handler, opts) { |
| 117 | const usable = handler && (typeof handler === 'function' || typeof handler.handleEvent === 'function'); |
| 118 | if (!usable || !SHIELD_TYPES.has(type) || handler.__shxNoShield) return origAdd.call(this, type, handler, opts); |
| 119 | const key = type + '/' + (captureOf(opts) ? 1 : 0); |
| 120 | let per = wrappers.get(handler); if (!per) { per = Object.create(null); wrappers.set(handler, per); } |
| 121 | let wrapper = per[key]; |
| 122 | if (!wrapper) { |
| 123 | wrapper = function (ev) { if (eventInPanel(ev)) return; return (typeof handler === 'function') ? handler.call(this, ev) : handler.handleEvent(ev); }; |
| 124 | per[key] = wrapper; |
| 125 | } |
| 126 | return origAdd.call(this, type, wrapper, opts); |
| 127 | }; |
| 128 | target.removeEventListener = function (type, handler, opts) { |
| 129 | if (!handler || !SHIELD_TYPES.has(type)) return origRemove.call(this, type, handler, opts); |
| 130 | const wrapper = wrappers.get(handler)?.[type + '/' + (captureOf(opts) ? 1 : 0)]; |
| 131 | return origRemove.call(this, type, wrapper || handler, opts); |
| 132 | }; |
| 133 | } catch (e) {} |
| 134 | } |
| 135 | try { shieldInputTarget(pageWin); } catch (e) {} |
| 136 | try { shieldInputTarget(pageWin.document); } catch (e) {} |
| 137 | try { shieldInputTarget(pageWin.document?.documentElement); } catch (e) {} |
| 138 | // <body> may not exist yet at document-start; it's shielded when the panel is built. |
| 139 | |
| 140 | /* ------------------------------------------------------------------ * |
| 141 | * Persistence — the "closed for this exact URL" flag and saved scans. |
| 142 | * Still per-frame: a child frame whose URL was remembered-closed stays |
| 143 | * out entirely (no hooks, no messaging), exactly as before. |
| 144 | * ------------------------------------------------------------------ */ |
| 145 | const PAGE = location.href.split('#')[0]; // "exact" page URL, ignoring #hash |
| 146 | const store = { |
| 147 | get(k, d) { |
| 148 | try { if (typeof GM_getValue === 'function') return GM_getValue(k, d); } catch (e) {} |
| 149 | try { const v = localStorage.getItem('shx_' + k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } |
| 150 | }, |
| 151 | set(k, v) { |
| 152 | try { if (typeof GM_setValue === 'function') { GM_setValue(k, v); return; } } catch (e) {} |
| 153 | try { localStorage.setItem('shx_' + k, JSON.stringify(v)); } catch (e) {} |
| 154 | } |
| 155 | }; |
| 156 | const CLOSED_KEY = 'closed:' + PAGE; |
| 157 | if (store.get(CLOSED_KEY, false) === true) return; // remembered closed for this exact URL → do nothing |
| 158 | |
| 159 | /* ------------------------------------------------------------------ * |
| 160 | * Scaled clocks. fake = anchorFake + (real - anchorReal) * scale |
| 161 | * ------------------------------------------------------------------ */ |
| 162 | let scale = 1; |
| 163 | let turbo = false; // experimental multi-step rAF |
| 164 | let turboNow = null; // forced timestamp during turbo sub-steps |
| 165 | let turboT = null; // monotonic accumulator for turbo frames; reseeded when null |
| 166 | |
| 167 | function makeClock(realFn) { |
| 168 | let aReal = realFn(), aFake = aReal, s = 1; |
| 169 | return { |
| 170 | now: () => aFake + (realFn() - aReal) * s, |
| 171 | setScale: (ns) => { const r = realFn(); aFake = aFake + (r - aReal) * s; aReal = r; s = ns; }, |
| 172 | reanchor: () => { const r = realFn(); aReal = r; aFake = r; }, |
| 173 | setTo: (v) => { aReal = realFn(); aFake = v; } // jump the fake timeline to absolute v |
| 174 | }; |
| 175 | } |
| 176 | const dateClock = makeClock(origDateNow); |
| 177 | const perfClock = makeClock(origPerfNow); |
| 178 | |
| 179 | function applyScale(ns) { |
| 180 | ns = Number(ns); |
| 181 | if (!isFinite(ns) || ns <= 0) return; |
| 182 | paused = false; |
| 183 | scale = ns; |
| 184 | dateClock.setScale(ns); |
| 185 | perfClock.setScale(ns); |
| 186 | } |
| 187 | const perfRead = () => (turboNow !== null) ? turboNow : perfClock.now(); |
| 188 | |
| 189 | /* ------------------------------------------------------------------ * |
| 190 | * Pause (for the Scan tab). scale=0 is normally rejected by applyScale, |
| 191 | * so we freeze the clocks directly: patched setTimeout/setInterval delays |
| 192 | * go to Infinity and the Date/performance clocks stop advancing, halting |
| 193 | * time-driven game logic. (A game that advances purely by rAF frame-count |
| 194 | * rather than elapsed time won't fully stop — noted in the UI.) Pause is |
| 195 | * local to this frame and is NOT broadcast. |
| 196 | * ------------------------------------------------------------------ */ |
| 197 | let paused = false; |
| 198 | let prevScale = 1; |
| 199 | function setPaused(on) { |
| 200 | on = !!on; |
| 201 | if (on === paused) return; |
| 202 | if (on) { |
| 203 | prevScale = scale || 1; |
| 204 | paused = true; |
| 205 | scale = 0; |
| 206 | dateClock.setScale(0); |
| 207 | perfClock.setScale(0); |
| 208 | } else { |
| 209 | applyScale(prevScale); // clears `paused`, restores clocks |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | /* ------------------------------------------------------------------ * |
| 214 | * Fake Date |
| 215 | * ------------------------------------------------------------------ */ |
| 216 | function FakeDate(...args) { |
| 217 | if (new.target === undefined) return new RealDate(dateClock.now()).toString(); |
| 218 | if (args.length === 0) return new RealDate(dateClock.now()); |
| 219 | return new RealDate(...args); |
| 220 | } |
| 221 | FakeDate.prototype = RealDate.prototype; |
| 222 | FakeDate.now = () => Math.floor(dateClock.now()); |
| 223 | FakeDate.parse = RealDate.parse.bind(RealDate); |
| 224 | FakeDate.UTC = RealDate.UTC.bind(RealDate); |
| 225 | try { Object.setPrototypeOf(FakeDate, RealDate); } catch (e) {} |
| 226 | |
| 227 | /* ------------------------------------------------------------------ * |
| 228 | * setInterval re-arming. We drive intervals through a self-rescheduling |
| 229 | * setTimeout chain that re-reads `scale` every tick, so moving the slider |
| 230 | * rescales already-running intervals. clearInterval/clearTimeout are |
| 231 | * patched to recognise our handles (and pass native ids straight through). |
| 232 | * ------------------------------------------------------------------ */ |
| 233 | let intervalSeq = 1; |
| 234 | const fakeIntervals = new Map(); // id -> { timer, cancelled } |
| 235 | function clearFake(id) { |
| 236 | const rec = fakeIntervals.get(id); |
| 237 | if (!rec) return false; |
| 238 | rec.cancelled = true; |
| 239 | origClearTimeout(rec.timer); |
| 240 | fakeIntervals.delete(id); |
| 241 | return true; |
| 242 | } |
| 243 | let clearHooksInstalled = false; |
| 244 | function installClearHooks() { |
| 245 | if (clearHooksInstalled) return; |
| 246 | clearHooksInstalled = true; |
| 247 | // Transparent: only our string handles are intercepted; native numeric ids fall through. |
| 248 | pageWin.clearInterval = (id) => { if (clearFake(id)) return; return origClearInterval(id); }; |
| 249 | pageWin.clearTimeout = (id) => { if (clearFake(id)) return; return origClearTimeout(id); }; |
| 250 | } |
| 251 | |
| 252 | /* ------------------------------------------------------------------ * |
| 253 | * Hooks — installed onto the PAGE window |
| 254 | * ------------------------------------------------------------------ */ |
| 255 | const hooks = { |
| 256 | date: { |
| 257 | label: 'Date (Date.now / new Date)', |
| 258 | install: () => { dateClock.reanchor(); pageWin.Date = FakeDate; }, |
| 259 | uninstall: () => { pageWin.Date = RealDate; } |
| 260 | }, |
| 261 | performance: { |
| 262 | label: 'performance.now', |
| 263 | install: () => { perfClock.reanchor(); if (pageWin.performance) pageWin.performance.now = () => perfRead(); }, |
| 264 | uninstall: () => { if (pageWin.performance) pageWin.performance.now = origPerfNow; } |
| 265 | }, |
| 266 | setTimeout: { |
| 267 | label: 'setTimeout', |
| 268 | install: () => { |
| 269 | pageWin.setTimeout = (fn, delay, ...rest) => { |
| 270 | if (typeof delay === 'number' && isFinite(delay)) delay = delay / scale; |
| 271 | return origSetTimeout(fn, delay, ...rest); |
| 272 | }; |
| 273 | }, |
| 274 | uninstall: () => { pageWin.setTimeout = origSetTimeout; } |
| 275 | }, |
| 276 | setInterval: { |
| 277 | label: 'setInterval', |
| 278 | install: () => { |
| 279 | installClearHooks(); |
| 280 | pageWin.setInterval = (fn, delay, ...rest) => { |
| 281 | // Non-function callback or non-finite delay → defer to native semantics. |
| 282 | if (typeof fn !== 'function' || typeof delay !== 'number' || !isFinite(delay)) { |
| 283 | return origSetInterval(fn, delay, ...rest); |
| 284 | } |
| 285 | const id = 'shx_int_' + (intervalSeq++); |
| 286 | const rec = { timer: 0, cancelled: false }; |
| 287 | fakeIntervals.set(id, rec); |
| 288 | const tick = () => { |
| 289 | if (rec.cancelled) return; |
| 290 | // Re-read scale every tick so slider changes take effect on a live interval. |
| 291 | // When the hook is toggled OFF, eff=1 → the interval keeps running at native |
| 292 | // cadence instead of freezing the page's loop. |
| 293 | const eff = state.setInterval ? scale : 1; |
| 294 | rec.timer = origSetTimeout(tick, delay / eff); // schedule next BEFORE the call, |
| 295 | try { fn.apply(pageWin, rest); } catch (e) {} // so a clear() inside fn cancels it |
| 296 | }; |
| 297 | const eff0 = state.setInterval ? scale : 1; |
| 298 | rec.timer = origSetTimeout(tick, delay / eff0); |
| 299 | return id; |
| 300 | }; |
| 301 | }, |
| 302 | // Running fake intervals keep ticking after uninstall, but at scale 1 (see `eff`), |
| 303 | // so toggling the hook off unscales them rather than freezing the page. |
| 304 | uninstall: () => { pageWin.setInterval = origSetInterval; } |
| 305 | }, |
| 306 | raf: { |
| 307 | label: 'requestAnimationFrame', |
| 308 | install: () => { |
| 309 | pageWin.requestAnimationFrame = (cb) => origRAF(() => { |
| 310 | if (!turbo) { |
| 311 | // Feed one scaled timestamp. If the callback throws, swallow it — do NOT |
| 312 | // re-invoke with a different time, which would double-run frame side effects. |
| 313 | try { cb(perfRead()); } catch (e) {} |
| 314 | return; |
| 315 | } |
| 316 | // turbo: run the frame callback k times per real frame on ONE monotonic |
| 317 | // timeline, ~1 normal frame apart, so engines that CLAMP delta-time still |
| 318 | // advance k frames. Fake time advances by exactly k*step here — it does not |
| 319 | // also track real elapsed time, which is what used to cause discontinuities. |
| 320 | const k = Math.max(1, Math.min(20, Math.round(scale))); // capped so the tab can't lock up |
| 321 | const step = 1000 / 60; |
| 322 | if (turboT === null) turboT = perfClock.now(); // seed once from current fake time |
| 323 | let currentCbs = [cb]; |
| 324 | for (let i = 0; i < k && currentCbs.length; i++) { |
| 325 | turboT += step; // advance the single timeline |
| 326 | turboNow = turboT; |
| 327 | const nextCbs = []; // collect EVERY rAF re-registered this sub-step, |
| 328 | const prev = pageWin.requestAnimationFrame; // not just the last (a callback may schedule several) |
| 329 | pageWin.requestAnimationFrame = (next) => { if (typeof next === 'function') nextCbs.push(next); return 0; }; |
| 330 | for (const c of currentCbs) { try { c(turboT); } catch (e) {} } |
| 331 | pageWin.requestAnimationFrame = prev; // restore our wrapper |
| 332 | currentCbs = nextCbs; // chain all re-registered callbacks |
| 333 | } |
| 334 | turboNow = null; |
| 335 | perfClock.setTo(turboT); // keep performance.now() continuous after the burst |
| 336 | for (const c of currentCbs) pageWin.requestAnimationFrame(c); // next REAL frame |
| 337 | }); |
| 338 | }, |
| 339 | uninstall: () => { pageWin.requestAnimationFrame = origRAF; } |
| 340 | } |
| 341 | }; |
| 342 | |
| 343 | // Defaults: every hook ON, scale 1 (== no effect), turbo OFF. None of this is persisted. |
| 344 | const state = { date: true, performance: true, setTimeout: true, setInterval: true, raf: true }; |
| 345 | function setHook(name, on) { |
| 346 | state[name] = on; |
| 347 | try { on ? hooks[name].install() : hooks[name].uninstall(); } catch (e) {} |
| 348 | } |
| 349 | for (const n of Object.keys(hooks)) if (state[n]) { try { hooks[n].install(); } catch (e) {} } |
| 350 | |
| 351 | /* ------------------------------------------------------------------ * |
| 352 | * Multi-frame coordination. |
| 353 | * Default: only the TOP frame shows a panel; it broadcasts settings to |
| 354 | * child frames over postMessage and they apply them locally. A frame can |
| 355 | * be "detached" to run its own independent panel. A frame that never hears |
| 356 | * from a host within 5s promotes itself (covers a top frame the manager |
| 357 | * didn't inject into). Hooks are installed in every frame regardless. |
| 358 | * ------------------------------------------------------------------ */ |
| 359 | const isTop = (() => { try { return window.top === window.self; } catch (e) { return true; } })(); |
| 360 | let isHost = isTop; // top frame hosts by default; a stranded child may promote |
| 361 | let attached = !isTop; // children follow the host until detached |
| 362 | let hostWin = null; // a child's link back to its host window |
| 363 | let gotHost = false; // did we ever hear from a host? |
| 364 | let hostClosed = false; // this host's panel was closed → don't adopt frames anymore |
| 365 | const frames = new Map(); // host only: childWindow -> { url, attached } |
| 366 | // A stable, per-frame random id. Scan commands are addressed by this id and |
| 367 | // delivered by broadcasting across the live frame tree (see broadcastScanMsg), |
| 368 | // so targeting never depends on a stored cross-world `e.source` reference — |
| 369 | // which can be null/unpostable for cross-origin iframes in an isolated world. |
| 370 | const SELF_ID = 'shx-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); |
| 371 | |
| 372 | const settingsMsg = (type) => ({ type, scale, turbo, hooks: { ...state } }); |
| 373 | function applySettings(s) { |
| 374 | if (s.hooks) for (const n of Object.keys(s.hooks)) { |
| 375 | if (hooks[n] && state[n] !== s.hooks[n]) setHook(n, s.hooks[n]); |
| 376 | } |
| 377 | if (typeof s.turbo === 'boolean' && s.turbo !== turbo) { turbo = s.turbo; turboT = null; } |
| 378 | if (typeof s.scale === 'number') applyScale(s.scale); |
| 379 | panelCtl?.sync(); |
| 380 | } |
| 381 | function postTo(win, msg) { try { msg.__shx = 1; win.postMessage(msg, '*'); } catch (e) {} } |
| 382 | |
| 383 | // The one cross-origin-safe recursive walk over the live frame tree (root = |
| 384 | // window.top by default, i.e. every frame on the page except the root itself). |
| 385 | const topWin = () => { try { return window.top; } catch (e) { return window; } }; |
| 386 | function forEachFrame(cb, root = topWin()) { |
| 387 | let list; try { list = root.frames; } catch (e) { return; } |
| 388 | for (let i = 0; i < list.length; i++) { |
| 389 | try { cb(list[i]); } catch (e) {} |
| 390 | forEachFrame(cb, list[i]); |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | // Deliver a scan message to EVERY frame in the tree (root + all descendants), |
| 395 | // the same live-frame walk rollcall uses — which is proven to reach cross-origin |
| 396 | // children. Only the frame whose SELF_ID matches `targetFrame` acts on it; the |
| 397 | // rest ignore it. This sidesteps stored-`e.source` references entirely. |
| 398 | function broadcastScanMsg(msg) { |
| 399 | postTo(topWin(), msg); |
| 400 | forEachFrame(w => postTo(w, msg)); |
| 401 | } |
| 402 | |
| 403 | function pruneFrames() { |
| 404 | // Registry keys are child window objects that are never otherwise cleaned up. Drop |
| 405 | // entries whose iframe has been removed from the tree, else they accumulate (phantom |
| 406 | // frame-list rows + dead postMessage targets) on long-lived / SPA pages. |
| 407 | const live = new Set(); |
| 408 | forEachFrame(w => live.add(w)); |
| 409 | let changed = false; |
| 410 | frames.forEach((f, src) => { if (!live.has(src)) { frames.delete(src); changed = true; } }); |
| 411 | return changed; |
| 412 | } |
| 413 | function broadcastSettings() { |
| 414 | if (!isHost) return; // only a host pushes settings out |
| 415 | pruneFrames(); |
| 416 | frames.forEach((f, src) => { if (f.attached) postTo(src, settingsMsg('settings')); }); |
| 417 | } |
| 418 | function rollcall() { |
| 419 | // Ask every descendant frame to (re-)announce itself — covers a host that |
| 420 | // booted after its children. Reaching cross-origin frames via postMessage is fine. |
| 421 | forEachFrame(w => postTo(w, { type: 'rollcall' })); |
| 422 | } |
| 423 | function promoteToHost() { |
| 424 | if (isHost) return; |
| 425 | isHost = true; attached = false; hostWin = null; |
| 426 | rollcall(); |
| 427 | ensurePanel('host'); |
| 428 | } |
| 429 | function detachFrame(src) { |
| 430 | const f = frames.get(src); if (!f) return; |
| 431 | f.attached = false; postTo(src, { type: 'detach' }); |
| 432 | panelCtl?.refreshFrames(); |
| 433 | } |
| 434 | function reattachFrame(src) { |
| 435 | const f = frames.get(src); if (!f) return; |
| 436 | f.attached = true; postTo(src, settingsMsg('attach')); |
| 437 | panelCtl?.refreshFrames(); |
| 438 | } |
| 439 | |
| 440 | // One named handler per message type; hostOnly/childOnly guards applied by the dispatcher. |
| 441 | const MSG = { |
| 442 | hello: { hostOnly: true, fn(m, e) { // host: a child announced itself |
| 443 | if (hostClosed) { postTo(e.source, { type: 'host-closing' }); return; } // late frame after close → go standalone |
| 444 | const f = frames.get(e.source); |
| 445 | if (f) { f.url = m.url; if (m.frameId) f.id = m.frameId; } |
| 446 | else frames.set(e.source, { url: m.url, attached: true, id: m.frameId }); |
| 447 | postTo(e.source, settingsMsg('settings')); // sync the newcomer immediately |
| 448 | postTo(e.source, clickerConfigMsg()); // ...including autoclicker config |
| 449 | postTo(e.source, { type: 'clicker-run', on: clicker.running }); |
| 450 | panelCtl?.refreshFrames(); |
| 451 | } }, |
| 452 | rollcall: { childOnly: true, fn(m, e) { // child: a host is probing for frames |
| 453 | gotHost = true; hostWin = e.source; |
| 454 | postTo(e.source, { type: 'hello', url: location.href, frameId: SELF_ID }); |
| 455 | } }, |
| 456 | settings: { childOnly: true, fn(m, e) { // child: host pushed settings |
| 457 | gotHost = true; hostWin = e.source; |
| 458 | if (attached) applySettings(m); |
| 459 | } }, |
| 460 | detach: { childOnly: true, fn(m, e) { // child: host detached us → own panel |
| 461 | hostWin = e.source; attached = false; |
| 462 | ensurePanel('detached'); |
| 463 | } }, |
| 464 | 'host-closing': { childOnly: true, fn() { // child: the host closed → become standalone |
| 465 | promoteToHost(); // host mode (no re-attach button), self-sufficient |
| 466 | } }, |
| 467 | attach: { childOnly: true, fn(m) { // child: host folded us back in |
| 468 | attached = true; destroyPanel(); applySettings(m); |
| 469 | } }, |
| 470 | reattach: { hostOnly: true, fn(m, e) { // host: a detached child asked to fold back |
| 471 | const f = frames.get(e.source); |
| 472 | if (f) { f.attached = true; postTo(e.source, settingsMsg('settings')); panelCtl?.refreshFrames(); } |
| 473 | } }, |
| 474 | 'clicker-config': { childOnly: true, fn(m) { // child: host pushed clicker settings |
| 475 | applyClickerConfig(m); |
| 476 | } }, |
| 477 | 'clicker-run': { fn(m) { // shared autoclicker running state |
| 478 | // host: a child toggled it → adopt + fan out to all; child: host pushed it down. |
| 479 | setClickerRunning(m.on, isHost); |
| 480 | } }, |
| 481 | 'scan-cmd': { fn(m, e) { // any frame: if addressed to me, run + reply |
| 482 | if (m.targetFrame && m.targetFrame !== SELF_ID) return; // broadcast not meant for this frame |
| 483 | const dkey = (m.from || '') + ':' + m.reqId; |
| 484 | if (scanHandled.has(dkey)) return; // duplicate — arrived via both channels |
| 485 | scanHandled.add(dkey); scanHandledQ.push(dkey); |
| 486 | if (scanHandledQ.length > 400) scanHandled.delete(scanHandledQ.shift()); |
| 487 | const replyWin = e.source; |
| 488 | const scmd = m.cmd || m; // command payload is nested under `cmd` (avoids type-field collision) |
| 489 | let lastFrac = -2; // throttle determinate progress to ~2% steps (indeterminate always relayed) |
| 490 | runScanCommand(scmd, (frac) => { |
| 491 | if (frac >= 0 && frac !== 1 && frac - lastFrac < 0.02) return; |
| 492 | lastFrac = frac; |
| 493 | try { if (replyWin) postTo(replyWin, { type: 'scan-progress', reqId: m.reqId, targetFrame: m.from, frac }); } catch (e2) {} |
| 494 | }).then((res) => { |
| 495 | res.type = 'scan-result'; res.reqId = m.reqId; res.targetFrame = m.from; |
| 496 | try { if (replyWin) postTo(replyWin, res); } catch (e2) {} // reply to the sender directly... |
| 497 | broadcastScanMsg(res); // ...and via window.top (reliable upward) |
| 498 | }); |
| 499 | } }, |
| 500 | 'scan-progress': { fn(m) { // controller: forward incremental progress to the UI |
| 501 | if (m.targetFrame && m.targetFrame !== SELF_ID) return; |
| 502 | scanProgress.get(m.reqId)?.(m.frac); |
| 503 | } }, |
| 504 | 'scan-result': { fn(m) { // controller: resolve the matching pending request |
| 505 | if (m.targetFrame && m.targetFrame !== SELF_ID) return; |
| 506 | scanProgress.delete(m.reqId); |
| 507 | const resolve = scanPending.get(m.reqId); |
| 508 | if (resolve) { scanPending.delete(m.reqId); resolve(m); } |
| 509 | } } |
| 510 | }; |
| 511 | window.addEventListener('message', (e) => { |
| 512 | const m = e.data; if (!m || m.__shx !== 1) return; |
| 513 | const h = MSG[m.type]; if (!h) return; |
| 514 | if ((h.hostOnly && !isHost) || (h.childOnly && isHost)) return; |
| 515 | h.fn(m, e); |
| 516 | }); |
| 517 | |
| 518 | /* ------------------------------------------------------------------ * |
| 519 | * Autoclicker. The engine runs in EVERY frame; the panel frame owns |
| 520 | * the UI and broadcasts config + the shared running flag. A frame only |
| 521 | * dispatches while the cursor is directly inside it, so clicks follow |
| 522 | * the cursor across (i)frames with no detaching. Real-time timers keep |
| 523 | * the cadence independent of the speed scale. |
| 524 | * ------------------------------------------------------------------ */ |
| 525 | const clicker = { |
| 526 | mode: 'toggle', // 'toggle' | 'hold' |
| 527 | hotkey: null, // { key, ctrl, alt, shift, meta } | null |
| 528 | swallowHotkey: false, // preventDefault/stopPropagation the hotkey so the page can't see it |
| 529 | cps: 10, // base clicks per second |
| 530 | jitterMs: 0, // random 0..jitterMs added to each gap |
| 531 | holdMs: 20, // mousedown→mouseup duration per click |
| 532 | running: false, |
| 533 | listening: false, // panel frame only: capturing the next key as the hotkey |
| 534 | lastX: 0, lastY: 0, // last cursor position in THIS frame (clientX/Y) |
| 535 | enteredDoc: false, // cursor currently within this frame's viewport |
| 536 | overChildFrame: false, // cursor currently over a nested <iframe>/<frame> |
| 537 | timer: 0, upTimer: 0 |
| 538 | }; |
| 539 | let panelHost = null; // the shadow-DOM host element of this frame's panel, if any |
| 540 | const MAX_CPS = 100; |
| 541 | const ctxDoc = pageWin.document || document; |
| 542 | |
| 543 | const cursorInside = () => clicker.enteredDoc && !clicker.overChildFrame; |
| 544 | function trackPointer(e) { |
| 545 | clicker.lastX = e.clientX; clicker.lastY = e.clientY; |
| 546 | clicker.enteredDoc = true; |
| 547 | const tag = e.target?.tagName; |
| 548 | clicker.overChildFrame = (tag === 'IFRAME' || tag === 'FRAME'); |
| 549 | } |
| 550 | try { |
| 551 | ctxDoc.addEventListener('pointermove', trackPointer, true); |
| 552 | ctxDoc.addEventListener('mousemove', trackPointer, true); // fallback where PointerEvents are absent |
| 553 | ctxDoc.addEventListener('mouseover', trackPointer, true); // updates overChildFrame even without movement |
| 554 | // relatedTarget == null on mouseout means the cursor left the window entirely. |
| 555 | ctxDoc.addEventListener('mouseout', (e) => { if (!e.relatedTarget) clicker.enteredDoc = false; }, true); |
| 556 | } catch (e) {} |
| 557 | |
| 558 | function fireClick() { |
| 559 | const x = clicker.lastX, y = clicker.lastY; |
| 560 | const el = ctxDoc.elementFromPoint ? ctxDoc.elementFromPoint(x, y) : null; |
| 561 | if (!el) return; |
| 562 | const base = { bubbles: true, cancelable: true, composed: true, view: pageWin, clientX: x, clientY: y, button: 0 }; |
| 563 | function dispatch(type, buttons, pointer) { |
| 564 | const opts = { ...base, buttons }; |
| 565 | let ev; |
| 566 | if (pointer && pageWin.PointerEvent) { |
| 567 | try { ev = new pageWin.PointerEvent(type, { ...opts, pointerId: 1, pointerType: 'mouse', isPrimary: true }); } catch (e) {} |
| 568 | } |
| 569 | if (!ev) { try { ev = new pageWin.MouseEvent(type, opts); } catch (e) { return; } } |
| 570 | try { el.dispatchEvent(ev); } catch (e) {} |
| 571 | } |
| 572 | dispatch('pointerdown', 1, true); dispatch('mousedown', 1, false); |
| 573 | const up = () => { |
| 574 | clicker.upTimer = 0; |
| 575 | dispatch('pointerup', 0, true); dispatch('mouseup', 0, false); dispatch('click', 0, false); |
| 576 | }; |
| 577 | const gap = 1000 / Math.max(0.1, clicker.cps); |
| 578 | const hold = Math.min(Math.max(0, clicker.holdMs), Math.max(0, gap - 5)); // keep hold < gap so clicks don't overlap |
| 579 | if (hold > 0) clicker.upTimer = origSetTimeout(up, hold); else up(); |
| 580 | } |
| 581 | |
| 582 | function startClickerLoop() { |
| 583 | if (clicker.timer) return; |
| 584 | (function tick() { |
| 585 | clicker.timer = 0; |
| 586 | if (!clicker.running) return; |
| 587 | if (cursorInside()) { try { fireClick(); } catch (e) {} } |
| 588 | // Re-read rate/jitter every cycle so slider-style changes apply live. Real-time |
| 589 | // timer (origSetTimeout) → cadence is unaffected by the speed scale. |
| 590 | const gap = Math.max(10, 1000 / Math.max(0.1, clicker.cps) + Math.random() * Math.max(0, clicker.jitterMs)); |
| 591 | clicker.timer = origSetTimeout(tick, gap); |
| 592 | })(); |
| 593 | } |
| 594 | function stopClickerLoop() { |
| 595 | if (clicker.timer) { origClearTimeout(clicker.timer); clicker.timer = 0; } |
| 596 | if (clicker.upTimer) { origClearTimeout(clicker.upTimer); clicker.upTimer = 0; } |
| 597 | } |
| 598 | |
| 599 | // Set the shared running flag. propagate=true → tell the host (or, if we ARE the host, |
| 600 | // fan out to every frame) so all frames share one running state. |
| 601 | function setClickerRunning(on, propagate) { |
| 602 | on = !!on; |
| 603 | if (clicker.running !== on) { clicker.running = on; on ? startClickerLoop() : stopClickerLoop(); } |
| 604 | panelCtl?.syncClicker(); |
| 605 | if (!propagate) return; |
| 606 | if (isHost) frames.forEach((f, src) => postTo(src, { type: 'clicker-run', on })); |
| 607 | else if (hostWin) postTo(hostWin, { type: 'clicker-run', on }); |
| 608 | } |
| 609 | |
| 610 | function clickerConfigMsg() { |
| 611 | const { mode, hotkey, swallowHotkey, cps, jitterMs, holdMs } = clicker; |
| 612 | return { type: 'clicker-config', mode, hotkey, swallowHotkey, cps, jitterMs, holdMs }; |
| 613 | } |
| 614 | function broadcastClickerConfig() { |
| 615 | if (!isHost) return; // config flows host → all frames (not just attached) |
| 616 | pruneFrames(); |
| 617 | frames.forEach((f, src) => postTo(src, clickerConfigMsg())); |
| 618 | } |
| 619 | function applyClickerConfig(c) { |
| 620 | if (c.mode === 'toggle' || c.mode === 'hold') clicker.mode = c.mode; |
| 621 | if ('hotkey' in c) clicker.hotkey = c.hotkey; |
| 622 | if (typeof c.swallowHotkey === 'boolean') clicker.swallowHotkey = c.swallowHotkey; |
| 623 | if (typeof c.cps === 'number') clicker.cps = Math.min(MAX_CPS, Math.max(0.1, c.cps)); |
| 624 | if (typeof c.jitterMs === 'number') clicker.jitterMs = Math.max(0, c.jitterMs); |
| 625 | if (typeof c.holdMs === 'number') clicker.holdMs = Math.max(0, c.holdMs); |
| 626 | panelCtl?.syncClicker(); |
| 627 | } |
| 628 | |
| 629 | function hotkeyMatches(e, hk) { |
| 630 | return hk && e.key === hk.key && !!e.ctrlKey === !!hk.ctrl && !!e.altKey === !!hk.alt && |
| 631 | !!e.shiftKey === !!hk.shift && !!e.metaKey === !!hk.meta; |
| 632 | } |
| 633 | function eventFromPanel(e) { |
| 634 | return panelHost && e.composedPath && e.composedPath().includes(panelHost); |
| 635 | } |
| 636 | function onClickerKeyDown(e) { |
| 637 | if (clicker.listening) { // capturing a new binding (panel frame) |
| 638 | if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return; // await a real key |
| 639 | e.preventDefault(); e.stopPropagation(); |
| 640 | clicker.hotkey = { key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey }; |
| 641 | clicker.listening = false; |
| 642 | panelCtl?.syncClicker(); |
| 643 | broadcastClickerConfig(); |
| 644 | return; |
| 645 | } |
| 646 | if (eventFromPanel(e)) return; // don't let typing in our own UI trigger the hotkey |
| 647 | if (!hotkeyMatches(e, clicker.hotkey)) return; |
| 648 | if (clicker.swallowHotkey) { e.preventDefault(); e.stopPropagation(); } |
| 649 | if (e.repeat) return; |
| 650 | if (clicker.mode === 'toggle') setClickerRunning(!clicker.running, true); |
| 651 | else setClickerRunning(true, true); // hold: down = on |
| 652 | } |
| 653 | function onClickerKeyUp(e) { |
| 654 | if (clicker.mode !== 'hold' || !clicker.hotkey || e.key !== clicker.hotkey.key) return; |
| 655 | if (eventFromPanel(e)) return; |
| 656 | if (clicker.swallowHotkey) { e.preventDefault(); e.stopPropagation(); } |
| 657 | setClickerRunning(false, true); |
| 658 | } |
| 659 | // These are OUR listeners and must see panel-originated keys (hotkey binding fires while |
| 660 | // the panel is focused), so exempt them from the input shield below. |
| 661 | onClickerKeyDown.__shxNoShield = true; |
| 662 | onClickerKeyUp.__shxNoShield = true; |
| 663 | try { |
| 664 | pageWin.addEventListener('keydown', onClickerKeyDown, true); |
| 665 | pageWin.addEventListener('keyup', onClickerKeyUp, true); |
| 666 | // Safeguard: in hold mode a keyup can land in a different frame than the keydown; |
| 667 | // losing focus then releasing would otherwise leave it stuck on. |
| 668 | pageWin.addEventListener('blur', () => { if (clicker.mode === 'hold' && clicker.running) setClickerRunning(false, true); }, true); |
| 669 | } catch (e) {} |
| 670 | |
| 671 | /* ================================================================== * |
| 672 | * Scan engine (the "Memory Scan" tab). Browser JS has no raw process |
| 673 | * memory, so we offer two backends: |
| 674 | * - 'wasm' : scan a WebAssembly.Memory linear buffer as raw typed |
| 675 | * values at byte offsets (the truest Cheat Engine analog; |
| 676 | * works for Unity/Emscripten/Godot/C-C++ games). |
| 677 | * - 'object': walk the object graph reachable from the page window and |
| 678 | * track numeric properties by their path (for plain-JS games). |
| 679 | * The engine runs LOCALLY in each frame and owns its own candidate state; |
| 680 | * the panel drives it directly (this frame) or over postMessage (iframes). |
| 681 | * ================================================================== */ |
| 682 | // `arr` is the TypedArray used for bulk scanning (platform-endian == little-endian |
| 683 | // on every supported browser, matching the explicit-LE DataView used for R/W). |
| 684 | const SCAN_TYPES = { |
| 685 | i8: { size: 1, arr: Int8Array, get: (d, o) => d.getInt8(o), set: (d, o, v) => d.setInt8(o, v) }, |
| 686 | u8: { size: 1, arr: Uint8Array, get: (d, o) => d.getUint8(o), set: (d, o, v) => d.setUint8(o, v) }, |
| 687 | i16: { size: 2, arr: Int16Array, get: (d, o) => d.getInt16(o, true), set: (d, o, v) => d.setInt16(o, v, true) }, |
| 688 | u16: { size: 2, arr: Uint16Array, get: (d, o) => d.getUint16(o, true), set: (d, o, v) => d.setUint16(o, v, true) }, |
| 689 | i32: { size: 4, arr: Int32Array, get: (d, o) => d.getInt32(o, true), set: (d, o, v) => d.setInt32(o, v, true) }, |
| 690 | u32: { size: 4, arr: Uint32Array, get: (d, o) => d.getUint32(o, true), set: (d, o, v) => d.setUint32(o, v >>> 0, true) }, |
| 691 | i64: { size: 8, arr: BigInt64Array, big: true, get: (d, o) => d.getBigInt64(o, true), set: (d, o, v) => d.setBigInt64(o, v, true) }, |
| 692 | f32: { size: 4, arr: Float32Array, float: true, get: (d, o) => d.getFloat32(o, true), set: (d, o, v) => d.setFloat32(o, v, true) }, |
| 693 | f64: { size: 8, arr: Float64Array, float: true, get: (d, o) => d.getFloat64(o, true), set: (d, o, v) => d.setFloat64(o, v, true) } |
| 694 | }; |
| 695 | const SCAN_STORE_CAP = 500000; // max candidates TRACKED locally (refine works on all of these) |
| 696 | const FLOAT_EPS = 1e-4; |
| 697 | // "Search multiple types": a type selection can be a single type or a group that |
| 698 | // expands to several. The engine scans every expanded type and tags each result |
| 699 | // with the type that matched, so refine/read/write stay correct per result. |
| 700 | const SCAN_TYPE_GROUPS = { |
| 701 | all: ['i8', 'u8', 'i16', 'u16', 'i32', 'u32', 'i64', 'f32', 'f64'], |
| 702 | allint: ['i8', 'u8', 'i16', 'u16', 'i32', 'u32', 'i64'], |
| 703 | allfloat: ['f32', 'f64'] |
| 704 | }; |
| 705 | const expandTypes = (sel) => |
| 706 | SCAN_TYPE_GROUPS[sel] ? SCAN_TYPE_GROUPS[sel].slice() : (SCAN_TYPES[sel] ? [sel] : ['i32']); |
| 707 | |
| 708 | // Parse the user's typed value into the JS form the type compares with. |
| 709 | function parseScanValue(type, raw) { |
| 710 | const t = SCAN_TYPES[type]; |
| 711 | if (!t) return null; |
| 712 | if (t.big) { try { return BigInt(Math.trunc(Number(raw))); } catch (e) { try { return BigInt(raw); } catch (e2) { return null; } } } |
| 713 | const n = Number(raw); |
| 714 | return isFinite(n) ? n : null; |
| 715 | } |
| 716 | function valuesEqual(type, a, b) { |
| 717 | const t = SCAN_TYPES[type]; |
| 718 | if (t.big) return a === b; |
| 719 | if (t.float) return Math.abs(a - b) <= FLOAT_EPS * (1 + Math.abs(b)); |
| 720 | return a === b; |
| 721 | } |
| 722 | // The place value of the least-significant digit the user actually typed, so an |
| 723 | // "exact" search is only as precise as entered: "1e10" → 1e10, "1.5" → 0.1, |
| 724 | // "100" → 1, "1.50" → 0.01. Returns 1 if the string isn't a plain number. |
| 725 | function precisionFromString(raw) { |
| 726 | const m = String(raw).trim().toLowerCase().match(/^[+-]?(?:\d+)?(?:\.(\d+))?(?:e([+-]?\d+))?$/); |
| 727 | if (!m) return 1; |
| 728 | const decimals = m[1] ? m[1].length : 0; |
| 729 | const exp = m[2] ? parseInt(m[2], 10) : 0; |
| 730 | const p = Math.pow(10, exp - decimals); |
| 731 | return (isFinite(p) && p > 0) ? p : 1; |
| 732 | } |
| 733 | // Build the "exact"-match predicate from the raw typed string. Integers/i64 match |
| 734 | // exactly; floats match the precision cell implied by what was typed, always |
| 735 | // extending AWAY from zero (closed on the typed value, open at the far end): |
| 736 | // "1e10" → [1e10, 2e10), "1.5" → [1.5, 1.6), "-1e10" → (-2e10, -1e10], |
| 737 | // "-1.5" → (-1.6, -1.5]. So magnitude grows symmetrically for either sign. |
| 738 | function makeExactMatcher(type, raw) { |
| 739 | const t = SCAN_TYPES[type]; if (!t) return null; |
| 740 | if (t.big) { const target = parseScanValue(type, raw); if (target === null) return null; return { value: target, match: x => x === target }; } |
| 741 | const v = Number(raw); if (!isFinite(v)) return null; |
| 742 | if (!t.float) return { value: v, match: x => x === v }; |
| 743 | const p = precisionFromString(raw), base = Math.round(v / p) * p; // snap to the precision grid |
| 744 | if (base < 0) { const lo = base - p, hi = base; return { value: v, match: x => x > lo && x <= hi }; } |
| 745 | const lo = base, hi = base + p; return { value: v, match: x => x >= lo && x < hi }; |
| 746 | } |
| 747 | // Union matcher for a type group ('all'/'allint'/'allfloat'): matches if ANY expanded |
| 748 | // type's exact matcher does. Object-graph values are all f64, so this widens a whole |
| 749 | // number like "15" to the float window [15,16) (matching 15.5) the way the WASM backend |
| 750 | // already does per-type — otherwise a group would collapse to typeList[0]'s int matcher. |
| 751 | function makeGroupMatcher(typeList, raw) { |
| 752 | if (raw == null) return null; |
| 753 | const ms = typeList.map(t => makeExactMatcher(t, raw)).filter(Boolean); |
| 754 | if (!ms.length) return null; |
| 755 | return { match: x => ms.some(m => m.match(x)) }; |
| 756 | } |
| 757 | // refine criteria: 'exact' uses the typed-precision matcher; the rest compare a |
| 758 | // fresh read `cur` against the stored previous value `prev`. |
| 759 | function passesCriteria(type, criteria, cur, prev, matcher) { |
| 760 | switch (criteria) { |
| 761 | case 'exact': return !!matcher && matcher.match(cur); |
| 762 | case 'changed': return !valuesEqual(type, cur, prev); |
| 763 | case 'unchanged': return valuesEqual(type, cur, prev); |
| 764 | case 'increased': return cur > prev; |
| 765 | case 'decreased': return cur < prev; |
| 766 | default: return false; |
| 767 | } |
| 768 | } |
| 769 | const scanValueToWire = (v) => (typeof v === 'bigint') ? v.toString() : v; |
| 770 | |
| 771 | // Run `body()` in slices, yielding to the event loop between them so a scan |
| 772 | // never freezes the frame and can be cancelled mid-flight. body() returns true |
| 773 | // while more work remains; onFinish(cancelled) fires once at the end. |
| 774 | function chunkLoop(job, body, onFinish) { |
| 775 | (function tick() { |
| 776 | if (job.cancelled) { onFinish(true); return; } |
| 777 | let more = false; |
| 778 | try { more = body(); } catch (e) { onFinish(false); return; } |
| 779 | if (more) origSetTimeout(tick, 0); else onFinish(false); |
| 780 | })(); |
| 781 | } |
| 782 | |
| 783 | /* ---- WASM backend -------------------------------------------------- * |
| 784 | * Candidates are tagged with the type that matched ({ off, val, ty }) so a |
| 785 | * single scan can cover several value types at once and refine/read/write each |
| 786 | * one correctly. Scans run chunked (one CHUNK of one type per event-loop slice, |
| 787 | * cycling through the type list) so they never freeze the frame and stay |
| 788 | * cancellable. Up to SCAN_STORE_CAP matches are tracked, so refining a large |
| 789 | * first scan narrows the WHOLE set — not just the first rows shown. |
| 790 | * ------------------------------------------------------------------- */ |
| 791 | const wasmScan = (() => { |
| 792 | let memIndex = 0; // which wasmMemories entry we're scanning |
| 793 | let types = ['i32']; // type list for the current scan |
| 794 | let candidates = null; // [{ off, val, ty }] or null |
| 795 | let snapshot = null; // Uint8Array copy for "unknown initial value" scans |
| 796 | const CHUNK = 8 * 1024 * 1024; // bytes scanned per event-loop slice |
| 797 | const REFINE_BUDGET = 200000; // candidates re-checked per slice during refine |
| 798 | |
| 799 | const handle = () => wasmMemories[memIndex] || null; |
| 800 | const buffer = () => { const h = handle(); try { return h ? h.memory.buffer : null; } catch (e) { return null; } }; |
| 801 | const view = () => { const b = buffer(); return b ? new DataView(b) : null; }; |
| 802 | const matcherCache = (raw) => { const c = {}; return (t) => { if (!(t in c)) c[t] = (raw != null ? makeExactMatcher(t, raw) : null); return c[t]; }; }; |
| 803 | |
| 804 | const reset = () => { candidates = null; snapshot = null; }; |
| 805 | |
| 806 | // Scan the whole buffer for each type in `types`, keeping matches for whichever |
| 807 | // `accept(ty, cur, prev)` returns true (prev is the snapshot value at the same |
| 808 | // offset, or the same as cur for a fresh exact scan). One CHUNK of one type per |
| 809 | // slice. Bulk reads go through TypedArray views (recreated per slice, since |
| 810 | // memory.grow() detaches the buffer) — several× faster than per-call DataView |
| 811 | // getters, and every scanned offset is type-aligned because we step from 0 by size. |
| 812 | function scanAll(accept, prevBuf, prevLen, job, done) { |
| 813 | const h = handle(); if (!h) { done({ error: 'no WASM memory' }); return; } |
| 814 | let len = 0; try { len = h.memory.buffer.byteLength; } catch (e) {} |
| 815 | const out = []; let count = 0, capped = false, ti = 0, off = 0; |
| 816 | chunkLoop(job, () => { |
| 817 | if (ti >= types.length) return false; |
| 818 | const buf = buffer(); if (!buf) return false; |
| 819 | const t = types[ti], { size: sz, arr: Arr } = SCAN_TYPES[t]; |
| 820 | const cap = prevBuf ? Math.min(len, buf.byteLength, prevLen) : Math.min(len, buf.byteLength); |
| 821 | const end = Math.min(cap, off + CHUNK); |
| 822 | const ta = new Arr(buf, 0, Math.floor(cap / sz)); // may throw on detach → chunkLoop finishes |
| 823 | const pa = prevBuf ? new Arr(prevBuf, 0, Math.floor(cap / sz)) : null; |
| 824 | const stop = Math.floor(end / sz); |
| 825 | for (let i = off / sz; i < stop; i++) { |
| 826 | const cur = ta[i], prev = pa ? pa[i] : cur; |
| 827 | if (accept(t, cur, prev)) { count++; if (out.length < SCAN_STORE_CAP) out.push({ off: i * sz, val: cur, ty: t }); else capped = true; } |
| 828 | } |
| 829 | off = stop * sz; |
| 830 | if (off + sz > cap) { ti++; off = 0; } |
| 831 | job.onProgress?.(Math.min(1, (ti + (cap > 0 ? Math.min(1, off / cap) : 1)) / types.length)); |
| 832 | return ti < types.length; |
| 833 | }, (cancelled) => { |
| 834 | if (cancelled) { done({ cancelled: true }); return; } |
| 835 | candidates = out; done({ count, capped, out }); |
| 836 | }); |
| 837 | } |
| 838 | |
| 839 | function firstExact(typeList, raw, job, done) { |
| 840 | types = typeList.slice(); snapshot = null; candidates = null; |
| 841 | const mfor = matcherCache(raw); |
| 842 | if (types.every(t => !mfor(t))) { done({ error: 'bad value' }); return; } |
| 843 | scanAll((t, cur) => { const m = mfor(t); return m && m.match(cur); }, null, 0, job, done); |
| 844 | } |
| 845 | |
| 846 | function firstUnknown(typeList, job, done) { |
| 847 | types = typeList.slice(); candidates = null; |
| 848 | const h = handle(); if (!h) { done({ error: 'no WASM memory' }); return; } |
| 849 | try { snapshot = new Uint8Array(h.memory.buffer.slice(0)); } catch (e) { done({ error: 'snapshot failed' }); return; } |
| 850 | done({ count: -1, capped: true }); // -1 -> "unknown armed; refine to materialize" |
| 851 | } |
| 852 | |
| 853 | // Build the first candidate list from the unknown-scan snapshot by diffing the buffer. |
| 854 | function materialize(criteria, raw, job, done) { |
| 855 | if (!snapshot) { done({ error: 'no snapshot' }); return; } |
| 856 | const mfor = matcherCache(raw); |
| 857 | scanAll((t, cur, prev) => passesCriteria(t, criteria, cur, prev, mfor(t)), snapshot.buffer, snapshot.byteLength, job, (r) => { |
| 858 | if (!r.cancelled && !r.error) { try { snapshot = new Uint8Array(handle().memory.buffer.slice(0)); } catch (e) {} } // re-baseline |
| 859 | done(r); |
| 860 | }); |
| 861 | } |
| 862 | |
| 863 | function refine(criteria, raw, job, done) { |
| 864 | if (candidates === null && snapshot !== null) { materialize(criteria, raw, job, done); return; } |
| 865 | if (candidates === null) { done({ error: 'no scan in progress' }); return; } |
| 866 | if (!view()) { done({ error: 'no WASM memory' }); return; } |
| 867 | const mfor = matcherCache(raw), src = candidates, kept = []; let i = 0; |
| 868 | chunkLoop(job, () => { |
| 869 | const d = view(); if (!d) return false; |
| 870 | for (let n = 0; i < src.length && n < REFINE_BUDGET; i++, n++) { |
| 871 | const c = src[i], { size: sz, get } = SCAN_TYPES[c.ty]; |
| 872 | if (c.off + sz > d.byteLength) continue; |
| 873 | let cur; try { cur = get(d, c.off); } catch (e) { continue; } |
| 874 | if (passesCriteria(c.ty, criteria, cur, c.val, mfor(c.ty))) kept.push({ off: c.off, val: cur, ty: c.ty }); |
| 875 | } |
| 876 | job.onProgress?.(src.length ? i / src.length : 1); |
| 877 | return i < src.length; |
| 878 | }, (cancelled) => { |
| 879 | if (cancelled) { done({ cancelled: true }); return; } |
| 880 | candidates = kept; done({ count: kept.length, capped: false }); |
| 881 | }); |
| 882 | } |
| 883 | |
| 884 | // address used by the panel/saved list: "<memIndex>:<offset>:<type>" |
| 885 | function rows(limit) { |
| 886 | const dv = view(); const out = [], list = candidates || []; |
| 887 | for (let i = 0; i < list.length && i < limit; i++) { |
| 888 | const c = list[i]; let cur = null; if (dv) try { cur = SCAN_TYPES[c.ty].get(dv, c.off); } catch (e) {} |
| 889 | out.push({ address: memIndex + ':' + c.off + ':' + c.ty, value: scanValueToWire(cur), type: c.ty }); |
| 890 | } |
| 891 | return out; |
| 892 | } |
| 893 | function readAddress(address, t) { |
| 894 | const [mi, off, aty] = String(address).split(':'); const ty = aty || t; |
| 895 | const h = wasmMemories[+mi]; if (!h || !SCAN_TYPES[ty]) return null; |
| 896 | try { return scanValueToWire(SCAN_TYPES[ty].get(new DataView(h.memory.buffer), +off)); } catch (e) { return null; } |
| 897 | } |
| 898 | function writeAddress(address, t, raw) { |
| 899 | const [mi, off, aty] = String(address).split(':'); const ty = aty || t; |
| 900 | const h = wasmMemories[+mi]; if (!h || !SCAN_TYPES[ty]) return false; |
| 901 | const v = parseScanValue(ty, raw); if (v === null) return false; |
| 902 | try { SCAN_TYPES[ty].set(new DataView(h.memory.buffer), +off, v); return true; } catch (e) { return false; } |
| 903 | } |
| 904 | const memories = () => wasmMemories.map(m => { |
| 905 | let mb = 0; try { mb = m.memory.buffer.byteLength >> 20; } catch (e) {} |
| 906 | return { label: m.label + ' (' + mb + ' MB)' }; |
| 907 | }); |
| 908 | const setMem = (i) => { memIndex = i | 0; reset(); }; |
| 909 | |
| 910 | return { memories, setMem, reset, firstExact, firstUnknown, refine, rows, readAddress, writeAddress }; |
| 911 | })(); |
| 912 | |
| 913 | /* ---- Object-graph backend ----------------------------------------- * |
| 914 | * Walks enumerable own properties reachable from the page window, capping |
| 915 | * node count and depth. Numeric leaves are tracked by their path (array of |
| 916 | * keys). JS numbers are all f64, so the int/float type only tunes equality |
| 917 | * (integer match vs. epsilon) here — noted in the UI. |
| 918 | * ------------------------------------------------------------------- */ |
| 919 | const objectScan = (() => { |
| 920 | const NODE_CAP = 200000, DEPTH_CAP = 12, BUDGET = 15000; // nodes per event-loop slice |
| 921 | let type = 'f64'; // display / default-write type (typeList[0]); values are all f64 |
| 922 | let types = ['f64']; // full expanded type list of the current scan (for group matchers) |
| 923 | let candidates = null; // [{ path:[...], val }] |
| 924 | let snapshot = null; // Map(pathKey -> { path, val }) for "unknown initial value" |
| 925 | |
| 926 | const pathKey = (path) => path.join(' '); |
| 927 | function resolve(path) { |
| 928 | let o = pageWin; |
| 929 | for (let i = 0; i < path.length; i++) { if (o == null) return undefined; o = o[path[i]]; } |
| 930 | return o; |
| 931 | } |
| 932 | // Iterative, chunked DFS over numeric leaves. cb(path, value); match(value) filters. |
| 933 | // Stack frames are parent-pointer nodes [obj, key, parentNode, depth]; the full path |
| 934 | // array is only materialized for leaves that actually match (path.concat per node |
| 935 | // used to allocate hundreds of thousands of throwaway arrays on a full walk). |
| 936 | function walkAsync(cb, match, job, done) { |
| 937 | const seen = new WeakSet(); let nodes = 0; |
| 938 | const stack = [[pageWin, null, null, 0]]; |
| 939 | const pathOf = (node, leafKey) => { |
| 940 | const p = [leafKey]; |
| 941 | for (let n = node; n && n[1] !== null; n = n[2]) p.push(n[1]); |
| 942 | return p.reverse(); |
| 943 | }; |
| 944 | chunkLoop(job, () => { |
| 945 | let processed = 0; |
| 946 | while (stack.length && processed < BUDGET && nodes < NODE_CAP) { |
| 947 | const fr = stack.pop(); const obj = fr[0], depth = fr[3]; processed++; |
| 948 | if (obj == null || depth > DEPTH_CAP) continue; |
| 949 | let keys; try { keys = Object.keys(obj); } catch (e) { continue; } |
| 950 | for (let i = 0; i < keys.length; i++) { |
| 951 | if (nodes >= NODE_CAP) break; |
| 952 | const k = keys[i]; let v; |
| 953 | try { v = obj[k]; } catch (e) { continue; } |
| 954 | const tv = typeof v; |
| 955 | if (tv === 'number') { if (isFinite(v) && (!match || match(v))) cb(pathOf(fr, k), v); } |
| 956 | else if (tv === 'object' || tv === 'function') { |
| 957 | if (v === null || seen.has(v) || v === pageWin || v === window) continue; |
| 958 | try { if (v.nodeType && v.nodeName) continue; } catch (e) {} // DOM nodes |
| 959 | try { if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) continue; } catch (e) {} |
| 960 | seen.add(v); nodes++; |
| 961 | stack.push([v, k, fr, depth + 1]); |
| 962 | } |
| 963 | } |
| 964 | } |
| 965 | job.onProgress?.(-1); // total is unknown up front → indeterminate |
| 966 | return stack.length > 0 && nodes < NODE_CAP; |
| 967 | }, done); |
| 968 | } |
| 969 | |
| 970 | const reset = () => { candidates = null; snapshot = null; }; |
| 971 | |
| 972 | // All JS numbers are f64, so the width only tunes equality (exact int vs. float |
| 973 | // cell); a multi-type selection just uses the first type's matcher here. |
| 974 | function firstExact(typeList, raw, job, done) { |
| 975 | types = typeList.slice(); type = typeList[0] || 'f64'; snapshot = null; candidates = null; |
| 976 | const matcher = makeGroupMatcher(types, raw); |
| 977 | if (!matcher) { done({ error: 'bad value' }); return; } |
| 978 | const out = []; let count = 0, capped = false; |
| 979 | walkAsync((path, v) => { count++; if (out.length < SCAN_STORE_CAP) out.push({ path, val: v }); else capped = true; }, |
| 980 | v => matcher.match(v), job, (cancelled) => { |
| 981 | if (cancelled) { done({ cancelled: true }); return; } |
| 982 | candidates = out; done({ count, capped }); |
| 983 | }); |
| 984 | } |
| 985 | function firstUnknown(typeList, job, done) { |
| 986 | types = typeList.slice(); type = typeList[0] || 'f64'; candidates = null; |
| 987 | const snap = new Map(); |
| 988 | walkAsync((path, v) => snap.set(pathKey(path), { path, val: v }), null, job, (cancelled) => { |
| 989 | if (cancelled) { done({ cancelled: true }); return; } |
| 990 | snapshot = snap; done({ count: -1, capped: true }); |
| 991 | }); |
| 992 | } |
| 993 | // Re-check `entries` ({ path, val }) against a fresh read, chunked so huge candidate |
| 994 | // sets never freeze the frame; used by both materialize (from snapshot, re-baselining) |
| 995 | // and refine (from candidates). |
| 996 | function recheck(entries, criteria, raw, rebaseline, job, done) { |
| 997 | const matcher = (raw != null) ? makeGroupMatcher(types, raw) : null; |
| 998 | const out = []; let count = 0, capped = false, i = 0; |
| 999 | chunkLoop(job, () => { |
| 1000 | for (let processed = 0; i < entries.length && processed < BUDGET; i++, processed++) { |
| 1001 | const entry = entries[i]; const cur = resolve(entry.path); |
| 1002 | if (typeof cur !== 'number' || !isFinite(cur)) continue; |
| 1003 | if (passesCriteria(type, criteria, cur, entry.val, matcher)) { count++; if (out.length < SCAN_STORE_CAP) out.push({ path: entry.path, val: cur }); else capped = true; } |
| 1004 | if (rebaseline) entry.val = cur; |
| 1005 | } |
| 1006 | job.onProgress?.(entries.length ? i / entries.length : 1); |
| 1007 | return i < entries.length; |
| 1008 | }, (cancelled) => { |
| 1009 | if (cancelled) { done({ cancelled: true }); return; } |
| 1010 | candidates = out; done({ count, capped }); |
| 1011 | }); |
| 1012 | } |
| 1013 | function refine(criteria, raw, job, done) { |
| 1014 | if (candidates === null && snapshot !== null) { recheck(Array.from(snapshot.values()), criteria, raw, true, job, done); return; } |
| 1015 | if (candidates === null) { done({ error: 'no scan in progress' }); return; } |
| 1016 | recheck(candidates, criteria, raw, false, job, done); |
| 1017 | } |
| 1018 | function rows(limit) { |
| 1019 | const out = [], list = candidates || []; |
| 1020 | for (let i = 0; i < list.length && i < limit; i++) { |
| 1021 | const cur = resolve(list[i].path); |
| 1022 | out.push({ address: JSON.stringify(list[i].path), value: (typeof cur === 'number' ? cur : null), type }); |
| 1023 | } |
| 1024 | return out; |
| 1025 | } |
| 1026 | function readAddress(address) { |
| 1027 | try { const cur = resolve(JSON.parse(address)); return (typeof cur === 'number') ? cur : null; } catch (e) { return null; } |
| 1028 | } |
| 1029 | function writeAddress(address, t, raw) { |
| 1030 | if (!SCAN_TYPES[t]) t = 'f64'; // a type group selection → object values are f64 |
| 1031 | let path; try { path = JSON.parse(address); } catch (e) { return false; } |
| 1032 | const v = parseScanValue(t, raw); if (v === null) return false; |
| 1033 | try { const parent = resolve(path.slice(0, -1)); if (parent == null) return false; parent[path[path.length - 1]] = (typeof v === 'bigint' ? Number(v) : v); return true; } |
| 1034 | catch (e) { return false; } |
| 1035 | } |
| 1036 | const memories = () => []; |
| 1037 | const setMem = () => {}; // n/a for object graph |
| 1038 | |
| 1039 | return { memories, setMem, reset, firstExact, firstUnknown, refine, rows, readAddress, writeAddress }; |
| 1040 | })(); |
| 1041 | |
| 1042 | const scanEngine = (name) => name === 'object' ? objectScan : wasmScan; |
| 1043 | |
| 1044 | // Address labels for display (wasm "mi:off:ty" → "mem#mi +0x.. (ty)"; object path → "window..."). |
| 1045 | function scanAddressLabel(engine, address) { |
| 1046 | if (engine === 'object') { try { return 'window.' + JSON.parse(address).join('.'); } catch (e) { return String(address); } } |
| 1047 | const [mi, off, ty] = String(address).split(':'); |
| 1048 | return 'mem#' + mi + ' +0x' + (Number(off) || 0).toString(16) + (ty ? ' (' + ty + ')' : ''); |
| 1049 | } |
| 1050 | |
| 1051 | // Execute one scan command against the LOCAL engines; resolves a wire-safe result. |
| 1052 | // Long ops run asynchronously (chunked) and a single in-flight `scanJob` can be |
| 1053 | // cancelled — there are no timeouts anywhere; callers wait until done or cancel. |
| 1054 | const SCAN_ROW_LIMIT = 200; // most rows we ship/render at once |
| 1055 | let scanJob = null; |
| 1056 | function runScanCommand(cmd, onProgress) { |
| 1057 | return new Promise((resolve) => { |
| 1058 | try { |
| 1059 | const eng = scanEngine(cmd.engine); |
| 1060 | const cancelJob = () => { if (scanJob) scanJob.cancelled = true; }; |
| 1061 | switch (cmd.op) { |
| 1062 | case 'ping': resolve({ ok: true, pong: true }); return; |
| 1063 | case 'list-memories': resolve({ ok: true, memories: wasmScan.memories() }); return; |
| 1064 | case 'set-mem': cancelJob(); eng.setMem(cmd.mem | 0); resolve({ ok: true }); return; |
| 1065 | case 'reset': cancelJob(); eng.reset(); resolve({ ok: true, count: 0, rows: [] }); return; |
| 1066 | case 'cancel': cancelJob(); resolve({ ok: true, cancelled: true }); return; |
| 1067 | case 'set-paused': setPaused(cmd.value); resolve({ ok: true, paused }); return; |
| 1068 | case 'read': |
| 1069 | resolve({ ok: true, values: (cmd.addresses || []).map(a => ({ address: a, value: scanValueToWire(eng.readAddress(a, cmd.type)) })) }); |
| 1070 | return; |
| 1071 | case 'write': { |
| 1072 | const ok = eng.writeAddress(cmd.address, cmd.type, cmd.value); |
| 1073 | resolve({ ok, value: scanValueToWire(eng.readAddress(cmd.address, cmd.type)) }); return; |
| 1074 | } |
| 1075 | case 'first-exact': case 'first-unknown': case 'refine': { |
| 1076 | cancelJob(); // supersede any prior scan |
| 1077 | const job = { cancelled: false, onProgress: onProgress || null }; scanJob = job; |
| 1078 | const done = (r) => { |
| 1079 | if (scanJob === job) scanJob = null; |
| 1080 | if (!r || r.error) { resolve({ ok: false, error: r?.error || 'scan failed' }); return; } |
| 1081 | if (r.cancelled) { resolve({ ok: true, cancelled: true }); return; } |
| 1082 | resolve({ ok: true, count: r.count, capped: r.capped, rows: eng.rows(SCAN_ROW_LIMIT) }); |
| 1083 | }; |
| 1084 | const types = expandTypes(cmd.type); |
| 1085 | if (cmd.op === 'first-exact') eng.firstExact(types, cmd.value, job, done); |
| 1086 | else if (cmd.op === 'first-unknown') eng.firstUnknown(types, job, done); |
| 1087 | else eng.refine(cmd.criteria, cmd.value ?? null, job, done); |
| 1088 | return; |
| 1089 | } |
| 1090 | default: resolve({ ok: false, error: 'unknown op' }); return; |
| 1091 | } |
| 1092 | } catch (e) { resolve({ ok: false, error: String(e?.message || e) }); } |
| 1093 | }); |
| 1094 | } |
| 1095 | |
| 1096 | /* ------------------------------------------------------------------ * |
| 1097 | * Scan controller — the panel drives a TARGET frame. For "this frame" |
| 1098 | * it calls the local engine directly; for a child iframe it marshals the |
| 1099 | * command over postMessage and resolves when the matching reply arrives. |
| 1100 | * There is NO timeout — a request waits until the target replies or the |
| 1101 | * user cancels (which sends a 'cancel' command that ends the scan). |
| 1102 | * Candidate state stays in the target frame; only commands + small result |
| 1103 | * batches cross the boundary (works cross-origin and for nested frames). |
| 1104 | * ------------------------------------------------------------------ */ |
| 1105 | let scanSeq = 1; |
| 1106 | const scanPending = new Map(); // reqId -> resolve (controller side) |
| 1107 | const scanProgress = new Map(); // reqId -> onProgress(frac) (controller side, remote scans) |
| 1108 | const scanHandled = new Set(); // "from:reqId" of cmds already run (target side, dedup) |
| 1109 | const scanHandledQ = []; // FIFO to bound scanHandled |
| 1110 | function sendScan(targetId, targetWin, cmd, onProgress) { |
| 1111 | if (!targetId) return runScanCommand(cmd, onProgress); // null → this frame (local engine, no messaging) |
| 1112 | return new Promise((resolve) => { |
| 1113 | const reqId = scanSeq++; |
| 1114 | scanPending.set(reqId, resolve); |
| 1115 | if (onProgress) scanProgress.set(reqId, onProgress); |
| 1116 | // Nest the command under `cmd` rather than flattening it into the envelope: |
| 1117 | // the scan value-type field is also called `type` and would otherwise overwrite |
| 1118 | // the envelope's `type: 'scan-cmd'`, so the target saw an unknown message type |
| 1119 | // and silently dropped every remote scan (v1.4.5 fix). |
| 1120 | const msg = { type: 'scan-cmd', reqId, targetFrame: targetId, from: SELF_ID, cmd }; |
| 1121 | if (targetWin) postTo(targetWin, msg); // proven channel (clicker/settings use it) |
| 1122 | broadcastScanMsg(msg); // + frame-tree broadcast as backup |
| 1123 | }); |
| 1124 | } |
| 1125 | /* ------------------------------------------------------------------ * |
| 1126 | * UI (Shadow DOM) |
| 1127 | * ------------------------------------------------------------------ */ |
| 1128 | const CSS = ` |
| 1129 | :host { all: initial; } |
| 1130 | #panel { |
| 1131 | position: fixed; right: 12px; bottom: 12px; z-index: 2147483647; box-sizing: border-box; |
| 1132 | font: 12px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; |
| 1133 | color: #e8e8ea; background: #1b1d22; border: 1px solid #3a3d44; border-radius: 10px; |
| 1134 | box-shadow: 0 10px 30px rgba(0,0,0,.5); |
| 1135 | width: 256px; max-width: 92vw; max-height: 82vh; |
| 1136 | display: flex; flex-direction: column; overflow: hidden; resize: both; user-select: none; |
| 1137 | } |
| 1138 | #panel.min { width: auto; height: auto !important; resize: none; } |
| 1139 | #panel.min #bar { gap: 4px; padding: 5px 6px; cursor: pointer; } |
| 1140 | #panel.min #title, #panel.min #badge, #panel.min .sp, #panel.min #close { display: none; } |
| 1141 | #panel * { box-sizing: border-box; } |
| 1142 | #bar { display: flex; align-items: center; gap: 6px; padding: 7px 8px; background: #23262d; cursor: grab; } |
| 1143 | #bar:active { cursor: grabbing; } |
| 1144 | #title { font-weight: 600; } |
| 1145 | #badge { padding: 1px 7px; background: #2f6df6; border-radius: 999px; font-weight: 700; font-size: 11px; } |
| 1146 | #panel:not(.min) #badge { display: none; } |
| 1147 | .sp { flex: 1 1 auto; } |
| 1148 | #bar button { all: unset; cursor: pointer; width: 20px; height: 20px; line-height: 20px; text-align: center; border-radius: 5px; color: #cfd2d8; font-size: 14px; } |
| 1149 | #bar button:hover { background: #34384199; color: #fff; } |
| 1150 | #body { padding: 11px; display: flex; flex-direction: column; gap: 11px; overflow: auto; } |
| 1151 | #panel.min #body { display: none; } |
| 1152 | .row { display: flex; flex-direction: column; gap: 6px; } |
| 1153 | .row .lbl { display: flex; justify-content: space-between; align-items: baseline; color: #aeb2bb; } |
| 1154 | .row output { color: #fff; font-weight: 700; } |
| 1155 | input[type=range] { width: 100%; accent-color: #2f6df6; } |
| 1156 | input[type=number] { all: unset; width: 100%; padding: 5px 8px; background: #14161a; border: 1px solid #3a3d44; border-radius: 6px; color: #fff; font: inherit; } |
| 1157 | .presets { display: flex; flex-wrap: wrap; gap: 5px; } |
| 1158 | .presets button { all: unset; cursor: pointer; padding: 3px 9px; background: #2a2e36; border: 1px solid #3a3d44; border-radius: 999px; color: #d6d9df; font-size: 11px; } |
| 1159 | .presets button:hover { background: #353a44; color: #fff; } |
| 1160 | .toggles { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid #2c2f37; padding-top: 9px; } |
| 1161 | .tg { display: flex; align-items: center; gap: 8px; cursor: pointer; } |
| 1162 | .tg input { accent-color: #2f6df6; cursor: pointer; } |
| 1163 | .tg input:disabled { cursor: not-allowed; } |
| 1164 | .tg.disabled { opacity: .45; cursor: not-allowed; } |
| 1165 | .frames { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid #2c2f37; padding-top: 9px; } |
| 1166 | #framesBox[hidden] { display: none; } /* .frames display:flex above defeats the UA [hidden] rule otherwise */ |
| 1167 | .frames .lbl { color: #aeb2bb; } |
| 1168 | #frameList { display: flex; flex-direction: column; gap: 5px; } |
| 1169 | .frow { display: flex; align-items: center; gap: 6px; } |
| 1170 | .furl { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d6d9df; font-size: 10.5px; } |
| 1171 | .frow button { all: unset; cursor: pointer; padding: 2px 8px; background: #2a2e36; border: 1px solid #3a3d44; border-radius: 999px; color: #d6d9df; font-size: 10.5px; } |
| 1172 | .frow button:hover { background: #353a44; color: #fff; } |
| 1173 | #reattach { all: unset; cursor: pointer; text-align: center; padding: 6px 8px; background: #2a2e36; border: 1px solid #3a3d44; border-radius: 6px; color: #d6d9df; font-size: 11px; } |
| 1174 | #reattach:hover { background: #353a44; color: #fff; } |
| 1175 | #reattach[hidden] { display: none; } /* all:unset above resets display to inline, defeating the UA [hidden] rule */ |
| 1176 | .tabs { display: flex; gap: 4px; padding: 6px 8px 0; background: #23262d; } |
| 1177 | #panel.min .tabs { display: none; } |
| 1178 | .tab { all: unset; cursor: pointer; padding: 5px 11px; border-radius: 6px 6px 0 0; color: #aeb2bb; font-size: 11.5px; font-weight: 600; } |
| 1179 | .tab:hover { color: #fff; } |
| 1180 | .tab.active { background: #1b1d22; color: #fff; } |
| 1181 | .pane { display: flex; flex-direction: column; gap: 11px; } |
| 1182 | .pane[hidden] { display: none; } |
| 1183 | .clk-row { display: flex; align-items: center; gap: 8px; } |
| 1184 | .clk-row > span.k { flex: 1 1 auto; color: #aeb2bb; } |
| 1185 | .clk-grid { display: grid; grid-template-columns: auto 1fr; gap: 7px 8px; align-items: center; } |
| 1186 | .clk-grid > span { color: #aeb2bb; } |
| 1187 | .clk-grid input[type=number] { width: 100%; } |
| 1188 | .clk-btn { all: unset; cursor: pointer; padding: 4px 10px; background: #2a2e36; border: 1px solid #3a3d44; border-radius: 6px; color: #d6d9df; font-size: 11px; } |
| 1189 | .clk-btn:hover { background: #353a44; color: #fff; } |
| 1190 | .clk-key { font-family: ui-monospace, Menlo, Consolas, monospace; color: #fff; background: #14161a; border: 1px solid #3a3d44; border-radius: 6px; padding: 4px 8px; flex: 1 1 auto; text-align: center; } |
| 1191 | .clk-key.listening { color: #f0b07a; border-color: #5a4326; } |
| 1192 | .seg { display: flex; gap: 0; border: 1px solid #3a3d44; border-radius: 6px; overflow: hidden; } |
| 1193 | .seg button { all: unset; cursor: pointer; flex: 1 1 0; text-align: center; padding: 5px 0; color: #d6d9df; font-size: 11px; } |
| 1194 | .seg button.on { background: #2f6df6; color: #fff; font-weight: 600; } |
| 1195 | .sc-rowx { display: flex; align-items: center; gap: 6px; } |
| 1196 | .sc-rowx > * { min-width: 0; } |
| 1197 | .sc-sel { all: unset; cursor: pointer; box-sizing: border-box; display: block; width: 100%; min-width: 0; max-width: 100%; padding: 5px 8px; background: #14161a; border: 1px solid #3a3d44; border-radius: 6px; color: #fff; font: inherit; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| 1198 | .sc-sel[hidden] { display: none; } |
| 1199 | .sc-type { flex: 0 0 auto; width: auto; } |
| 1200 | #scValue { all: unset; box-sizing: border-box; flex: 1 1 auto; width: 100%; min-width: 0; padding: 5px 8px; background: #14161a; border: 1px solid #3a3d44; border-radius: 6px; color: #fff; font: inherit; } |
| 1201 | .sc-pause { display: block; box-sizing: border-box; width: 100%; text-align: center; } |
| 1202 | .sc-pause.on { background: #2f6df6; border-color: #2f6df6; color: #fff; } |
| 1203 | .sc-btns { display: flex; flex-wrap: wrap; gap: 5px; } |
| 1204 | .sc-btns .clk-btn { flex: 1 1 auto; text-align: center; } |
| 1205 | .clk-btn[disabled] { opacity: .4; cursor: not-allowed; } |
| 1206 | .sc-cancel { background: #3a2417; border-color: #5a4326; color: #f0b07a; } |
| 1207 | .sc-cancel:hover { background: #4a2e1d; color: #ffcf9a; } |
| 1208 | .sc-cancel[hidden], #scMemDetect[hidden] { display: none; } |
| 1209 | #scRefine[hidden] { display: none; } |
| 1210 | .sc-mode { display: flex; gap: 14px; flex-wrap: wrap; } |
| 1211 | .sc-mode .tg { flex: 0 0 auto; } |
| 1212 | .sc-mode input:disabled + span { opacity: .45; } |
| 1213 | #scValue:disabled { opacity: .45; } |
| 1214 | .sc-progress { height: 4px; background: #14161a; border-radius: 3px; overflow: hidden; } |
| 1215 | .sc-progress[hidden] { display: none; } |
| 1216 | .sc-bar { height: 100%; width: 0; background: #2f6df6; transition: width .12s linear; } |
| 1217 | .sc-bar.indet { width: 40%; animation: shx-indet 1s linear infinite; } |
| 1218 | @keyframes shx-indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } |
| 1219 | .sc-count { color: #aeb2bb; font-size: 10.5px; } |
| 1220 | .sc-results { display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow: auto; } |
| 1221 | .sc-results:empty { display: none; } |
| 1222 | .sc-row { display: flex; align-items: center; gap: 6px; } |
| 1223 | .sc-addr { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 10px; color: #9aa0ab; } |
| 1224 | .sc-val { all: unset; box-sizing: border-box; width: 74px; padding: 3px 6px; background: #14161a; border: 1px solid #3a3d44; border-radius: 5px; color: #fff; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 10.5px; text-align: right; } |
| 1225 | .sc-mini { all: unset; cursor: pointer; padding: 2px 7px; background: #2a2e36; border: 1px solid #3a3d44; border-radius: 999px; color: #d6d9df; font-size: 10px; } |
| 1226 | .sc-mini:hover { background: #353a44; color: #fff; } |
| 1227 | .sc-saved { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid #2c2f37; padding-top: 9px; } |
| 1228 | .sc-saved .lbl { color: #aeb2bb; } |
| 1229 | #scSaved { display: flex; flex-direction: column; gap: 5px; } |
| 1230 | #scSaved:empty::after { content: 'Nothing saved yet.'; color: #6f747f; font-size: 10.5px; } |
| 1231 | .sc-srow { display: flex; align-items: center; gap: 6px; } |
| 1232 | .sc-name { all: unset; box-sizing: border-box; flex: 1 1 auto; min-width: 0; padding: 4px 7px; background: #14161a; border: 1px solid #3a3d44; border-radius: 5px; color: #d6d9df; font: inherit; font-size: 11px; overflow: hidden; text-overflow: ellipsis; } |
| 1233 | .sc-name:focus { border-color: #2f6df6; color: #fff; } |
| 1234 | .sc-name.stale { color: #c77; border-color: #5a2c2c; } |
| 1235 | .status { font-size: 10.5px; padding: 5px 7px; border-radius: 6px; } |
| 1236 | .status.run { background: #16331f; color: #7fe0a0; } |
| 1237 | .status.idle { background: #23262d; color: #aeb2bb; } |
| 1238 | .status.ok { background: #16331f; color: #7fe0a0; } |
| 1239 | .status.warn { background: #3a2417; color: #f0b07a; } |
| 1240 | .note { color: #6f747f; font-size: 10.5px; border-top: 1px solid #2c2f37; padding-top: 8px; } |
| 1241 | #dlg { position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.45); } |
| 1242 | #dlg[hidden] { display: none; } |
| 1243 | .dlgbox { background: #1b1d22; border: 1px solid #3a3d44; border-radius: 10px; padding: 16px; width: 300px; max-width: 88vw; box-shadow: 0 14px 40px rgba(0,0,0,.6); } |
| 1244 | .dlgmsg { font-weight: 600; margin-bottom: 8px; } |
| 1245 | .dlgurl { font-size: 10.5px; color: #8b909a; word-break: break-all; background: #14161a; border: 1px solid #2c2f37; border-radius: 6px; padding: 6px 8px; margin-bottom: 14px; } |
| 1246 | .dlgbtns { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } |
| 1247 | .dlgbtns button { all: unset; cursor: pointer; padding: 6px 11px; border-radius: 6px; font-size: 11.5px; } |
| 1248 | #dlgYes { background: #2f6df6; color: #fff; font-weight: 600; } |
| 1249 | #dlgNo { background: #2a2e36; color: #e8e8ea; border: 1px solid #3a3d44; } |
| 1250 | #dlgCancel { color: #aeb2bb; } |
| 1251 | #dlgYes:hover { background: #3d79ff; } |
| 1252 | `; |
| 1253 | |
| 1254 | const HTML = ` |
| 1255 | <div id="panel" class="min"> |
| 1256 | <div id="bar"> |
| 1257 | <span id="grip">⚡</span> |
| 1258 | <span id="title">Speedhack</span> |
| 1259 | <span id="badge">1×</span> |
| 1260 | <span class="sp"></span> |
| 1261 | <button id="min" title="Minimize / expand">–</button> |
| 1262 | <button id="close" title="Close">×</button> |
| 1263 | </div> |
| 1264 | <div class="tabs" id="tabs"> |
| 1265 | <button class="tab active" data-tab="speed">Speed</button> |
| 1266 | <button class="tab" data-tab="clicker">Clicker</button> |
| 1267 | <button class="tab" data-tab="scan">Scan</button> |
| 1268 | </div> |
| 1269 | <div id="body"> |
| 1270 | <div class="pane" data-pane="speed"> |
| 1271 | <div id="status" class="status"></div> |
| 1272 | <div class="row"> |
| 1273 | <div class="lbl"><span>Scale factor</span><output id="scaleOut">1×</output></div> |
| 1274 | <input id="scaleRange" type="range" min="0.1" max="100" step="0.1" value="1"> |
| 1275 | <input id="scaleNum" type="number" min="0.1" max="1000" step="0.1" value="1"> |
| 1276 | <div class="presets"> |
| 1277 | <button data-s="0.25">0.25×</button> |
| 1278 | <button data-s="0.5">0.5×</button> |
| 1279 | <button data-s="1">1×</button> |
| 1280 | <button data-s="2">2×</button> |
| 1281 | <button data-s="5">5×</button> |
| 1282 | <button data-s="10">10×</button> |
| 1283 | </div> |
| 1284 | </div> |
| 1285 | <div class="toggles" id="toggles"></div> |
| 1286 | <div class="frames" id="framesBox" hidden> |
| 1287 | <div class="lbl"><span>Frames on this page</span></div> |
| 1288 | <div id="frameList"></div> |
| 1289 | </div> |
| 1290 | <button id="reattach" hidden>↩ Re-attach to main panel</button> |
| 1291 | <div class="note">Settings reset on reload. Only the per-URL “closed” choice is saved.</div> |
| 1292 | </div> |
| 1293 | <div class="pane" data-pane="clicker" hidden> |
| 1294 | <div id="clkStatus" class="status idle"></div> |
| 1295 | <div class="seg" id="clkMode"> |
| 1296 | <button data-mode="toggle">Toggle</button> |
| 1297 | <button data-mode="hold">Hold</button> |
| 1298 | </div> |
| 1299 | <div class="clk-row"> |
| 1300 | <span class="clk-key" id="clkKey">not set</span> |
| 1301 | <button class="clk-btn" id="clkSet">Set</button> |
| 1302 | <button class="clk-btn" id="clkClear">Clear</button> |
| 1303 | </div> |
| 1304 | <label class="tg"><input type="checkbox" id="clkSwallow"><span>Swallow hotkey (hide it from the page)</span></label> |
| 1305 | <div class="clk-grid"> |
| 1306 | <span>Rate (clicks/s)</span><input id="clkCps" type="number" min="0.1" max="100" step="0.1" value="10"> |
| 1307 | <span>Random (± ms)</span><input id="clkJitter" type="number" min="0" max="2000" step="1" value="0"> |
| 1308 | <span>Hold (ms)</span><input id="clkHold" type="number" min="0" max="2000" step="1" value="20"> |
| 1309 | </div> |
| 1310 | <div class="note">Clicks at the cursor in whichever (i)frame it is over. Synthetic clicks (isTrusted=false) — some anti-bot/anti-cheat won't accept them.</div> |
| 1311 | </div> |
| 1312 | <div class="pane" data-pane="scan" hidden> |
| 1313 | <select id="scTarget" class="sc-sel" title="Frame to scan"></select> |
| 1314 | <button class="clk-btn sc-pause" id="scPause">⏸ Pause</button> |
| 1315 | <div id="scStatus" class="status idle"></div> |
| 1316 | <div class="seg" id="scEngine"> |
| 1317 | <button data-engine="wasm" class="on">WASM</button> |
| 1318 | <button data-engine="object">JS Objects</button> |
| 1319 | </div> |
| 1320 | <select id="scMem" class="sc-sel" hidden></select> |
| 1321 | <button class="clk-btn sc-pause" id="scMemDetect" hidden>↻ Detect WASM memory</button> |
| 1322 | <div class="sc-mode" id="scMode"> |
| 1323 | <label class="tg"><input type="radio" name="shxScMode" value="exact" checked><span>Known value</span></label> |
| 1324 | <label class="tg" title="Snapshot all values now, then narrow by how they change (Up/Down/Changed) — use when you don't know the value."><input type="radio" name="shxScMode" value="unknown"><span>Unknown initial value</span></label> |
| 1325 | </div> |
| 1326 | <div class="sc-rowx"> |
| 1327 | <select id="scType" class="sc-sel sc-type"> |
| 1328 | <option value="i32" title="32-bit signed integer. The most common type — scores, counts, HP, currency in many games.">int32</option> |
| 1329 | <option value="f32" title="32-bit float. Positions, health bars, speeds, timers — typical for Unity/C/C++ (WASM) games.">float32</option> |
| 1330 | <option value="f64" title="64-bit float. JavaScript's native number type — the default for JS-object games.">float64</option> |
| 1331 | <option value="i8" title="8-bit signed integer (-128..127). Small flags, levels, tiny counters.">int8</option> |
| 1332 | <option value="u8" title="8-bit unsigned (0..255). Raw bytes, booleans, RGBA channels, small counters.">uint8</option> |
| 1333 | <option value="i16" title="16-bit signed integer (-32768..32767). Medium counters and coordinates.">int16</option> |
| 1334 | <option value="u16" title="16-bit unsigned (0..65535). Medium counters, tile/IDs, ammo.">uint16</option> |
| 1335 | <option value="u32" title="32-bit unsigned (0..4.29e9). Large counts, currency, timestamps.">uint32</option> |
| 1336 | <option value="i64" title="64-bit integer via BigInt. Very large currencies/IDs — WASM only.">int64</option> |
| 1337 | <option value="allint" title="Scan every integer width (i8…i64) at once. Use when you know it's a whole number but not the size. WASM only.">all integers</option> |
| 1338 | <option value="allfloat" title="Scan both float widths (f32 + f64) at once. Use when unsure which float precision the game uses.">all floats</option> |
| 1339 | <option value="all" title="Scan ALL value types at once — slowest, but finds the value whatever its type. WASM only; refine to narrow.">all types</option> |
| 1340 | </select> |
| 1341 | <input id="scValue" type="text" inputmode="decimal" placeholder="value"> |
| 1342 | </div> |
| 1343 | <div class="sc-btns"> |
| 1344 | <button class="clk-btn" id="scFirst">First scan</button> |
| 1345 | <button class="clk-btn sc-cancel" id="scCancel" hidden>Cancel</button> |
| 1346 | </div> |
| 1347 | <div class="sc-btns" id="scRefine" hidden> |
| 1348 | <button class="clk-btn" data-crit="exact">Exact</button> |
| 1349 | <button class="clk-btn" data-crit="changed">Changed</button> |
| 1350 | <button class="clk-btn" data-crit="unchanged">Unchanged</button> |
| 1351 | <button class="clk-btn" data-crit="increased">▲ Up</button> |
| 1352 | <button class="clk-btn" data-crit="decreased">▼ Down</button> |
| 1353 | </div> |
| 1354 | <div id="scProgress" class="sc-progress" hidden><div id="scBar" class="sc-bar"></div></div> |
| 1355 | <div id="scCount" class="sc-count">No scan yet.</div> |
| 1356 | <div id="scResults" class="sc-results"></div> |
| 1357 | <div class="sc-saved"> |
| 1358 | <div class="lbl"><span>Saved</span></div> |
| 1359 | <div id="scSaved"></div> |
| 1360 | </div> |
| 1361 | <div class="note">No raw memory in a browser: WASM mode scans a game's WebAssembly heap; JS mode walks values reachable from <code>window</code>. Exact search is only as precise as typed (e.g. <code>1e10</code> matches 1e10–2e10). Pause is speed=0. Saved WASM offsets reset on reload.</div> |
| 1362 | </div> |
| 1363 | </div> |
| 1364 | </div> |
| 1365 | <div id="dlg" hidden> |
| 1366 | <div class="dlgbox"> |
| 1367 | <div class="dlgmsg">Remember closing for this exact page?</div> |
| 1368 | <div class="dlgurl" id="dlgurl"></div> |
| 1369 | <div class="dlgbtns"> |
| 1370 | <button id="dlgCancel">Cancel</button> |
| 1371 | <button id="dlgNo">Just close</button> |
| 1372 | <button id="dlgYes">Don’t show here</button> |
| 1373 | </div> |
| 1374 | </div> |
| 1375 | </div> |
| 1376 | `; |
| 1377 | |
| 1378 | function shortUrl(u) { |
| 1379 | try { const p = new URL(u); return (p.pathname === '/' ? p.host : p.host + p.pathname); } |
| 1380 | catch (e) { return String(u).replace(/^[a-z]+:\/\//, ''); } |
| 1381 | } |
| 1382 | |
| 1383 | // mode: 'host' (top / promoted — shows frame list) or 'detached' (child with re-attach) |
| 1384 | function buildUI(mode) { |
| 1385 | let host, root; |
| 1386 | try { |
| 1387 | host = document.createElement('div'); |
| 1388 | host.style.all = 'initial'; |
| 1389 | root = host.attachShadow({ mode: 'open' }); |
| 1390 | root.innerHTML = '<style>' + CSS + '</style>' + HTML; |
| 1391 | try { shieldInputTarget(document.body); } catch (e) {} // now that <body> exists |
| 1392 | (document.body || document.documentElement).appendChild(host); |
| 1393 | } catch (e) { return null; } |
| 1394 | panelHost = host; // so the hotkey listener can ignore keys typed into our own UI |
| 1395 | |
| 1396 | // Input shield: stop events that originate inside the panel from reaching the |
| 1397 | // page's own listeners, so clicking/typing in the UI doesn't also drive the game. |
| 1398 | // These fire in the BUBBLE phase, AFTER the panel's own handlers have run, then |
| 1399 | // stopPropagation() keeps the event from bubbling out to document/window. (A page |
| 1400 | // listener registered on window/document in the CAPTURE phase still sees the event — |
| 1401 | // nothing in the same DOM can prevent that; detaching into the game's own frame, or |
| 1402 | // an input-isolating iframe, would be the only full fix.) |
| 1403 | for (const t of ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu', |
| 1404 | 'keydown', 'keyup', 'keypress', 'wheel', 'touchstart', 'touchend', 'pointermove', 'mousemove']) |
| 1405 | host.addEventListener(t, e => e.stopPropagation(), false); |
| 1406 | |
| 1407 | // Every [id] in the template, keyed by id: ui.panel, ui.scaleRange, ui.clkCps, … |
| 1408 | const ui = {}; |
| 1409 | root.querySelectorAll('[id]').forEach(n => { ui[n.id] = n; }); |
| 1410 | const el = (tag, cls, text) => { |
| 1411 | const n = document.createElement(tag); |
| 1412 | if (cls) n.className = cls; |
| 1413 | if (text != null) n.textContent = text; |
| 1414 | return n; |
| 1415 | }; |
| 1416 | const { panel, bar } = ui; |
| 1417 | |
| 1418 | // status line — confirms we reached the page's real context |
| 1419 | if (hasUnsafe) { ui.status.className = 'status ok'; ui.status.textContent = '✓ patching page context (unsafeWindow)'; } |
| 1420 | else { ui.status.className = 'status warn'; ui.status.textContent = '⚠ unsafeWindow not found — using this window. If nothing speeds up, the manager is sandboxing the script.'; } |
| 1421 | |
| 1422 | // toggles (the 5 hooks + turbo) |
| 1423 | const makeToggle = (text, checked, onChange) => { |
| 1424 | const lab = el('label', 'tg'); |
| 1425 | lab.innerHTML = '<input type="checkbox"><span></span>'; |
| 1426 | const inp = lab.querySelector('input'); |
| 1427 | inp.checked = checked; |
| 1428 | lab.querySelector('span').textContent = text; |
| 1429 | inp.addEventListener('change', e => onChange(e.target.checked)); |
| 1430 | ui.toggles.appendChild(lab); |
| 1431 | return { lab, inp }; |
| 1432 | }; |
| 1433 | const hookInputs = {}; |
| 1434 | for (const name of Object.keys(hooks)) { |
| 1435 | hookInputs[name] = makeToggle(hooks[name].label, state[name], (on) => { |
| 1436 | setHook(name, on); |
| 1437 | if (name === 'raf') updateTurboEnabled(); |
| 1438 | broadcastSettings(); |
| 1439 | }).inp; |
| 1440 | } |
| 1441 | const turboTg = makeToggle('rAF turbo — multi-step (experimental)', turbo, (on) => { turbo = on; turboT = null; broadcastSettings(); }); |
| 1442 | const turboInput = turboTg.inp; |
| 1443 | |
| 1444 | // turbo only does anything while the rAF hook is installed |
| 1445 | function updateTurboEnabled() { |
| 1446 | const on = !!state.raf; |
| 1447 | turboInput.disabled = !on; |
| 1448 | turboTg.lab.classList.toggle('disabled', !on); |
| 1449 | } |
| 1450 | updateTurboEnabled(); |
| 1451 | |
| 1452 | // scale |
| 1453 | function reflect(v) { ui.scaleOut.textContent = v + '×'; ui.badge.textContent = v + '×'; } |
| 1454 | const MAX_SCALE = 1000, SLIDER_MAX = 100, SLIDER_MIN = 0.1; |
| 1455 | const clampSlider = (v) => Math.min(SLIDER_MAX, Math.max(SLIDER_MIN, v)); |
| 1456 | // writeNum=false while the user is typing into the number field, so we don't |
| 1457 | // clobber the caret / intermediate input — that field is normalized on commit. |
| 1458 | function onScale(v, writeNum) { |
| 1459 | v = Number(v); if (!isFinite(v) || v <= 0) return; |
| 1460 | if (v > MAX_SCALE) v = MAX_SCALE; // hard cap, incl. typed-in numbers |
| 1461 | applyScale(v); |
| 1462 | ui.scaleRange.value = clampSlider(v); |
| 1463 | if (writeNum !== false) ui.scaleNum.value = v; |
| 1464 | reflect(v); |
| 1465 | broadcastSettings(); |
| 1466 | } |
| 1467 | ui.scaleRange.addEventListener('input', e => onScale(e.target.value)); |
| 1468 | ui.scaleNum.addEventListener('input', e => onScale(e.target.value, false)); |
| 1469 | ui.scaleNum.addEventListener('change', e => onScale(e.target.value)); // normalize + cap on commit |
| 1470 | root.querySelectorAll('.presets button').forEach(b => { |
| 1471 | b.addEventListener('click', () => onScale(b.dataset.s)); |
| 1472 | }); |
| 1473 | |
| 1474 | // pull controls back in line with current state (used when settings arrive remotely) |
| 1475 | function sync() { |
| 1476 | for (const n of Object.keys(hookInputs)) hookInputs[n].checked = !!state[n]; |
| 1477 | turboInput.checked = turbo; |
| 1478 | updateTurboEnabled(); |
| 1479 | ui.scaleRange.value = clampSlider(scale); |
| 1480 | ui.scaleNum.value = scale; |
| 1481 | reflect(scale); |
| 1482 | } |
| 1483 | |
| 1484 | // frame list (host mode) — one row per child frame that has announced itself |
| 1485 | function refreshFrames() { |
| 1486 | refreshScanTargets(); |
| 1487 | if (curMode !== 'host') { ui.framesBox.hidden = true; return; } |
| 1488 | pruneFrames(); |
| 1489 | ui.frameList.textContent = ''; |
| 1490 | ui.framesBox.hidden = frames.size === 0; |
| 1491 | frames.forEach((f, src) => { |
| 1492 | const row = el('div', 'frow'); |
| 1493 | const label = el('span', 'furl', shortUrl(f.url)); label.title = f.url; |
| 1494 | const btn = el('button', '', f.attached ? 'Detach' : 'Re-attach'); |
| 1495 | btn.addEventListener('click', () => { f.attached ? detachFrame(src) : reattachFrame(src); }); |
| 1496 | row.append(label, btn); |
| 1497 | ui.frameList.appendChild(row); |
| 1498 | }); |
| 1499 | } |
| 1500 | |
| 1501 | ui.reattach.addEventListener('click', () => { |
| 1502 | if (hostWin) postTo(hostWin, { type: 'reattach' }); |
| 1503 | attached = true; |
| 1504 | destroyPanel(); // back to headless; host will resend settings |
| 1505 | }); |
| 1506 | |
| 1507 | // tabs |
| 1508 | const panes = {}; |
| 1509 | root.querySelectorAll('.pane').forEach(p => { panes[p.dataset.pane] = p; }); |
| 1510 | const tabBtns = root.querySelectorAll('.tab'); |
| 1511 | function setTab(name) { |
| 1512 | tabBtns.forEach(b => b.classList.toggle('active', b.dataset.tab === name)); |
| 1513 | for (const n of Object.keys(panes)) panes[n].hidden = (n !== name); |
| 1514 | } |
| 1515 | tabBtns.forEach(b => b.addEventListener('click', () => setTab(b.dataset.tab))); |
| 1516 | |
| 1517 | /* ----- clicker controls ----- */ |
| 1518 | const clkModeBtns = root.querySelectorAll('#clkMode button'); |
| 1519 | |
| 1520 | function keyLabel(hk) { |
| 1521 | if (!hk) return 'not set'; |
| 1522 | return (hk.ctrl ? 'Ctrl+' : '') + (hk.alt ? 'Alt+' : '') + (hk.shift ? 'Shift+' : '') + (hk.meta ? 'Meta+' : '') + |
| 1523 | (hk.key === ' ' ? 'Space' : hk.key); |
| 1524 | } |
| 1525 | function syncClicker() { |
| 1526 | clkModeBtns.forEach(b => b.classList.toggle('on', b.dataset.mode === clicker.mode)); |
| 1527 | ui.clkKey.textContent = clicker.listening ? 'press a key…' : keyLabel(clicker.hotkey); |
| 1528 | ui.clkKey.classList.toggle('listening', clicker.listening); |
| 1529 | ui.clkSwallow.checked = clicker.swallowHotkey; |
| 1530 | // root.activeElement (not document.activeElement) sees focus *inside* the shadow root, |
| 1531 | // so we don't overwrite a field the user is currently typing into. |
| 1532 | if (root.activeElement !== ui.clkCps) ui.clkCps.value = clicker.cps; |
| 1533 | if (root.activeElement !== ui.clkJitter) ui.clkJitter.value = clicker.jitterMs; |
| 1534 | if (root.activeElement !== ui.clkHold) ui.clkHold.value = clicker.holdMs; |
| 1535 | ui.clkStatus.className = 'status ' + (clicker.running ? 'run' : 'idle'); |
| 1536 | ui.clkStatus.textContent = clicker.running |
| 1537 | ? '● clicking — ' + Math.round(clicker.cps) + '/s at cursor' |
| 1538 | : (clicker.hotkey ? '○ idle — press ' + keyLabel(clicker.hotkey) + ' to ' + (clicker.mode === 'hold' ? 'hold' : 'toggle') |
| 1539 | : '○ idle — set a hotkey to start'); |
| 1540 | } |
| 1541 | clkModeBtns.forEach(b => { |
| 1542 | b.addEventListener('click', () => { |
| 1543 | clicker.mode = b.dataset.mode; |
| 1544 | if (clicker.running) setClickerRunning(false, true); // mode switch is a clean stop |
| 1545 | syncClicker(); broadcastClickerConfig(); |
| 1546 | }); |
| 1547 | }); |
| 1548 | ui.clkSet.addEventListener('click', () => { clicker.listening = true; syncClicker(); }); |
| 1549 | ui.clkClear.addEventListener('click', () => { |
| 1550 | if (clicker.running) setClickerRunning(false, true); |
| 1551 | clicker.hotkey = null; clicker.listening = false; syncClicker(); broadcastClickerConfig(); |
| 1552 | }); |
| 1553 | ui.clkSwallow.addEventListener('change', e => { clicker.swallowHotkey = e.target.checked; broadcastClickerConfig(); }); |
| 1554 | // Each numeric field commits live on input (clamped) and snaps back to the |
| 1555 | // accepted value on change (blur/Enter). |
| 1556 | for (const [input, key, min, max] of [[ui.clkCps, 'cps', 0.1, MAX_CPS], [ui.clkJitter, 'jitterMs', 0, 2000], [ui.clkHold, 'holdMs', 0, 2000]]) { |
| 1557 | input.addEventListener('input', () => { |
| 1558 | const v = Number(input.value); |
| 1559 | if (!isFinite(v)) return; |
| 1560 | clicker[key] = Math.min(max, Math.max(min, v)); |
| 1561 | broadcastClickerConfig(); |
| 1562 | }); |
| 1563 | input.addEventListener('change', () => { input.value = clicker[key]; }); |
| 1564 | } |
| 1565 | syncClicker(); |
| 1566 | |
| 1567 | /* ----- scan controls ----- */ |
| 1568 | const scModeInputs = root.querySelectorAll('#scMode input'); |
| 1569 | const getScMode = () => [...scModeInputs].find(r => r.checked)?.value || 'exact'; |
| 1570 | const scEngineBtns = root.querySelectorAll('#scEngine button'); |
| 1571 | const scRefineBtns = root.querySelectorAll('#scRefine button'); |
| 1572 | |
| 1573 | let scEngineName = 'wasm'; |
| 1574 | let scTargetId = null; // null → this frame; else a remote frame's SELF_ID |
| 1575 | let scTargetWin = null; // that frame's window (proven postMessage channel) |
| 1576 | const scTargetList = []; // [{ id, win }] parallel to scTarget options (after the first) |
| 1577 | let savedScans = store.get('scan:' + PAGE, []) || []; |
| 1578 | let scanActive = false; // a candidate set exists (refine available) |
| 1579 | let scanRunning = false; // a scan op is in flight |
| 1580 | let pausedLocalView = false; |
| 1581 | |
| 1582 | function scCmd(extra, onProgress) { |
| 1583 | return sendScan(scTargetId, scTargetWin, { engine: scEngineName, type: ui.scType.value, ...extra }, onProgress); |
| 1584 | } |
| 1585 | |
| 1586 | // Two independent status lines so they never clobber each other: |
| 1587 | // - setConn(): frame / WASM-memory / pause state (top, #scStatus) |
| 1588 | // - setScan(): scan progress + result counts (#scCount) |
| 1589 | function setConn(kind, text) { ui.scStatus.className = 'status ' + kind; ui.scStatus.textContent = text; } |
| 1590 | function setScan(text) { ui.scCount.textContent = text; } |
| 1591 | |
| 1592 | function refreshScanTargets() { |
| 1593 | const prev = ui.scTarget.value; |
| 1594 | scTargetList.length = 0; |
| 1595 | ui.scTarget.textContent = ''; |
| 1596 | const self = el('option', '', 'This frame'); self.value = 'self'; |
| 1597 | ui.scTarget.appendChild(self); |
| 1598 | // `frames` is only populated in host mode; a detached child just sees itself. |
| 1599 | frames.forEach((f, src) => { |
| 1600 | if (!f.id) return; |
| 1601 | const idx = scTargetList.push({ id: f.id, win: src }) - 1; |
| 1602 | const opt = el('option', '', shortUrl(f.url)); |
| 1603 | opt.value = 'f' + idx; opt.title = f.url; |
| 1604 | ui.scTarget.appendChild(opt); |
| 1605 | }); |
| 1606 | ui.scTarget.value = [...ui.scTarget.options].some(o => o.value === prev) ? prev : 'self'; |
| 1607 | applyTargetSelection(); |
| 1608 | } |
| 1609 | function applyTargetSelection() { |
| 1610 | const v = ui.scTarget.value; |
| 1611 | const ent = (v === 'self') ? null : scTargetList[+v.slice(1)]; |
| 1612 | scTargetId = ent ? ent.id : null; |
| 1613 | scTargetWin = ent ? ent.win : null; |
| 1614 | } |
| 1615 | |
| 1616 | // Reflect scanActive / scanRunning onto the buttons. |
| 1617 | function updateButtons() { |
| 1618 | ui.scFirst.textContent = scanActive ? 'New scan' : 'First scan'; |
| 1619 | ui.scFirst.disabled = scanRunning; |
| 1620 | // Scan mode (Known value / Unknown initial) is locked once a scan exists — it only |
| 1621 | // applies to starting a NEW scan. Start a new scan to change it. |
| 1622 | scModeInputs.forEach(r => { r.disabled = scanRunning || scanActive; }); |
| 1623 | ui.scCancel.hidden = !scanRunning; |
| 1624 | ui.scRefine.hidden = !scanActive; |
| 1625 | scRefineBtns.forEach(b => { b.disabled = scanRunning || !scanActive; }); |
| 1626 | ui.scType.disabled = scanRunning; |
| 1627 | // The value box stays editable during refine (so "Exact" refine can take a new value); |
| 1628 | // it's disabled only while a scan runs, or for a fresh "Unknown initial" scan (no value). |
| 1629 | ui.scValue.disabled = scanRunning || (!scanActive && getScMode() === 'unknown'); |
| 1630 | ui.scMemDetect.disabled = scanRunning; |
| 1631 | } |
| 1632 | |
| 1633 | function setEngine(name) { |
| 1634 | scEngineName = name; |
| 1635 | scEngineBtns.forEach(b => b.classList.toggle('on', b.dataset.engine === name)); |
| 1636 | const wasm = (name === 'wasm'); |
| 1637 | ui.scMemDetect.hidden = !wasm; |
| 1638 | ui.scMem.hidden = true; |
| 1639 | newScan(); |
| 1640 | if (wasm) { refreshMemList(); return; } |
| 1641 | // object-graph engine: confirm a remote frame is reachable (same short |
| 1642 | // connectivity timer as WASM detect; not a scan timeout). |
| 1643 | if (!scTargetId) { setConn('idle', 'Walking values reachable from this frame’s window.'); return; } |
| 1644 | const id = scTargetId; let settled = false; |
| 1645 | setConn('idle', 'Connecting to frame…'); |
| 1646 | origSetTimeout(() => { |
| 1647 | if (settled || scTargetId !== id || scEngineName !== 'object') return; |
| 1648 | settled = true; setConn('warn', 'No response from that frame. If it just loaded, re-select it; otherwise open the frame’s own panel and scan there.'); |
| 1649 | }, 2500); |
| 1650 | scCmd({ op: 'ping' }).then((res) => { |
| 1651 | if (settled || scTargetId !== id || scEngineName !== 'object') return; |
| 1652 | settled = true; setConn(res?.ok ? 'ok' : 'warn', res?.ok ? 'Frame connected — walking values reachable from its window.' : 'Frame not responding.'); |
| 1653 | }); |
| 1654 | } |
| 1655 | |
| 1656 | // Manual WASM-memory detection (no polling). Updates the memory dropdown + status. |
| 1657 | // For a remote target this doubles as the connectivity check: a short real-time |
| 1658 | // timer (NOT a scan timeout — scans still wait indefinitely) flips the status to |
| 1659 | // "not responding" if the frame never answers, instead of hanging on "Detecting…". |
| 1660 | function refreshMemList() { |
| 1661 | if (scEngineName !== 'wasm') return; |
| 1662 | const id = scTargetId; let settled = false; |
| 1663 | setConn('idle', 'Detecting WASM memory…'); |
| 1664 | if (id) origSetTimeout(() => { |
| 1665 | if (settled || scTargetId !== id || scEngineName !== 'wasm') return; |
| 1666 | settled = true; ui.scMem.hidden = true; |
| 1667 | setConn('warn', 'No response from that frame. If the game is still loading, click “Detect” again; otherwise open the frame’s own panel and scan there.'); |
| 1668 | }, 2500); |
| 1669 | scCmd({ op: 'list-memories' }).then((res) => { |
| 1670 | if (settled || scEngineName !== 'wasm' || scTargetId !== id) return; // stale/superseded |
| 1671 | settled = true; |
| 1672 | if (!res || res.ok === false) { ui.scMem.hidden = true; setConn('warn', id ? 'Target frame not responding — try detaching its panel and scanning there.' : 'Frame not responding.'); return; } |
| 1673 | const mems = res.memories || []; |
| 1674 | const prev = ui.scMem.value; |
| 1675 | ui.scMem.textContent = ''; |
| 1676 | mems.forEach((m, i) => { const o = el('option', '', m.label); o.value = String(i); ui.scMem.appendChild(o); }); |
| 1677 | if (prev && mems[+prev]) ui.scMem.value = prev; |
| 1678 | ui.scMem.hidden = mems.length === 0; |
| 1679 | ui.scMemDetect.textContent = mems.length ? '↻ Re-detect WASM memory' : '↻ Detect WASM memory'; |
| 1680 | if (mems.length === 0) setConn('warn', 'No WASM memory' + (id ? ' in that frame' : '') + ' yet. If the game is still loading, click “Detect” again.'); |
| 1681 | else setConn('ok', mems.length + ' WASM memor' + (mems.length === 1 ? 'y' : 'ies') + ' found.'); |
| 1682 | }); |
| 1683 | } |
| 1684 | |
| 1685 | function newScan() { |
| 1686 | scanActive = false; |
| 1687 | ui.scResults.textContent = ''; |
| 1688 | setScan('No scan yet.'); |
| 1689 | showProgress(false); |
| 1690 | updateButtons(); |
| 1691 | scCmd({ op: 'reset' }); |
| 1692 | } |
| 1693 | |
| 1694 | function setScanRunning(on) { scanRunning = on; updateButtons(); } |
| 1695 | |
| 1696 | function onScanDone(res) { |
| 1697 | setScanRunning(false); |
| 1698 | if (!res || !res.ok) { setScan('Scan failed: ' + (res?.error || 'unknown')); return; } |
| 1699 | if (res.cancelled) { setScan('Scan cancelled.'); updateButtons(); return; } |
| 1700 | scanActive = true; |
| 1701 | updateButtons(); |
| 1702 | if (res.count === -1) { |
| 1703 | setScan('Unknown scan armed — change the value in-game, then refine (Up / Down / Changed).'); |
| 1704 | } else if (res.capped) { |
| 1705 | setScan(res.count.toLocaleString() + ' matches — too many to list; refine to narrow.'); |
| 1706 | } else { |
| 1707 | setScan(res.count.toLocaleString() + ' match' + (res.count === 1 ? '' : 'es') + (res.count > 200 ? ' (showing first 200)' : '')); |
| 1708 | } |
| 1709 | renderRows(res.rows || []); |
| 1710 | } |
| 1711 | |
| 1712 | function startScan(cmd, progress) { |
| 1713 | if (scanRunning) return; |
| 1714 | setScanRunning(true); |
| 1715 | setScan(progress); |
| 1716 | showProgress(true); |
| 1717 | scCmd(cmd, onScanProgress).then((res) => { showProgress(false); onScanDone(res); }); |
| 1718 | } |
| 1719 | // Progress bar: starts indeterminate (animated stripe); flips to a determinate fill the |
| 1720 | // first time the engine reports a real fraction (frac >= 0). frac < 0 stays indeterminate |
| 1721 | // (the object-graph walk, whose total isn't known up front). |
| 1722 | function showProgress(on) { |
| 1723 | ui.scProgress.hidden = !on; |
| 1724 | if (on) { ui.scBar.classList.add('indet'); ui.scBar.style.width = '0%'; } |
| 1725 | } |
| 1726 | function onScanProgress(frac) { |
| 1727 | if (typeof frac !== 'number' || frac < 0) return; |
| 1728 | ui.scBar.classList.remove('indet'); |
| 1729 | ui.scBar.style.width = Math.max(0, Math.min(1, frac)) * 100 + '%'; |
| 1730 | } |
| 1731 | |
| 1732 | function renderRows(rows) { |
| 1733 | ui.scResults.textContent = ''; |
| 1734 | for (const r of rows) { |
| 1735 | const row = el('div', 'sc-row'); row.dataset.addr = r.address; |
| 1736 | const addr = el('span', 'sc-addr', scanAddressLabel(scEngineName, r.address)); addr.title = addr.textContent; |
| 1737 | const val = el('input', 'sc-val'); val.value = (r.value == null ? '?' : r.value); |
| 1738 | val.addEventListener('change', () => scCmd({ op: 'write', address: r.address, value: val.value })); |
| 1739 | const save = el('button', 'sc-mini', '★'); save.title = 'Save this result'; |
| 1740 | save.addEventListener('click', () => addSaved(r.address, r.type)); |
| 1741 | row.append(addr, val, save); |
| 1742 | ui.scResults.appendChild(row); |
| 1743 | } |
| 1744 | } |
| 1745 | |
| 1746 | // live re-read of shown result rows + saved rows (real-time; unaffected by pause) |
| 1747 | const scPane = panes.scan; |
| 1748 | function pollValues() { |
| 1749 | // Only poll while the Scan tab is actually visible (not tab-hidden, not minimized). |
| 1750 | if ((scPane && scPane.hidden) || panel.classList.contains('min')) return; |
| 1751 | const rowEls = [...ui.scResults.querySelectorAll('.sc-row')]; |
| 1752 | const addrs = rowEls.map(e2 => e2.dataset.addr); |
| 1753 | if (addrs.length) { |
| 1754 | scCmd({ op: 'read', addresses: addrs }).then((res) => { |
| 1755 | if (!res || !res.values) return; |
| 1756 | const byAddr = new Map(res.values.map(v => [v.address, v.value])); |
| 1757 | for (const rowEl of rowEls) { |
| 1758 | const inp = rowEl.querySelector('.sc-val'); |
| 1759 | const v = byAddr.get(rowEl.dataset.addr); |
| 1760 | if (inp && root.activeElement !== inp) inp.value = (v == null ? '?' : v); |
| 1761 | } |
| 1762 | }); |
| 1763 | } |
| 1764 | pollSaved(); |
| 1765 | } |
| 1766 | |
| 1767 | /* ----- saved list ----- */ |
| 1768 | function persistSaved() { store.set('scan:' + PAGE, savedScans); } |
| 1769 | function addSaved(address, ty) { |
| 1770 | savedScans.push({ name: scanAddressLabel(scEngineName, address), engine: scEngineName, type: (ty || ui.scType.value), address }); |
| 1771 | persistSaved(); renderSaved(); |
| 1772 | } |
| 1773 | function renderSaved() { |
| 1774 | ui.scSaved.textContent = ''; |
| 1775 | savedScans.forEach((s, i) => { |
| 1776 | const row = el('div', 'sc-srow'); row.dataset.idx = i; |
| 1777 | const name = el('input', 'sc-name'); name.value = s.name; |
| 1778 | name.title = scanAddressLabel(s.engine, s.address) + ' (' + s.type + ')'; |
| 1779 | name.addEventListener('change', () => { s.name = name.value; persistSaved(); }); |
| 1780 | const val = el('input', 'sc-val'); val.value = '?'; |
| 1781 | val.addEventListener('change', () => sendScan(scTargetId, scTargetWin, { engine: s.engine, type: s.type, op: 'write', address: s.address, value: val.value })); |
| 1782 | const del = el('button', 'sc-mini', '×'); del.title = 'Delete'; |
| 1783 | del.addEventListener('click', () => { savedScans.splice(i, 1); persistSaved(); renderSaved(); }); |
| 1784 | row.append(name, val, del); |
| 1785 | ui.scSaved.appendChild(row); |
| 1786 | }); |
| 1787 | } |
| 1788 | // One batched `read` per (engine, type) group instead of a postMessage round-trip |
| 1789 | // per saved row every poll tick. |
| 1790 | function pollSaved() { |
| 1791 | const groups = new Map(); // "engine type" -> { engine, type, addrs, els } |
| 1792 | for (const rowEl of ui.scSaved.querySelectorAll('.sc-srow')) { |
| 1793 | const s = savedScans[+rowEl.dataset.idx]; if (!s) continue; |
| 1794 | const key = s.engine + ' ' + s.type; |
| 1795 | let g = groups.get(key); |
| 1796 | if (!g) { g = { engine: s.engine, type: s.type, addrs: [], els: [] }; groups.set(key, g); } |
| 1797 | g.addrs.push(s.address); g.els.push(rowEl); |
| 1798 | } |
| 1799 | groups.forEach((g) => { |
| 1800 | sendScan(scTargetId, scTargetWin, { engine: g.engine, type: g.type, op: 'read', addresses: g.addrs }).then((res) => { |
| 1801 | const byAddr = new Map((res?.values || []).map(v => [v.address, v.value])); |
| 1802 | g.els.forEach((rowEl, i) => { |
| 1803 | const inp = rowEl.querySelector('.sc-val'); if (!inp || root.activeElement === inp) return; |
| 1804 | const v = byAddr.get(g.addrs[i]); |
| 1805 | inp.value = (v == null ? '∅' : v); |
| 1806 | rowEl.querySelector('.sc-name').classList.toggle('stale', v == null); |
| 1807 | }); |
| 1808 | }); |
| 1809 | }); |
| 1810 | } |
| 1811 | |
| 1812 | // wire scan controls |
| 1813 | scEngineBtns.forEach(b => b.addEventListener('click', () => setEngine(b.dataset.engine))); |
| 1814 | ui.scTarget.addEventListener('change', () => { applyTargetSelection(); setEngine(scEngineName); }); |
| 1815 | ui.scMem.addEventListener('change', () => { scCmd({ op: 'set-mem', mem: +ui.scMem.value }).then(newScan); }); |
| 1816 | ui.scMemDetect.addEventListener('click', refreshMemList); |
| 1817 | ui.scType.addEventListener('change', () => { ui.scType.title = ui.scType.options[ui.scType.selectedIndex]?.title || ''; }); |
| 1818 | scModeInputs.forEach(r => r.addEventListener('change', updateButtons)); |
| 1819 | ui.scFirst.addEventListener('click', () => { |
| 1820 | if (scanActive) { newScan(); return; } // acts as "New scan" once a scan exists |
| 1821 | if (getScMode() === 'unknown') { startScan({ op: 'first-unknown' }, 'Snapshotting…'); return; } |
| 1822 | const raw = ui.scValue.value.trim(); |
| 1823 | if (raw === '') { setScan('Enter a value, or choose “Unknown initial value”.'); return; } |
| 1824 | startScan({ op: 'first-exact', value: raw }, 'Scanning…'); |
| 1825 | }); |
| 1826 | ui.scCancel.addEventListener('click', () => { setScan('Cancelling…'); scCmd({ op: 'cancel' }); }); |
| 1827 | scRefineBtns.forEach(b => { |
| 1828 | b.addEventListener('click', () => { |
| 1829 | const crit = b.dataset.crit, raw = ui.scValue.value.trim(); |
| 1830 | startScan({ op: 'refine', criteria: crit, value: (crit === 'exact' && raw !== '') ? raw : undefined }, 'Refining…'); |
| 1831 | }); |
| 1832 | }); |
| 1833 | ui.scPause.addEventListener('click', () => { |
| 1834 | scCmd({ op: 'set-paused', value: !pausedLocalView }).then((res) => { |
| 1835 | pausedLocalView = !!res?.paused; |
| 1836 | reflectPause(); |
| 1837 | }); |
| 1838 | }); |
| 1839 | function reflectPause() { |
| 1840 | ui.scPause.textContent = pausedLocalView ? '▶ Resume' : '⏸ Pause'; |
| 1841 | ui.scPause.classList.toggle('on', pausedLocalView); |
| 1842 | } |
| 1843 | |
| 1844 | ui.scType.title = ui.scType.options[ui.scType.selectedIndex]?.title || ''; |
| 1845 | renderSaved(); |
| 1846 | setEngine('wasm'); |
| 1847 | reflectPause(); |
| 1848 | const scPoll = origSetInterval(pollValues, 600); // real-time; unaffected by speed/pause |
| 1849 | |
| 1850 | let curMode; |
| 1851 | function setMode(m) { |
| 1852 | curMode = m; |
| 1853 | if (m === 'host') { ui.title.textContent = 'Speedhack'; ui.reattach.hidden = true; refreshFrames(); } |
| 1854 | else { ui.title.textContent = 'Speedhack (frame)'; ui.framesBox.hidden = true; ui.reattach.hidden = false; } |
| 1855 | } |
| 1856 | setMode(mode || 'host'); |
| 1857 | |
| 1858 | // minimize (start minimized) |
| 1859 | function setMin(m) { panel.classList.toggle('min', m); ui.min.textContent = m ? '▢' : '–'; } |
| 1860 | setMin(true); |
| 1861 | ui.min.addEventListener('click', (e) => { e.stopPropagation(); setMin(!panel.classList.contains('min')); }); |
| 1862 | |
| 1863 | // close + remember dialog |
| 1864 | ui.close.addEventListener('click', (e) => { e.stopPropagation(); ui.dlgurl.textContent = PAGE; ui.dlg.hidden = false; }); |
| 1865 | ui.dlgCancel.addEventListener('click', () => { ui.dlg.hidden = true; }); |
| 1866 | ui.dlgNo.addEventListener('click', () => doClose(false)); |
| 1867 | ui.dlgYes.addEventListener('click', () => doClose(true)); |
| 1868 | function doClose(remember) { |
| 1869 | if (remember) store.set(CLOSED_KEY, true); |
| 1870 | for (const n of Object.keys(hooks)) { try { hooks[n].uninstall(); } catch (e) {} } |
| 1871 | // The host is going away, so there's nothing left to re-attach to. Tell EVERY |
| 1872 | // frame (attached or already-detached) to become its own standalone main panel |
| 1873 | // rather than a detached one with a dead "re-attach" button. `hostClosed` also |
| 1874 | // makes us hand off any iframe that announces itself AFTER this point. |
| 1875 | if (isHost) { |
| 1876 | hostClosed = true; |
| 1877 | frames.forEach((f, src) => { f.attached = false; postTo(src, { type: 'host-closing' }); }); |
| 1878 | } |
| 1879 | destroyPanel(); |
| 1880 | } |
| 1881 | |
| 1882 | // drag (and tap-to-expand when minimized) |
| 1883 | let dragging = false, moved = false, sx, sy, ox, oy; |
| 1884 | bar.addEventListener('pointerdown', (e) => { |
| 1885 | if (e.target.closest('button')) return; |
| 1886 | dragging = true; moved = false; |
| 1887 | const r = panel.getBoundingClientRect(); |
| 1888 | panel.style.left = r.left + 'px'; panel.style.top = r.top + 'px'; |
| 1889 | panel.style.right = 'auto'; panel.style.bottom = 'auto'; |
| 1890 | sx = e.clientX; sy = e.clientY; ox = r.left; oy = r.top; |
| 1891 | try { bar.setPointerCapture(e.pointerId); } catch (e2) {} |
| 1892 | }); |
| 1893 | bar.addEventListener('pointermove', (e) => { |
| 1894 | if (!dragging) return; |
| 1895 | const dx = e.clientX - sx, dy = e.clientY - sy; |
| 1896 | if (Math.abs(dx) > 4 || Math.abs(dy) > 4) moved = true; |
| 1897 | panel.style.left = Math.max(0, Math.min(window.innerWidth - 30, ox + dx)) + 'px'; |
| 1898 | panel.style.top = Math.max(0, Math.min(window.innerHeight - 20, oy + dy)) + 'px'; |
| 1899 | }); |
| 1900 | bar.addEventListener('pointerup', (e) => { |
| 1901 | if (!dragging) return; dragging = false; |
| 1902 | try { bar.releasePointerCapture(e.pointerId); } catch (e2) {} |
| 1903 | if (!moved && panel.classList.contains('min')) setMin(false); |
| 1904 | }); |
| 1905 | |
| 1906 | return { |
| 1907 | removeNode() { |
| 1908 | try { origClearInterval(scPoll); } catch (e) {} |
| 1909 | try { host.remove(); } catch (e) {} |
| 1910 | if (panelHost === host) panelHost = null; |
| 1911 | clicker.listening = false; |
| 1912 | }, |
| 1913 | refreshFrames, |
| 1914 | refreshScan: refreshScanTargets, |
| 1915 | setMode, |
| 1916 | sync, |
| 1917 | syncClicker |
| 1918 | }; |
| 1919 | } |
| 1920 | |
| 1921 | /* ------------------------------------------------------------------ * |
| 1922 | * Panel lifecycle — build lazily once a host node exists; rebuildable |
| 1923 | * (a child may gain/lose a panel as it detaches / re-attaches). |
| 1924 | * ------------------------------------------------------------------ */ |
| 1925 | let panelCtl = null; |
| 1926 | let pendingMode = 'host'; |
| 1927 | function whenBody(fn) { |
| 1928 | if (document.body) { fn(); return; } |
| 1929 | const obs = new MutationObserver(() => { if (document.body) { obs.disconnect(); fn(); } }); |
| 1930 | try { obs.observe(document.documentElement, { childList: true, subtree: true }); } catch (e) {} |
| 1931 | document.addEventListener('DOMContentLoaded', () => { try { obs.disconnect(); } catch (e) {} fn(); }, { once: true }); |
| 1932 | } |
| 1933 | function ensurePanel(mode) { |
| 1934 | pendingMode = mode; |
| 1935 | if (panelCtl) { panelCtl.setMode(mode); return; } |
| 1936 | whenBody(() => { |
| 1937 | if (panelCtl) { panelCtl.setMode(pendingMode); return; } |
| 1938 | if (!(document.body || document.documentElement)) return; |
| 1939 | panelCtl = buildUI(pendingMode); |
| 1940 | }); |
| 1941 | } |
| 1942 | function destroyPanel() { |
| 1943 | if (panelCtl) { panelCtl.removeNode(); panelCtl = null; } |
| 1944 | } |
| 1945 | |
| 1946 | /* ------------------------------------------------------------------ * |
| 1947 | * Boot. Top frame hosts and rolls call; a child announces itself and |
| 1948 | * arms a real-time 5s fallback to promote if no host ever answers. |
| 1949 | * ------------------------------------------------------------------ */ |
| 1950 | if (isTop) { |
| 1951 | ensurePanel('host'); |
| 1952 | rollcall(); |
| 1953 | } else { |
| 1954 | postTo(window.top, { type: 'hello', url: location.href, frameId: SELF_ID }); |
| 1955 | // origSetTimeout (real time) so scaling can't distort the 5s fallback window. |
| 1956 | origSetTimeout(() => { if (!gotHost) promoteToHost(); }, 5000); |
| 1957 | } |
| 1958 | })(); |
| 1959 |