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