speedhack.js
Raw
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 let lastFrac = -2; // throttle determinate progress to ~2% steps (indeterminate always relayed)
540 runScanCommand(scmd, function (frac) {
541 if (frac >= 0 && frac !== 1 && frac - lastFrac < 0.02) return;
542 lastFrac = frac;
543 try { if (replyWin) postTo(replyWin, { type: 'scan-progress', reqId: m.reqId, targetFrame: m.from, frac: frac }); } catch (e2) {}
544 }).then(function (res) {
545 res.type = 'scan-result'; res.reqId = m.reqId; res.targetFrame = m.from;
546 try { if (replyWin) postTo(replyWin, res); } catch (e2) {} // reply to the sender directly...
547 broadcastScanMsg(res); // ...and via window.top (reliable upward)
548 });
549 break;
550 }
551 case 'scan-progress': { // controller: forward incremental progress to the UI
552 if (m.targetFrame && m.targetFrame !== SELF_ID) break;
553 const cb = scanProgress.get(m.reqId);
554 if (cb) cb(m.frac);
555 break;
556 }
557 case 'scan-result': { // controller: resolve the matching pending request
558 if (m.targetFrame && m.targetFrame !== SELF_ID) break;
559 scanProgress.delete(m.reqId);
560 const resolve = scanPending.get(m.reqId);
561 if (resolve) { scanPending.delete(m.reqId); resolve(m); }
562 break;
563 }
564 }
565 });
566
567 /* ------------------------------------------------------------------ *
568 * Autoclicker. The engine runs in EVERY frame; the panel frame owns
569 * the UI and broadcasts config + the shared running flag. A frame only
570 * dispatches while the cursor is directly inside it, so clicks follow
571 * the cursor across (i)frames with no detaching. Real-time timers keep
572 * the cadence independent of the speed scale.
573 * ------------------------------------------------------------------ */
574 const clicker = {
575 mode: 'toggle', // 'toggle' | 'hold'
576 hotkey: null, // { key, ctrl, alt, shift, meta } | null
577 swallowHotkey: false, // preventDefault/stopPropagation the hotkey so the page can't see it
578 cps: 10, // base clicks per second
579 jitterMs: 0, // random 0..jitterMs added to each gap
580 holdMs: 20, // mousedown→mouseup duration per click
581 running: false,
582 listening: false, // panel frame only: capturing the next key as the hotkey
583 lastX: 0, lastY: 0, // last cursor position in THIS frame (clientX/Y)
584 enteredDoc: false, // cursor currently within this frame's viewport
585 overChildFrame: false, // cursor currently over a nested <iframe>/<frame>
586 timer: 0, upTimer: 0
587 };
588 let panelHost = null; // the shadow-DOM host element of this frame's panel, if any
589 const MAX_CPS = 100;
590 const ctxDoc = pageWin.document || document;
591
592 function cursorInside() { return clicker.enteredDoc && !clicker.overChildFrame; }
593 function trackPointer(e) {
594 clicker.lastX = e.clientX; clicker.lastY = e.clientY;
595 clicker.enteredDoc = true;
596 const t = e.target, tag = t && t.tagName;
597 clicker.overChildFrame = (tag === 'IFRAME' || tag === 'FRAME');
598 }
599 try {
600 ctxDoc.addEventListener('pointermove', trackPointer, true);
601 ctxDoc.addEventListener('mousemove', trackPointer, true); // fallback where PointerEvents are absent
602 ctxDoc.addEventListener('mouseover', trackPointer, true); // updates overChildFrame even without movement
603 // relatedTarget == null on mouseout means the cursor left the window entirely.
604 ctxDoc.addEventListener('mouseout', function (e) { if (!e.relatedTarget) clicker.enteredDoc = false; }, true);
605 } catch (e) {}
606
607 function fireClick() {
608 const x = clicker.lastX, y = clicker.lastY;
609 const el = ctxDoc.elementFromPoint ? ctxDoc.elementFromPoint(x, y) : null;
610 if (!el) return;
611 const base = { bubbles: true, cancelable: true, composed: true, view: pageWin, clientX: x, clientY: y, button: 0 };
612 function dispatch(type, buttons, pointer) {
613 const opts = Object.assign({}, base, { buttons: buttons });
614 let ev;
615 if (pointer && pageWin.PointerEvent) {
616 try { ev = new pageWin.PointerEvent(type, Object.assign(opts, { pointerId: 1, pointerType: 'mouse', isPrimary: true })); } catch (e) {}
617 }
618 if (!ev) { try { ev = new pageWin.MouseEvent(type, opts); } catch (e) { return; } }
619 try { el.dispatchEvent(ev); } catch (e) {}
620 }
621 dispatch('pointerdown', 1, true); dispatch('mousedown', 1, false);
622 const up = function () {
623 clicker.upTimer = 0;
624 dispatch('pointerup', 0, true); dispatch('mouseup', 0, false); dispatch('click', 0, false);
625 };
626 const gap = 1000 / Math.max(0.1, clicker.cps);
627 const hold = Math.min(Math.max(0, clicker.holdMs), Math.max(0, gap - 5)); // keep hold < gap so clicks don't overlap
628 if (hold > 0) clicker.upTimer = origSetTimeout(up, hold); else up();
629 }
630
631 function startClickerLoop() {
632 if (clicker.timer) return;
633 (function tick() {
634 clicker.timer = 0;
635 if (!clicker.running) return;
636 if (cursorInside()) { try { fireClick(); } catch (e) {} }
637 // Re-read rate/jitter every cycle so slider-style changes apply live. Real-time
638 // timer (origSetTimeout) → cadence is unaffected by the speed scale.
639 const gap = Math.max(10, 1000 / Math.max(0.1, clicker.cps) + Math.random() * Math.max(0, clicker.jitterMs));
640 clicker.timer = origSetTimeout(tick, gap);
641 })();
642 }
643 function stopClickerLoop() {
644 if (clicker.timer) { origClearTimeout(clicker.timer); clicker.timer = 0; }
645 if (clicker.upTimer) { origClearTimeout(clicker.upTimer); clicker.upTimer = 0; }
646 }
647
648 // Set the shared running flag. propagate=true → tell the host (or, if we ARE the host,
649 // fan out to every frame) so all frames share one running state.
650 function setClickerRunning(on, propagate) {
651 on = !!on;
652 if (clicker.running !== on) { clicker.running = on; on ? startClickerLoop() : stopClickerLoop(); }
653 if (panelCtl) panelCtl.syncClicker();
654 if (!propagate) return;
655 if (isHost) frames.forEach(function (f, src) { postTo(src, { type: 'clicker-run', on: on }); });
656 else if (hostWin) postTo(hostWin, { type: 'clicker-run', on: on });
657 }
658
659 function clickerConfigMsg() {
660 return { type: 'clicker-config', mode: clicker.mode, hotkey: clicker.hotkey,
661 swallowHotkey: clicker.swallowHotkey, cps: clicker.cps, jitterMs: clicker.jitterMs, holdMs: clicker.holdMs };
662 }
663 function broadcastClickerConfig() {
664 if (!isHost) return; // config flows host → all frames (not just attached)
665 pruneFrames();
666 frames.forEach(function (f, src) { postTo(src, clickerConfigMsg()); });
667 }
668 function applyClickerConfig(c) {
669 if (c.mode === 'toggle' || c.mode === 'hold') clicker.mode = c.mode;
670 if ('hotkey' in c) clicker.hotkey = c.hotkey;
671 if (typeof c.swallowHotkey === 'boolean') clicker.swallowHotkey = c.swallowHotkey;
672 if (typeof c.cps === 'number') clicker.cps = Math.min(MAX_CPS, Math.max(0.1, c.cps));
673 if (typeof c.jitterMs === 'number') clicker.jitterMs = Math.max(0, c.jitterMs);
674 if (typeof c.holdMs === 'number') clicker.holdMs = Math.max(0, c.holdMs);
675 if (panelCtl) panelCtl.syncClicker();
676 }
677
678 function hotkeyMatches(e, hk) {
679 return hk && e.key === hk.key && !!e.ctrlKey === !!hk.ctrl && !!e.altKey === !!hk.alt &&
680 !!e.shiftKey === !!hk.shift && !!e.metaKey === !!hk.meta;
681 }
682 function eventFromPanel(e) {
683 return panelHost && e.composedPath && e.composedPath().indexOf(panelHost) !== -1;
684 }
685 function onClickerKeyDown(e) {
686 if (clicker.listening) { // capturing a new binding (panel frame)
687 if (e.key === 'Control' || e.key === 'Alt' || e.key === 'Shift' || e.key === 'Meta') return; // await a real key
688 e.preventDefault(); e.stopPropagation();
689 clicker.hotkey = { key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey };
690 clicker.listening = false;
691 if (panelCtl) panelCtl.syncClicker();
692 broadcastClickerConfig();
693 return;
694 }
695 if (eventFromPanel(e)) return; // don't let typing in our own UI trigger the hotkey
696 if (!hotkeyMatches(e, clicker.hotkey)) return;
697 if (clicker.swallowHotkey) { e.preventDefault(); e.stopPropagation(); }
698 if (e.repeat) return;
699 if (clicker.mode === 'toggle') setClickerRunning(!clicker.running, true);
700 else setClickerRunning(true, true); // hold: down = on
701 }
702 function onClickerKeyUp(e) {
703 if (clicker.mode !== 'hold' || !clicker.hotkey || e.key !== clicker.hotkey.key) return;
704 if (eventFromPanel(e)) return;
705 if (clicker.swallowHotkey) { e.preventDefault(); e.stopPropagation(); }
706 setClickerRunning(false, true);
707 }
708 // These are OUR listeners and must see panel-originated keys (hotkey binding fires while
709 // the panel is focused), so exempt them from the input shield below.
710 onClickerKeyDown.__shxNoShield = true;
711 onClickerKeyUp.__shxNoShield = true;
712 try {
713 pageWin.addEventListener('keydown', onClickerKeyDown, true);
714 pageWin.addEventListener('keyup', onClickerKeyUp, true);
715 // Safeguard: in hold mode a keyup can land in a different frame than the keydown;
716 // losing focus then releasing would otherwise leave it stuck on.
717 pageWin.addEventListener('blur', function () { if (clicker.mode === 'hold' && clicker.running) setClickerRunning(false, true); }, true);
718 } catch (e) {}
719
720 /* ================================================================== *
721 * Scan engine (the "Memory Scan" tab). Browser JS has no raw process
722 * memory, so we offer two backends:
723 * - 'wasm' : scan a WebAssembly.Memory linear buffer as raw typed
724 * values at byte offsets (the truest Cheat Engine analog;
725 * works for Unity/Emscripten/Godot/C-C++ games).
726 * - 'object': walk the object graph reachable from the page window and
727 * track numeric properties by their path (for plain-JS games).
728 * The engine runs LOCALLY in each frame and owns its own candidate state;
729 * the panel drives it directly (this frame) or over postMessage (iframes).
730 * ================================================================== */
731 const SCAN_TYPES = {
732 i8: { size: 1, get: function (d, o) { return d.getInt8(o); }, set: function (d, o, v) { d.setInt8(o, v); } },
733 u8: { size: 1, get: function (d, o) { return d.getUint8(o); }, set: function (d, o, v) { d.setUint8(o, v); } },
734 i16: { size: 2, get: function (d, o) { return d.getInt16(o, true); }, set: function (d, o, v) { d.setInt16(o, v, true); } },
735 u16: { size: 2, get: function (d, o) { return d.getUint16(o, true); }, set: function (d, o, v) { d.setUint16(o, v, true); } },
736 i32: { size: 4, get: function (d, o) { return d.getInt32(o, true); }, set: function (d, o, v) { d.setInt32(o, v, true); } },
737 u32: { size: 4, get: function (d, o) { return d.getUint32(o, true); }, set: function (d, o, v) { d.setUint32(o, v >>> 0, true); } },
738 i64: { size: 8, big: true, get: function (d, o) { return d.getBigInt64(o, true); }, set: function (d, o, v) { d.setBigInt64(o, v, true); } },
739 f32: { size: 4, float: true, get: function (d, o) { return d.getFloat32(o, true); }, set: function (d, o, v) { d.setFloat32(o, v, true); } },
740 f64: { size: 8, float: true, get: function (d, o) { return d.getFloat64(o, true); }, set: function (d, o, v) { d.setFloat64(o, v, true); } }
741 };
742 const SCAN_STORE_CAP = 500000; // max candidates TRACKED locally (refine works on all of these)
743 const FLOAT_EPS = 1e-4;
744 // "Search multiple types": a type selection can be a single type or a group that
745 // expands to several. The engine scans every expanded type and tags each result
746 // with the type that matched, so refine/read/write stay correct per result.
747 const SCAN_TYPE_GROUPS = {
748 all: ['i8', 'u8', 'i16', 'u16', 'i32', 'u32', 'i64', 'f32', 'f64'],
749 allint: ['i8', 'u8', 'i16', 'u16', 'i32', 'u32', 'i64'],
750 allfloat: ['f32', 'f64']
751 };
752 function expandTypes(sel) {
753 if (SCAN_TYPE_GROUPS[sel]) return SCAN_TYPE_GROUPS[sel].slice();
754 return SCAN_TYPES[sel] ? [sel] : ['i32'];
755 }
756
757 // Parse the user's typed value into the JS form the type compares with.
758 function parseScanValue(type, raw) {
759 const t = SCAN_TYPES[type];
760 if (!t) return null;
761 if (t.big) { try { return BigInt(Math.trunc(Number(raw))); } catch (e) { try { return BigInt(raw); } catch (e2) { return null; } } }
762 const n = Number(raw);
763 return isFinite(n) ? n : null;
764 }
765 function valuesEqual(type, a, b) {
766 const t = SCAN_TYPES[type];
767 if (t.big) return a === b;
768 if (t.float) return Math.abs(a - b) <= FLOAT_EPS * (1 + Math.abs(b));
769 return a === b;
770 }
771 // The place value of the least-significant digit the user actually typed, so an
772 // "exact" search is only as precise as entered: "1e10" → 1e10, "1.5" → 0.1,
773 // "100" → 1, "1.50" → 0.01. Returns 1 if the string isn't a plain number.
774 function precisionFromString(raw) {
775 const m = String(raw).trim().toLowerCase().match(/^[+-]?(?:\d+)?(?:\.(\d+))?(?:e([+-]?\d+))?$/);
776 if (!m) return 1;
777 const decimals = m[1] ? m[1].length : 0;
778 const exp = m[2] ? parseInt(m[2], 10) : 0;
779 const p = Math.pow(10, exp - decimals);
780 return (isFinite(p) && p > 0) ? p : 1;
781 }
782 // Build the "exact"-match predicate from the raw typed string. Integers/i64 match
783 // exactly; floats match the precision cell implied by what was typed, always
784 // extending AWAY from zero (closed on the typed value, open at the far end):
785 // "1e10" → [1e10, 2e10), "1.5" → [1.5, 1.6), "-1e10" → (-2e10, -1e10],
786 // "-1.5" → (-1.6, -1.5]. So magnitude grows symmetrically for either sign.
787 function makeExactMatcher(type, raw) {
788 const t = SCAN_TYPES[type]; if (!t) return null;
789 if (t.big) { const target = parseScanValue(type, raw); if (target === null) return null; return { value: target, match: function (x) { return x === target; } }; }
790 const v = Number(raw); if (!isFinite(v)) return null;
791 if (!t.float) return { value: v, match: function (x) { return x === v; } };
792 const p = precisionFromString(raw), base = Math.round(v / p) * p; // snap to the precision grid
793 if (base < 0) { const lo = base - p, hi = base; return { value: v, match: function (x) { return x > lo && x <= hi; } }; }
794 const lo = base, hi = base + p; return { value: v, match: function (x) { return x >= lo && x < hi; } };
795 }
796 // Union matcher for a type group ('all'/'allint'/'allfloat'): matches if ANY expanded
797 // type's exact matcher does. Object-graph values are all f64, so this widens a whole
798 // number like "15" to the float window [15,16) (matching 15.5) the way the WASM backend
799 // already does per-type — otherwise a group would collapse to typeList[0]'s int matcher.
800 function makeGroupMatcher(typeList, raw) {
801 if (raw == null) return null;
802 const ms = typeList.map(function (t) { return makeExactMatcher(t, raw); }).filter(Boolean);
803 if (!ms.length) return null;
804 return { match: function (x) { for (let i = 0; i < ms.length; i++) if (ms[i].match(x)) return true; return false; } };
805 }
806 // refine criteria: 'exact' uses the typed-precision matcher; the rest compare a
807 // fresh read `cur` against the stored previous value `prev`.
808 function passesCriteria(type, criteria, cur, prev, matcher) {
809 switch (criteria) {
810 case 'exact': return !!matcher && matcher.match(cur);
811 case 'changed': return !valuesEqual(type, cur, prev);
812 case 'unchanged': return valuesEqual(type, cur, prev);
813 case 'increased': return cur > prev;
814 case 'decreased': return cur < prev;
815 default: return false;
816 }
817 }
818 function scanValueToWire(v) { return (typeof v === 'bigint') ? v.toString() : v; }
819
820 // Run `body()` in slices, yielding to the event loop between them so a scan
821 // never freezes the frame and can be cancelled mid-flight. body() returns true
822 // while more work remains; onFinish(cancelled) fires once at the end.
823 function chunkLoop(job, body, onFinish) {
824 function tick() {
825 if (job.cancelled) { onFinish(true); return; }
826 let more = false;
827 try { more = body(); } catch (e) { onFinish(false); return; }
828 if (more) origSetTimeout(tick, 0); else onFinish(false);
829 }
830 tick();
831 }
832
833 /* ---- WASM backend -------------------------------------------------- *
834 * Candidates are tagged with the type that matched ({ off, val, ty }) so a
835 * single scan can cover several value types at once and refine/read/write each
836 * one correctly. Scans run chunked (one CHUNK of one type per event-loop slice,
837 * cycling through the type list) so they never freeze the frame and stay
838 * cancellable. Up to SCAN_STORE_CAP matches are tracked, so refining a large
839 * first scan narrows the WHOLE set — not just the first rows shown.
840 * ------------------------------------------------------------------- */
841 const wasmScan = (function () {
842 let memIndex = 0; // which wasmMemories entry we're scanning
843 let types = ['i32']; // type list for the current scan
844 let candidates = null; // [{ off, val, ty }] or null
845 let snapshot = null; // Uint8Array copy for "unknown initial value" scans
846 const CHUNK = 8 * 1024 * 1024; // bytes scanned per event-loop slice
847 const REFINE_BUDGET = 200000; // candidates re-checked per slice during refine
848
849 function handle() { return wasmMemories[memIndex] || null; }
850 function view() { const h = handle(); try { return h ? new DataView(h.memory.buffer) : null; } catch (e) { return null; } }
851 function matcherCache(raw) { const c = {}; return function (t) { if (!(t in c)) c[t] = (raw != null ? makeExactMatcher(t, raw) : null); return c[t]; }; }
852
853 function reset() { candidates = null; snapshot = null; }
854
855 // Scan the whole buffer for each type in `types`, keeping matches for whichever
856 // `accept(ty, cur, prev)` returns true (prev is the snapshot byte value, or the
857 // same as cur for a fresh exact scan). One CHUNK of one type per slice.
858 function scanAll(accept, prevDv, prevLen, job, done) {
859 const h = handle(); if (!h) { done({ error: 'no WASM memory' }); return; }
860 let len = 0; try { len = h.memory.buffer.byteLength; } catch (e) {}
861 const out = []; let count = 0, capped = false, ti = 0, off = 0;
862 chunkLoop(job, function () {
863 if (ti >= types.length) return false;
864 const dv = view(); if (!dv) return false;
865 const t = types[ti], sz = SCAN_TYPES[t].size;
866 const cap = prevDv ? Math.min(len, dv.byteLength, prevLen) : Math.min(len, dv.byteLength);
867 const end = Math.min(cap, off + CHUNK);
868 for (; off + sz <= end; off += sz) {
869 let cur, prev; try { cur = SCAN_TYPES[t].get(dv, off); prev = prevDv ? SCAN_TYPES[t].get(prevDv, off) : cur; } catch (e) { continue; }
870 if (accept(t, cur, prev)) { count++; if (out.length < SCAN_STORE_CAP) out.push({ off: off, val: cur, ty: t }); else capped = true; }
871 }
872 if (off + sz > cap) { ti++; off = 0; }
873 if (job.onProgress) job.onProgress(Math.min(1, (ti + (cap > 0 ? Math.min(1, off / cap) : 1)) / types.length));
874 return ti < types.length;
875 }, function (cancelled) {
876 if (cancelled) { done({ cancelled: true }); return; }
877 candidates = out; done({ count: count, capped: capped, out: out });
878 });
879 }
880
881 function firstExact(typeList, raw, job, done) {
882 types = typeList.slice(); snapshot = null; candidates = null;
883 const mfor = matcherCache(raw);
884 if (types.every(function (t) { return !mfor(t); })) { done({ error: 'bad value' }); return; }
885 scanAll(function (t, cur) { const m = mfor(t); return m && m.match(cur); }, null, 0, job, done);
886 }
887
888 function firstUnknown(typeList, job, done) {
889 types = typeList.slice(); candidates = null;
890 const h = handle(); if (!h) { done({ error: 'no WASM memory' }); return; }
891 try { snapshot = new Uint8Array(h.memory.buffer.slice(0)); } catch (e) { done({ error: 'snapshot failed' }); return; }
892 done({ count: -1, capped: true }); // -1 -> "unknown armed; refine to materialize"
893 }
894
895 // Build the first candidate list from the unknown-scan snapshot by diffing the buffer.
896 function materialize(criteria, raw, job, done) {
897 if (!snapshot) { done({ error: 'no snapshot' }); return; }
898 const mfor = matcherCache(raw), prevDv = new DataView(snapshot.buffer), snapLen = snapshot.byteLength;
899 scanAll(function (t, cur, prev) { return passesCriteria(t, criteria, cur, prev, mfor(t)); }, prevDv, snapLen, job, function (r) {
900 if (!r.cancelled && !r.error) { try { snapshot = new Uint8Array(handle().memory.buffer.slice(0)); } catch (e) {} } // re-baseline
901 done(r);
902 });
903 }
904
905 function refine(criteria, raw, job, done) {
906 if (candidates === null && snapshot !== null) { materialize(criteria, raw, job, done); return; }
907 if (candidates === null) { done({ error: 'no scan in progress' }); return; }
908 const dv = view(); if (!dv) { done({ error: 'no WASM memory' }); return; }
909 const mfor = matcherCache(raw), src = candidates, kept = []; let i = 0;
910 chunkLoop(job, function () {
911 const d = view(); if (!d) return false;
912 let n = 0;
913 for (; i < src.length && n < REFINE_BUDGET; i++, n++) {
914 const c = src[i], sz = SCAN_TYPES[c.ty].size;
915 if (c.off + sz > d.byteLength) continue;
916 let cur; try { cur = SCAN_TYPES[c.ty].get(d, c.off); } catch (e) { continue; }
917 if (passesCriteria(c.ty, criteria, cur, c.val, mfor(c.ty))) kept.push({ off: c.off, val: cur, ty: c.ty });
918 }
919 if (job.onProgress) job.onProgress(src.length ? i / src.length : 1);
920 return i < src.length;
921 }, function (cancelled) {
922 if (cancelled) { done({ cancelled: true }); return; }
923 candidates = kept; done({ count: kept.length, capped: false });
924 });
925 }
926
927 // address used by the panel/saved list: "<memIndex>:<offset>:<type>"
928 function rows(limit) {
929 const dv = view(); const out = [], list = candidates || [];
930 for (let i = 0; i < list.length && i < limit; i++) {
931 const c = list[i]; let cur = null; if (dv) try { cur = SCAN_TYPES[c.ty].get(dv, c.off); } catch (e) {}
932 out.push({ address: memIndex + ':' + c.off + ':' + c.ty, value: scanValueToWire(cur), type: c.ty });
933 }
934 return out;
935 }
936 function readAddress(address, t) {
937 const p = String(address).split(':'); const mi = +p[0], off = +p[1], ty = p[2] || t;
938 const h = wasmMemories[mi]; if (!h || !SCAN_TYPES[ty]) return null;
939 try { return scanValueToWire(SCAN_TYPES[ty].get(new DataView(h.memory.buffer), off)); } catch (e) { return null; }
940 }
941 function writeAddress(address, t, raw) {
942 const p = String(address).split(':'); const mi = +p[0], off = +p[1], ty = p[2] || t;
943 const h = wasmMemories[mi]; if (!h || !SCAN_TYPES[ty]) return false;
944 const v = parseScanValue(ty, raw); if (v === null) return false;
945 try { SCAN_TYPES[ty].set(new DataView(h.memory.buffer), off, v); return true; } catch (e) { return false; }
946 }
947 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)' }; }); }
948 function setMem(i) { memIndex = i | 0; reset(); }
949
950 return {
951 memories: memories, setMem: setMem, reset: reset,
952 firstExact: firstExact, firstUnknown: firstUnknown, refine: refine,
953 rows: rows, readAddress: readAddress, writeAddress: writeAddress
954 };
955 })();
956
957 /* ---- Object-graph backend ----------------------------------------- *
958 * Walks enumerable own properties reachable from the page window, capping
959 * node count and depth. Numeric leaves are tracked by their path (array of
960 * keys). JS numbers are all f64, so the int/float type only tunes equality
961 * (integer match vs. epsilon) here — noted in the UI.
962 * ------------------------------------------------------------------- */
963 const objectScan = (function () {
964 const NODE_CAP = 200000, DEPTH_CAP = 12, BUDGET = 15000; // nodes per event-loop slice
965 let type = 'f64'; // display / default-write type (typeList[0]); values are all f64
966 let types = ['f64']; // full expanded type list of the current scan (for group matchers)
967 let candidates = null; // [{ path:[...], val }]
968 let snapshot = null; // Map(pathKey -> { path, val }) for "unknown initial value"
969
970 function pathKey(path) { return path.join(' '); }
971 function resolve(path) {
972 let o = pageWin;
973 for (let i = 0; i < path.length; i++) { if (o == null) return undefined; o = o[path[i]]; }
974 return o;
975 }
976 // Iterative, chunked DFS over numeric leaves. cb(path, value); match(value) filters.
977 function walkAsync(cb, match, job, done) {
978 const seen = new WeakSet(); let nodes = 0;
979 const stack = [[pageWin, [], 0]];
980 chunkLoop(job, function () {
981 let processed = 0;
982 while (stack.length && processed < BUDGET && nodes < NODE_CAP) {
983 const fr = stack.pop(); const obj = fr[0], path = fr[1], depth = fr[2]; processed++;
984 if (obj == null || depth > DEPTH_CAP) continue;
985 let keys; try { keys = Object.keys(obj); } catch (e) { continue; }
986 for (let i = 0; i < keys.length; i++) {
987 if (nodes >= NODE_CAP) break;
988 const k = keys[i]; let v;
989 try { v = obj[k]; } catch (e) { continue; }
990 const tv = typeof v;
991 if (tv === 'number') { if (isFinite(v) && (!match || match(v))) cb(path.concat(k), v); }
992 else if (tv === 'object' || tv === 'function') {
993 if (v === null || seen.has(v) || v === pageWin || v === window) continue;
994 try { if (v.nodeType && v.nodeName) continue; } catch (e) {} // DOM nodes
995 try { if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) continue; } catch (e) {}
996 seen.add(v); nodes++;
997 stack.push([v, path.concat(k), depth + 1]);
998 }
999 }
1000 }
1001 if (job.onProgress) job.onProgress(-1); // total is unknown up front → indeterminate
1002 return stack.length > 0 && nodes < NODE_CAP;
1003 }, done);
1004 }
1005
1006 function reset() { candidates = null; snapshot = null; }
1007 function setMem() {} // n/a for object graph
1008
1009 // All JS numbers are f64, so the width only tunes equality (exact int vs. float
1010 // cell); a multi-type selection just uses the first type's matcher here.
1011 function firstExact(typeList, raw, job, done) {
1012 types = typeList.slice(); type = typeList[0] || 'f64'; snapshot = null; candidates = null;
1013 const matcher = makeGroupMatcher(types, raw);
1014 if (!matcher) { done({ error: 'bad value' }); return; }
1015 const out = []; let count = 0, capped = false;
1016 walkAsync(function (path, v) { count++; if (out.length < SCAN_STORE_CAP) out.push({ path: path, val: v }); else capped = true; },
1017 function (v) { return matcher.match(v); }, job, function (cancelled) {
1018 if (cancelled) { done({ cancelled: true }); return; }
1019 candidates = out; done({ count: count, capped: capped });
1020 });
1021 }
1022 function firstUnknown(typeList, job, done) {
1023 types = typeList.slice(); type = typeList[0] || 'f64'; candidates = null;
1024 const snap = new Map();
1025 walkAsync(function (path, v) { snap.set(pathKey(path), { path: path, val: v }); }, null, job, function (cancelled) {
1026 if (cancelled) { done({ cancelled: true }); return; }
1027 snapshot = snap; done({ count: -1, capped: true });
1028 });
1029 }
1030 function materialize(criteria, raw, job, done) {
1031 if (!snapshot) { done({ error: 'no snapshot' }); return; }
1032 const matcher = (raw != null) ? makeGroupMatcher(types, raw) : null;
1033 const entries = Array.from(snapshot.values());
1034 const out = []; let count = 0, capped = false, i = 0;
1035 chunkLoop(job, function () {
1036 let processed = 0;
1037 for (; i < entries.length && processed < BUDGET; i++, processed++) {
1038 const entry = entries[i]; const cur = resolve(entry.path);
1039 if (typeof cur !== 'number' || !isFinite(cur)) continue;
1040 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; }
1041 entry.val = cur; // re-baseline
1042 }
1043 if (job.onProgress) job.onProgress(entries.length ? i / entries.length : 1);
1044 return i < entries.length;
1045 }, function (cancelled) {
1046 if (cancelled) { done({ cancelled: true }); return; }
1047 candidates = out; done({ count: count, capped: capped });
1048 });
1049 }
1050 function refine(criteria, raw, job, done) {
1051 if (candidates === null && snapshot !== null) { materialize(criteria, raw, job, done); return; }
1052 if (candidates === null) { done({ error: 'no scan in progress' }); return; }
1053 const matcher = (raw != null) ? makeGroupMatcher(types, raw) : null;
1054 const kept = [];
1055 for (let i = 0; i < candidates.length; i++) {
1056 const c = candidates[i]; const cur = resolve(c.path);
1057 if (typeof cur === 'number' && isFinite(cur) && passesCriteria(type, criteria, cur, c.val, matcher)) kept.push({ path: c.path, val: cur });
1058 }
1059 candidates = kept; done({ count: kept.length, capped: false });
1060 }
1061 function rows(limit) {
1062 const out = [], list = candidates || [];
1063 for (let i = 0; i < list.length && i < limit; i++) {
1064 const cur = resolve(list[i].path);
1065 out.push({ address: JSON.stringify(list[i].path), value: (typeof cur === 'number' ? cur : null), type: type });
1066 }
1067 return out;
1068 }
1069 function readAddress(address) {
1070 try { const cur = resolve(JSON.parse(address)); return (typeof cur === 'number') ? cur : null; } catch (e) { return null; }
1071 }
1072 function writeAddress(address, t, raw) {
1073 if (!SCAN_TYPES[t]) t = 'f64'; // a type group selection → object values are f64
1074 let path; try { path = JSON.parse(address); } catch (e) { return false; }
1075 const v = parseScanValue(t, raw); if (v === null) return false;
1076 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; }
1077 catch (e) { return false; }
1078 }
1079 function memories() { return []; }
1080
1081 return {
1082 memories: memories, setMem: setMem, reset: reset,
1083 firstExact: firstExact, firstUnknown: firstUnknown, refine: refine,
1084 rows: rows, readAddress: readAddress, writeAddress: writeAddress
1085 };
1086 })();
1087
1088 function scanEngine(name) { return name === 'object' ? objectScan : wasmScan; }
1089
1090 // Address labels for display (wasm "mi:off:ty" → "mem#mi +0x.. (ty)"; object path → "window...").
1091 function scanAddressLabel(engine, address) {
1092 if (engine === 'object') { try { return 'window.' + JSON.parse(address).join('.'); } catch (e) { return String(address); } }
1093 const p = String(address).split(':');
1094 return 'mem#' + p[0] + ' +0x' + (Number(p[1]) || 0).toString(16) + (p[2] ? ' (' + p[2] + ')' : '');
1095 }
1096
1097 // Execute one scan command against the LOCAL engines; resolves a wire-safe result.
1098 // Long ops run asynchronously (chunked) and a single in-flight `scanJob` can be
1099 // cancelled — there are no timeouts anywhere; callers wait until done or cancel.
1100 const SCAN_ROW_LIMIT = 200; // most rows we ship/render at once
1101 let scanJob = null;
1102 function runScanCommand(cmd, onProgress) {
1103 return new Promise(function (resolve) {
1104 try {
1105 const eng = scanEngine(cmd.engine);
1106 switch (cmd.op) {
1107 case 'ping': resolve({ ok: true, pong: true }); return;
1108 case 'list-memories': resolve({ ok: true, memories: wasmScan.memories() }); return;
1109 case 'set-mem': if (scanJob) scanJob.cancelled = true; eng.setMem(cmd.mem | 0); resolve({ ok: true }); return;
1110 case 'reset': if (scanJob) scanJob.cancelled = true; eng.reset(); resolve({ ok: true, count: 0, rows: [] }); return;
1111 case 'cancel': if (scanJob) scanJob.cancelled = true; resolve({ ok: true, cancelled: true }); return;
1112 case 'set-paused': setPaused(cmd.value); resolve({ ok: true, paused: paused }); return;
1113 case 'read': {
1114 const vals = (cmd.addresses || []).map(function (a) { return { address: a, value: scanValueToWire(eng.readAddress(a, cmd.type)) }; });
1115 resolve({ ok: true, values: vals }); return;
1116 }
1117 case 'write': {
1118 const ok = eng.writeAddress(cmd.address, cmd.type, cmd.value);
1119 resolve({ ok: ok, value: scanValueToWire(eng.readAddress(cmd.address, cmd.type)) }); return;
1120 }
1121 case 'first-exact': case 'first-unknown': case 'refine': {
1122 if (scanJob) scanJob.cancelled = true; // supersede any prior scan
1123 const job = { cancelled: false, onProgress: onProgress || null }; scanJob = job;
1124 const done = function (r) {
1125 if (scanJob === job) scanJob = null;
1126 if (!r || r.error) { resolve({ ok: false, error: (r && r.error) || 'scan failed' }); return; }
1127 if (r.cancelled) { resolve({ ok: true, cancelled: true }); return; }
1128 resolve({ ok: true, count: r.count, capped: r.capped, rows: eng.rows(SCAN_ROW_LIMIT) });
1129 };
1130 const types = expandTypes(cmd.type);
1131 if (cmd.op === 'first-exact') eng.firstExact(types, cmd.value, job, done);
1132 else if (cmd.op === 'first-unknown') eng.firstUnknown(types, job, done);
1133 else eng.refine(cmd.criteria, (cmd.value != null ? cmd.value : null), job, done);
1134 return;
1135 }
1136 default: resolve({ ok: false, error: 'unknown op' }); return;
1137 }
1138 } catch (e) { resolve({ ok: false, error: String(e && e.message || e) }); }
1139 });
1140 }
1141
1142 /* ------------------------------------------------------------------ *
1143 * Scan controller — the panel drives a TARGET frame. For "this frame"
1144 * it calls the local engine directly; for a child iframe it marshals the
1145 * command over postMessage and resolves when the matching reply arrives.
1146 * There is NO timeout — a request waits until the target replies or the
1147 * user cancels (which sends a 'cancel' command that ends the scan).
1148 * Candidate state stays in the target frame; only commands + small result
1149 * batches cross the boundary (works cross-origin and for nested frames).
1150 * ------------------------------------------------------------------ */
1151 let scanSeq = 1;
1152 const scanPending = new Map(); // reqId -> resolve (controller side)
1153 const scanProgress = new Map(); // reqId -> onProgress(frac) (controller side, remote scans)
1154 const scanHandled = new Set(); // "from:reqId" of cmds already run (target side, dedup)
1155 const scanHandledQ = []; // FIFO to bound scanHandled
1156 function sendScan(targetId, targetWin, cmd, onProgress) {
1157 if (!targetId) return runScanCommand(cmd, onProgress); // null → this frame (local engine, no messaging)
1158 return new Promise(function (resolve) {
1159 const reqId = scanSeq++;
1160 scanPending.set(reqId, resolve);
1161 if (onProgress) scanProgress.set(reqId, onProgress);
1162 // Nest the command under `cmd` rather than flattening it into the envelope:
1163 // the scan value-type field is also called `type` and would otherwise overwrite
1164 // the envelope's `type: 'scan-cmd'`, so the target saw an unknown message type
1165 // and silently dropped every remote scan (v1.4.5 fix).
1166 const msg = { type: 'scan-cmd', reqId: reqId, targetFrame: targetId, from: SELF_ID, cmd: cmd };
1167 if (targetWin) { try { postTo(targetWin, msg); } catch (e) {} } // proven channel (clicker/settings use it)
1168 broadcastScanMsg(msg); // + frame-tree broadcast as backup
1169 });
1170 }
1171
1172 /* ------------------------------------------------------------------ *
1173 * UI (Shadow DOM)
1174 * ------------------------------------------------------------------ */
1175 const CSS = `
1176 :host { all: initial; }
1177 #panel {
1178 position: fixed; right: 12px; bottom: 12px; z-index: 2147483647; box-sizing: border-box;
1179 font: 12px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
1180 color: #e8e8ea; background: #1b1d22; border: 1px solid #3a3d44; border-radius: 10px;
1181 box-shadow: 0 10px 30px rgba(0,0,0,.5);
1182 width: 256px; max-width: 92vw; max-height: 82vh;
1183 display: flex; flex-direction: column; overflow: hidden; resize: both; user-select: none;
1184 }
1185 #panel.min { width: auto; height: auto !important; resize: none; }
1186 #panel.min #bar { gap: 4px; padding: 5px 6px; cursor: pointer; }
1187 #panel.min #title, #panel.min #badge, #panel.min .sp, #panel.min #close { display: none; }
1188 #panel * { box-sizing: border-box; }
1189 #bar { display: flex; align-items: center; gap: 6px; padding: 7px 8px; background: #23262d; cursor: grab; }
1190 #bar:active { cursor: grabbing; }
1191 #title { font-weight: 600; }
1192 #badge { padding: 1px 7px; background: #2f6df6; border-radius: 999px; font-weight: 700; font-size: 11px; }
1193 #panel:not(.min) #badge { display: none; }
1194 .sp { flex: 1 1 auto; }
1195 #bar button { all: unset; cursor: pointer; width: 20px; height: 20px; line-height: 20px; text-align: center; border-radius: 5px; color: #cfd2d8; font-size: 14px; }
1196 #bar button:hover { background: #34384199; color: #fff; }
1197 #body { padding: 11px; display: flex; flex-direction: column; gap: 11px; overflow: auto; }
1198 #panel.min #body { display: none; }
1199 .row { display: flex; flex-direction: column; gap: 6px; }
1200 .row .lbl { display: flex; justify-content: space-between; align-items: baseline; color: #aeb2bb; }
1201 .row output { color: #fff; font-weight: 700; }
1202 input[type=range] { width: 100%; accent-color: #2f6df6; }
1203 input[type=number] { all: unset; width: 100%; padding: 5px 8px; background: #14161a; border: 1px solid #3a3d44; border-radius: 6px; color: #fff; font: inherit; }
1204 .presets { display: flex; flex-wrap: wrap; gap: 5px; }
1205 .presets button { all: unset; cursor: pointer; padding: 3px 9px; background: #2a2e36; border: 1px solid #3a3d44; border-radius: 999px; color: #d6d9df; font-size: 11px; }
1206 .presets button:hover { background: #353a44; color: #fff; }
1207 .toggles { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid #2c2f37; padding-top: 9px; }
1208 .tg { display: flex; align-items: center; gap: 8px; cursor: pointer; }
1209 .tg input { accent-color: #2f6df6; cursor: pointer; }
1210 .tg input:disabled { cursor: not-allowed; }
1211 .tg.disabled { opacity: .45; cursor: not-allowed; }
1212 .frames { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid #2c2f37; padding-top: 9px; }
1213 #framesBox[hidden] { display: none; } /* .frames display:flex above defeats the UA [hidden] rule otherwise */
1214 .frames .lbl { color: #aeb2bb; }
1215 #frameList { display: flex; flex-direction: column; gap: 5px; }
1216 .frow { display: flex; align-items: center; gap: 6px; }
1217 .furl { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d6d9df; font-size: 10.5px; }
1218 .frow button { all: unset; cursor: pointer; padding: 2px 8px; background: #2a2e36; border: 1px solid #3a3d44; border-radius: 999px; color: #d6d9df; font-size: 10.5px; }
1219 .frow button:hover { background: #353a44; color: #fff; }
1220 #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; }
1221 #reattach:hover { background: #353a44; color: #fff; }
1222 #reattach[hidden] { display: none; } /* all:unset above resets display to inline, defeating the UA [hidden] rule */
1223 .tabs { display: flex; gap: 4px; padding: 6px 8px 0; background: #23262d; }
1224 #panel.min .tabs { display: none; }
1225 .tab { all: unset; cursor: pointer; padding: 5px 11px; border-radius: 6px 6px 0 0; color: #aeb2bb; font-size: 11.5px; font-weight: 600; }
1226 .tab:hover { color: #fff; }
1227 .tab.active { background: #1b1d22; color: #fff; }
1228 .pane { display: flex; flex-direction: column; gap: 11px; }
1229 .pane[hidden] { display: none; }
1230 .clk-row { display: flex; align-items: center; gap: 8px; }
1231 .clk-row > span.k { flex: 1 1 auto; color: #aeb2bb; }
1232 .clk-grid { display: grid; grid-template-columns: auto 1fr; gap: 7px 8px; align-items: center; }
1233 .clk-grid > span { color: #aeb2bb; }
1234 .clk-grid input[type=number] { width: 100%; }
1235 .clk-btn { all: unset; cursor: pointer; padding: 4px 10px; background: #2a2e36; border: 1px solid #3a3d44; border-radius: 6px; color: #d6d9df; font-size: 11px; }
1236 .clk-btn:hover { background: #353a44; color: #fff; }
1237 .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; }
1238 .clk-key.listening { color: #f0b07a; border-color: #5a4326; }
1239 .seg { display: flex; gap: 0; border: 1px solid #3a3d44; border-radius: 6px; overflow: hidden; }
1240 .seg button { all: unset; cursor: pointer; flex: 1 1 0; text-align: center; padding: 5px 0; color: #d6d9df; font-size: 11px; }
1241 .seg button.on { background: #2f6df6; color: #fff; font-weight: 600; }
1242 .sc-rowx { display: flex; align-items: center; gap: 6px; }
1243 .sc-rowx > * { min-width: 0; }
1244 .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; }
1245 .sc-sel[hidden] { display: none; }
1246 .sc-type { flex: 0 0 auto; width: auto; }
1247 #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; }
1248 .sc-pause { display: block; box-sizing: border-box; width: 100%; text-align: center; }
1249 .sc-pause.on { background: #2f6df6; border-color: #2f6df6; color: #fff; }
1250 .sc-btns { display: flex; flex-wrap: wrap; gap: 5px; }
1251 .sc-btns .clk-btn { flex: 1 1 auto; text-align: center; }
1252 .clk-btn[disabled] { opacity: .4; cursor: not-allowed; }
1253 .sc-cancel { background: #3a2417; border-color: #5a4326; color: #f0b07a; }
1254 .sc-cancel:hover { background: #4a2e1d; color: #ffcf9a; }
1255 .sc-cancel[hidden], #scMemDetect[hidden] { display: none; }
1256 #scRefine[hidden] { display: none; }
1257 .sc-mode { display: flex; gap: 14px; flex-wrap: wrap; }
1258 .sc-mode .tg { flex: 0 0 auto; }
1259 .sc-mode input:disabled + span { opacity: .45; }
1260 #scValue:disabled { opacity: .45; }
1261 .sc-progress { height: 4px; background: #14161a; border-radius: 3px; overflow: hidden; }
1262 .sc-progress[hidden] { display: none; }
1263 .sc-bar { height: 100%; width: 0; background: #2f6df6; transition: width .12s linear; }
1264 .sc-bar.indet { width: 40%; animation: shx-indet 1s linear infinite; }
1265 @keyframes shx-indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
1266 .sc-count { color: #aeb2bb; font-size: 10.5px; }
1267 .sc-results { display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow: auto; }
1268 .sc-results:empty { display: none; }
1269 .sc-row { display: flex; align-items: center; gap: 6px; }
1270 .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; }
1271 .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; }
1272 .sc-mini { all: unset; cursor: pointer; padding: 2px 7px; background: #2a2e36; border: 1px solid #3a3d44; border-radius: 999px; color: #d6d9df; font-size: 10px; }
1273 .sc-mini:hover { background: #353a44; color: #fff; }
1274 .sc-saved { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid #2c2f37; padding-top: 9px; }
1275 .sc-saved .lbl { color: #aeb2bb; }
1276 #scSaved { display: flex; flex-direction: column; gap: 5px; }
1277 #scSaved:empty::after { content: 'Nothing saved yet.'; color: #6f747f; font-size: 10.5px; }
1278 .sc-srow { display: flex; align-items: center; gap: 6px; }
1279 .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; }
1280 .sc-name:focus { border-color: #2f6df6; color: #fff; }
1281 .sc-name.stale { color: #c77; border-color: #5a2c2c; }
1282 .status { font-size: 10.5px; padding: 5px 7px; border-radius: 6px; }
1283 .status.run { background: #16331f; color: #7fe0a0; }
1284 .status.idle { background: #23262d; color: #aeb2bb; }
1285 .status.ok { background: #16331f; color: #7fe0a0; }
1286 .status.warn { background: #3a2417; color: #f0b07a; }
1287 .note { color: #6f747f; font-size: 10.5px; border-top: 1px solid #2c2f37; padding-top: 8px; }
1288 #dlg { position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.45); }
1289 #dlg[hidden] { display: none; }
1290 .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); }
1291 .dlgmsg { font-weight: 600; margin-bottom: 8px; }
1292 .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; }
1293 .dlgbtns { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
1294 .dlgbtns button { all: unset; cursor: pointer; padding: 6px 11px; border-radius: 6px; font-size: 11.5px; }
1295 #dlgYes { background: #2f6df6; color: #fff; font-weight: 600; }
1296 #dlgNo { background: #2a2e36; color: #e8e8ea; border: 1px solid #3a3d44; }
1297 #dlgCancel { color: #aeb2bb; }
1298 #dlgYes:hover { background: #3d79ff; }
1299 `;
1300
1301 const HTML = `
1302 <div id="panel" class="min">
1303 <div id="bar">
1304 <span id="grip">⚡</span>
1305 <span id="title">Speedhack</span>
1306 <span id="badge">1×</span>
1307 <span class="sp"></span>
1308 <button id="min" title="Minimize / expand">–</button>
1309 <button id="close" title="Close">×</button>
1310 </div>
1311 <div class="tabs" id="tabs">
1312 <button class="tab active" data-tab="speed">Speed</button>
1313 <button class="tab" data-tab="clicker">Clicker</button>
1314 <button class="tab" data-tab="scan">Scan</button>
1315 </div>
1316 <div id="body">
1317 <div class="pane" data-pane="speed">
1318 <div id="status" class="status"></div>
1319 <div class="row">
1320 <div class="lbl"><span>Scale factor</span><output id="scaleOut">1×</output></div>
1321 <input id="scaleRange" type="range" min="0.1" max="100" step="0.1" value="1">
1322 <input id="scaleNum" type="number" min="0.1" max="1000" step="0.1" value="1">
1323 <div class="presets">
1324 <button data-s="0.25">0.25×</button>
1325 <button data-s="0.5">0.5×</button>
1326 <button data-s="1">1×</button>
1327 <button data-s="2">2×</button>
1328 <button data-s="5">5×</button>
1329 <button data-s="10">10×</button>
1330 </div>
1331 </div>
1332 <div class="toggles" id="toggles"></div>
1333 <div class="frames" id="framesBox" hidden>
1334 <div class="lbl"><span>Frames on this page</span></div>
1335 <div id="frameList"></div>
1336 </div>
1337 <button id="reattach" hidden>↩ Re-attach to main panel</button>
1338 <div class="note">Settings reset on reload. Only the per-URL “closed” choice is saved.</div>
1339 </div>
1340 <div class="pane" data-pane="clicker" hidden>
1341 <div id="clkStatus" class="status idle"></div>
1342 <div class="seg" id="clkMode">
1343 <button data-mode="toggle">Toggle</button>
1344 <button data-mode="hold">Hold</button>
1345 </div>
1346 <div class="clk-row">
1347 <span class="clk-key" id="clkKey">not set</span>
1348 <button class="clk-btn" id="clkSet">Set</button>
1349 <button class="clk-btn" id="clkClear">Clear</button>
1350 </div>
1351 <label class="tg"><input type="checkbox" id="clkSwallow"><span>Swallow hotkey (hide it from the page)</span></label>
1352 <div class="clk-grid">
1353 <span>Rate (clicks/s)</span><input id="clkCps" type="number" min="0.1" max="100" step="0.1" value="10">
1354 <span>Random (± ms)</span><input id="clkJitter" type="number" min="0" max="2000" step="1" value="0">
1355 <span>Hold (ms)</span><input id="clkHold" type="number" min="0" max="2000" step="1" value="20">
1356 </div>
1357 <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>
1358 </div>
1359 <div class="pane" data-pane="scan" hidden>
1360 <select id="scTarget" class="sc-sel" title="Frame to scan"></select>
1361 <button class="clk-btn sc-pause" id="scPause">⏸ Pause</button>
1362 <div id="scStatus" class="status idle"></div>
1363 <div class="seg" id="scEngine">
1364 <button data-engine="wasm" class="on">WASM</button>
1365 <button data-engine="object">JS Objects</button>
1366 </div>
1367 <select id="scMem" class="sc-sel" hidden></select>
1368 <button class="clk-btn sc-pause" id="scMemDetect" hidden>↻ Detect WASM memory</button>
1369 <div class="sc-mode" id="scMode">
1370 <label class="tg"><input type="radio" name="shxScMode" value="exact" checked><span>Known value</span></label>
1371 <label class="tg" title="Snapshot all values now, then narrow by how they change (Up/Down/Changed) — use when you don't know the value."><input type="radio" name="shxScMode" value="unknown"><span>Unknown initial value</span></label>
1372 </div>
1373 <div class="sc-rowx">
1374 <select id="scType" class="sc-sel sc-type">
1375 <option value="i32" title="32-bit signed integer. The most common type — scores, counts, HP, currency in many games.">int32</option>
1376 <option value="f32" title="32-bit float. Positions, health bars, speeds, timers — typical for Unity/C/C++ (WASM) games.">float32</option>
1377 <option value="f64" title="64-bit float. JavaScript's native number type — the default for JS-object games.">float64</option>
1378 <option value="i8" title="8-bit signed integer (-128..127). Small flags, levels, tiny counters.">int8</option>
1379 <option value="u8" title="8-bit unsigned (0..255). Raw bytes, booleans, RGBA channels, small counters.">uint8</option>
1380 <option value="i16" title="16-bit signed integer (-32768..32767). Medium counters and coordinates.">int16</option>
1381 <option value="u16" title="16-bit unsigned (0..65535). Medium counters, tile/IDs, ammo.">uint16</option>
1382 <option value="u32" title="32-bit unsigned (0..4.29e9). Large counts, currency, timestamps.">uint32</option>
1383 <option value="i64" title="64-bit integer via BigInt. Very large currencies/IDs — WASM only.">int64</option>
1384 <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>
1385 <option value="allfloat" title="Scan both float widths (f32 + f64) at once. Use when unsure which float precision the game uses.">all floats</option>
1386 <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>
1387 </select>
1388 <input id="scValue" type="text" inputmode="decimal" placeholder="value">
1389 </div>
1390 <div class="sc-btns">
1391 <button class="clk-btn" id="scFirst">First scan</button>
1392 <button class="clk-btn sc-cancel" id="scCancel" hidden>Cancel</button>
1393 </div>
1394 <div class="sc-btns" id="scRefine" hidden>
1395 <button class="clk-btn" data-crit="exact">Exact</button>
1396 <button class="clk-btn" data-crit="changed">Changed</button>
1397 <button class="clk-btn" data-crit="unchanged">Unchanged</button>
1398 <button class="clk-btn" data-crit="increased">▲ Up</button>
1399 <button class="clk-btn" data-crit="decreased">▼ Down</button>
1400 </div>
1401 <div id="scProgress" class="sc-progress" hidden><div id="scBar" class="sc-bar"></div></div>
1402 <div id="scCount" class="sc-count">No scan yet.</div>
1403 <div id="scResults" class="sc-results"></div>
1404 <div class="sc-saved">
1405 <div class="lbl"><span>Saved</span></div>
1406 <div id="scSaved"></div>
1407 </div>
1408 <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>
1409 </div>
1410 </div>
1411 </div>
1412 <div id="dlg" hidden>
1413 <div class="dlgbox">
1414 <div class="dlgmsg">Remember closing for this exact page?</div>
1415 <div class="dlgurl" id="dlgurl"></div>
1416 <div class="dlgbtns">
1417 <button id="dlgCancel">Cancel</button>
1418 <button id="dlgNo">Just close</button>
1419 <button id="dlgYes">Don’t show here</button>
1420 </div>
1421 </div>
1422 </div>
1423 `;
1424
1425 function shortUrl(u) {
1426 try { const p = new URL(u); return (p.pathname === '/' ? p.host : p.host + p.pathname); }
1427 catch (e) { return String(u).replace(/^[a-z]+:\/\//, ''); }
1428 }
1429
1430 // mode: 'host' (top / promoted — shows frame list) or 'detached' (child with re-attach)
1431 function buildUI(mode) {
1432 let host, root;
1433 try {
1434 host = document.createElement('div');
1435 host.style.all = 'initial';
1436 root = host.attachShadow({ mode: 'open' });
1437 root.innerHTML = '<style>' + CSS + '</style>' + HTML;
1438 try { shieldInputTarget(document.body); } catch (e) {} // now that <body> exists
1439 (document.body || document.documentElement).appendChild(host);
1440 } catch (e) { return null; }
1441 panelHost = host; // so the hotkey listener can ignore keys typed into our own UI
1442
1443 // Input shield: stop events that originate inside the panel from reaching the
1444 // page's own listeners, so clicking/typing in the UI doesn't also drive the game.
1445 // These fire in the BUBBLE phase, AFTER the panel's own handlers have run, then
1446 // stopPropagation() keeps the event from bubbling out to document/window. (A page
1447 // listener registered on window/document in the CAPTURE phase still sees the event —
1448 // nothing in the same DOM can prevent that; detaching into the game's own frame, or
1449 // an input-isolating iframe, would be the only full fix.)
1450 ['pointerdown','pointerup','mousedown','mouseup','click','dblclick','contextmenu',
1451 'keydown','keyup','keypress','wheel','touchstart','touchend','pointermove','mousemove'
1452 ].forEach(function (t) { host.addEventListener(t, function (e) { e.stopPropagation(); }, false); });
1453
1454 const $ = function (s) { return root.querySelector(s); };
1455 const panel = $('#panel'), bar = $('#bar'), badge = $('#badge'), title = $('#title');
1456 const range = $('#scaleRange'), num = $('#scaleNum'), out = $('#scaleOut');
1457 const btnMin = $('#min'), btnClose = $('#close'), dlg = $('#dlg'), status = $('#status');
1458 const framesBox = $('#framesBox'), frameList = $('#frameList'), reattachBtn = $('#reattach');
1459
1460 // status line — confirms we reached the page's real context
1461 if (hasUnsafe) { status.className = 'status ok'; status.textContent = '✓ patching page context (unsafeWindow)'; }
1462 else { status.className = 'status warn'; status.textContent = '⚠ unsafeWindow not found — using this window. If nothing speeds up, the manager is sandboxing the script.'; }
1463
1464 // toggles (the 5 hooks + turbo)
1465 const tg = $('#toggles');
1466 const hookInputs = {};
1467 Object.keys(hooks).forEach(function (name) {
1468 const lab = document.createElement('label');
1469 lab.className = 'tg';
1470 lab.innerHTML = '<input type="checkbox" ' + (state[name] ? 'checked' : '') + '><span>' + hooks[name].label + '</span>';
1471 const inp = lab.querySelector('input');
1472 hookInputs[name] = inp;
1473 inp.addEventListener('change', function (e) {
1474 setHook(name, e.target.checked);
1475 if (name === 'raf') updateTurboEnabled();
1476 broadcastSettings();
1477 });
1478 tg.appendChild(lab);
1479 });
1480 const tlab = document.createElement('label');
1481 tlab.className = 'tg';
1482 tlab.innerHTML = '<input type="checkbox"><span>rAF turbo — multi-step (experimental)</span>';
1483 const turboInput = tlab.querySelector('input');
1484 turboInput.checked = turbo;
1485 turboInput.addEventListener('change', function (e) { turbo = e.target.checked; turboT = null; broadcastSettings(); });
1486 tg.appendChild(tlab);
1487
1488 // turbo only does anything while the rAF hook is installed
1489 function updateTurboEnabled() {
1490 const on = !!state.raf;
1491 turboInput.disabled = !on;
1492 tlab.classList.toggle('disabled', !on);
1493 }
1494 updateTurboEnabled();
1495
1496 // scale
1497 function reflect(v) { out.textContent = v + '×'; badge.textContent = v + '×'; }
1498 const MAX_SCALE = 1000, SLIDER_MAX = 100, SLIDER_MIN = 0.1;
1499 // writeNum=false while the user is typing into the number field, so we don't
1500 // clobber the caret / intermediate input — that field is normalized on commit.
1501 function onScale(v, writeNum) {
1502 v = Number(v); if (!isFinite(v) || v <= 0) return;
1503 if (v > MAX_SCALE) v = MAX_SCALE; // hard cap, incl. typed-in numbers
1504 applyScale(v);
1505 range.value = Math.min(SLIDER_MAX, Math.max(SLIDER_MIN, v));
1506 if (writeNum !== false) num.value = v;
1507 reflect(v);
1508 broadcastSettings();
1509 }
1510 range.addEventListener('input', function (e) { onScale(e.target.value); });
1511 num.addEventListener('input', function (e) { onScale(e.target.value, false); });
1512 num.addEventListener('change', function (e) { onScale(e.target.value); }); // normalize + cap on commit
1513 root.querySelectorAll('.presets button').forEach(function (b) {
1514 b.addEventListener('click', function () { onScale(b.dataset.s); });
1515 });
1516
1517 // pull controls back in line with current state (used when settings arrive remotely)
1518 function sync() {
1519 Object.keys(hookInputs).forEach(function (n) { hookInputs[n].checked = !!state[n]; });
1520 turboInput.checked = turbo;
1521 updateTurboEnabled();
1522 range.value = Math.min(SLIDER_MAX, Math.max(SLIDER_MIN, scale));
1523 num.value = scale;
1524 reflect(scale);
1525 }
1526
1527 // frame list (host mode) — one row per child frame that has announced itself
1528 function refreshFrames() {
1529 refreshScanTargets();
1530 if (curMode !== 'host') { framesBox.hidden = true; return; }
1531 pruneFrames();
1532 frameList.textContent = '';
1533 if (frames.size === 0) { framesBox.hidden = true; return; }
1534 framesBox.hidden = false;
1535 frames.forEach(function (f, src) {
1536 const row = document.createElement('div'); row.className = 'frow';
1537 const label = document.createElement('span'); label.className = 'furl';
1538 label.textContent = shortUrl(f.url); label.title = f.url;
1539 const btn = document.createElement('button');
1540 btn.textContent = f.attached ? 'Detach' : 'Re-attach';
1541 btn.addEventListener('click', function () { f.attached ? detachFrame(src) : reattachFrame(src); });
1542 row.appendChild(label); row.appendChild(btn);
1543 frameList.appendChild(row);
1544 });
1545 }
1546
1547 reattachBtn.addEventListener('click', function () {
1548 if (hostWin) postTo(hostWin, { type: 'reattach' });
1549 attached = true;
1550 destroyPanel(); // back to headless; host will resend settings
1551 });
1552
1553 // tabs
1554 const panes = {};
1555 root.querySelectorAll('.pane').forEach(function (p) { panes[p.dataset.pane] = p; });
1556 const tabBtns = root.querySelectorAll('.tab');
1557 function setTab(name) {
1558 tabBtns.forEach(function (b) { b.classList.toggle('active', b.dataset.tab === name); });
1559 Object.keys(panes).forEach(function (n) { panes[n].hidden = (n !== name); });
1560 }
1561 tabBtns.forEach(function (b) { b.addEventListener('click', function () { setTab(b.dataset.tab); }); });
1562
1563 /* ----- clicker controls ----- */
1564 const clkStatus = $('#clkStatus'), clkKey = $('#clkKey'), clkSet = $('#clkSet'), clkClear = $('#clkClear');
1565 const clkSwallow = $('#clkSwallow'), clkCps = $('#clkCps'), clkJitter = $('#clkJitter'), clkHold = $('#clkHold');
1566 const clkModeBtns = root.querySelectorAll('#clkMode button');
1567
1568 function keyLabel(hk) {
1569 if (!hk) return 'not set';
1570 return (hk.ctrl ? 'Ctrl+' : '') + (hk.alt ? 'Alt+' : '') + (hk.shift ? 'Shift+' : '') + (hk.meta ? 'Meta+' : '') +
1571 (hk.key === ' ' ? 'Space' : hk.key);
1572 }
1573 function syncClicker() {
1574 clkModeBtns.forEach(function (b) { b.classList.toggle('on', b.dataset.mode === clicker.mode); });
1575 clkKey.textContent = clicker.listening ? 'press a key…' : keyLabel(clicker.hotkey);
1576 clkKey.classList.toggle('listening', clicker.listening);
1577 clkSwallow.checked = clicker.swallowHotkey;
1578 // root.activeElement (not document.activeElement) sees focus *inside* the shadow root,
1579 // so we don't overwrite a field the user is currently typing into.
1580 if (root.activeElement !== clkCps) clkCps.value = clicker.cps;
1581 if (root.activeElement !== clkJitter) clkJitter.value = clicker.jitterMs;
1582 if (root.activeElement !== clkHold) clkHold.value = clicker.holdMs;
1583 clkStatus.className = 'status ' + (clicker.running ? 'run' : 'idle');
1584 clkStatus.textContent = clicker.running
1585 ? '● clicking — ' + Math.round(clicker.cps) + '/s at cursor'
1586 : (clicker.hotkey ? '○ idle — press ' + keyLabel(clicker.hotkey) + ' to ' + (clicker.mode === 'hold' ? 'hold' : 'toggle')
1587 : '○ idle — set a hotkey to start');
1588 }
1589 clkModeBtns.forEach(function (b) {
1590 b.addEventListener('click', function () {
1591 clicker.mode = b.dataset.mode;
1592 if (clicker.running) setClickerRunning(false, true); // mode switch is a clean stop
1593 syncClicker(); broadcastClickerConfig();
1594 });
1595 });
1596 clkSet.addEventListener('click', function () { clicker.listening = true; syncClicker(); });
1597 clkClear.addEventListener('click', function () {
1598 if (clicker.running) setClickerRunning(false, true);
1599 clicker.hotkey = null; clicker.listening = false; syncClicker(); broadcastClickerConfig();
1600 });
1601 clkSwallow.addEventListener('change', function (e) { clicker.swallowHotkey = e.target.checked; broadcastClickerConfig(); });
1602 function commitNum(input, key, min, max) {
1603 let v = Number(input.value);
1604 if (!isFinite(v)) return;
1605 v = Math.min(max, Math.max(min, v));
1606 clicker[key] = v;
1607 broadcastClickerConfig();
1608 }
1609 clkCps.addEventListener('input', function () { commitNum(clkCps, 'cps', 0.1, MAX_CPS); });
1610 clkCps.addEventListener('change', function () { clkCps.value = clicker.cps; });
1611 clkJitter.addEventListener('input', function () { commitNum(clkJitter, 'jitterMs', 0, 2000); });
1612 clkJitter.addEventListener('change', function () { clkJitter.value = clicker.jitterMs; });
1613 clkHold.addEventListener('input', function () { commitNum(clkHold, 'holdMs', 0, 2000); });
1614 clkHold.addEventListener('change', function () { clkHold.value = clicker.holdMs; });
1615 syncClicker();
1616
1617 /* ----- scan controls ----- */
1618 const scTarget = $('#scTarget'), scPause = $('#scPause'), scStatus = $('#scStatus');
1619 const scMem = $('#scMem'), scMemDetect = $('#scMemDetect'), scType = $('#scType'), scValue = $('#scValue');
1620 const scFirst = $('#scFirst'), scCancel = $('#scCancel');
1621 const scProgress = $('#scProgress'), scBar = $('#scBar');
1622 const scModeInputs = root.querySelectorAll('#scMode input');
1623 const scRefine = $('#scRefine'), scCount = $('#scCount'), scResults = $('#scResults'), scSaved = $('#scSaved');
1624 function getScMode() {
1625 for (let i = 0; i < scModeInputs.length; i++) if (scModeInputs[i].checked) return scModeInputs[i].value;
1626 return 'exact';
1627 }
1628 const scEngineBtns = root.querySelectorAll('#scEngine button');
1629 const scRefineBtns = root.querySelectorAll('#scRefine button');
1630
1631 let scEngineName = 'wasm';
1632 let scTargetId = null; // null → this frame; else a remote frame's SELF_ID
1633 let scTargetWin = null; // that frame's window (proven postMessage channel)
1634 const scTargetList = []; // [{ id, win }] parallel to scTarget options (after the first)
1635 let savedScans = store.get('scan:' + PAGE, []) || [];
1636 let scanActive = false; // a candidate set exists (refine available)
1637 let scanRunning = false; // a scan op is in flight
1638 let pausedLocalView = false;
1639
1640 function scCmd(extra, onProgress) {
1641 const cmd = { engine: scEngineName, type: scType.value };
1642 for (const k in extra) cmd[k] = extra[k];
1643 return sendScan(scTargetId, scTargetWin, cmd, onProgress);
1644 }
1645
1646 // Two independent status lines so they never clobber each other:
1647 // - setConn(): frame / WASM-memory / pause state (top, #scStatus)
1648 // - setScan(): scan progress + result counts (#scCount)
1649 function setConn(kind, text) { scStatus.className = 'status ' + kind; scStatus.textContent = text; }
1650 function setScan(text) { scCount.textContent = text; }
1651
1652 function refreshScanTargets() {
1653 const prev = scTarget.value;
1654 scTargetList.length = 0;
1655 scTarget.textContent = '';
1656 const self = document.createElement('option'); self.value = 'self'; self.textContent = 'This frame';
1657 scTarget.appendChild(self);
1658 // `frames` is only populated in host mode; a detached child just sees itself.
1659 frames.forEach(function (f, src) {
1660 if (!f.id) return;
1661 const idx = scTargetList.push({ id: f.id, win: src }) - 1;
1662 const opt = document.createElement('option');
1663 opt.value = 'f' + idx; opt.textContent = shortUrl(f.url); opt.title = f.url;
1664 scTarget.appendChild(opt);
1665 });
1666 scTarget.value = Array.prototype.some.call(scTarget.options, function (o) { return o.value === prev; }) ? prev : 'self';
1667 applyTargetSelection();
1668 }
1669 function applyTargetSelection() {
1670 const v = scTarget.value;
1671 const ent = (v === 'self') ? null : scTargetList[+v.slice(1)];
1672 scTargetId = ent ? ent.id : null;
1673 scTargetWin = ent ? ent.win : null;
1674 }
1675
1676 // Reflect scanActive / scanRunning onto the buttons.
1677 function updateButtons() {
1678 scFirst.textContent = scanActive ? 'New scan' : 'First scan';
1679 scFirst.disabled = scanRunning;
1680 // Scan mode (Known value / Unknown initial) is locked once a scan exists — it only
1681 // applies to starting a NEW scan. Start a new scan to change it.
1682 scModeInputs.forEach(function (r) { r.disabled = scanRunning || scanActive; });
1683 scCancel.hidden = !scanRunning;
1684 scRefine.hidden = !scanActive;
1685 scRefineBtns.forEach(function (b) { b.disabled = scanRunning || !scanActive; });
1686 scType.disabled = scanRunning;
1687 // The value box stays editable during refine (so "Exact" refine can take a new value);
1688 // it's disabled only while a scan runs, or for a fresh "Unknown initial" scan (no value).
1689 scValue.disabled = scanRunning || (!scanActive && getScMode() === 'unknown');
1690 scMemDetect.disabled = scanRunning;
1691 }
1692
1693 function setEngine(name) {
1694 scEngineName = name;
1695 scEngineBtns.forEach(function (b) { b.classList.toggle('on', b.dataset.engine === name); });
1696 const wasm = (name === 'wasm');
1697 scMemDetect.hidden = !wasm;
1698 scMem.hidden = true;
1699 newScan();
1700 if (wasm) { refreshMemList(); return; }
1701 // object-graph engine: confirm a remote frame is reachable (same short
1702 // connectivity timer as WASM detect; not a scan timeout).
1703 if (!scTargetId) { setConn('idle', 'Walking values reachable from this frame’s window.'); return; }
1704 const id = scTargetId; let settled = false;
1705 setConn('idle', 'Connecting to frame…');
1706 origSetTimeout(function () {
1707 if (settled || scTargetId !== id || scEngineName !== 'object') return;
1708 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.');
1709 }, 2500);
1710 scCmd({ op: 'ping' }).then(function (res) {
1711 if (settled || scTargetId !== id || scEngineName !== 'object') return;
1712 settled = true; setConn(res && res.ok ? 'ok' : 'warn', res && res.ok ? 'Frame connected — walking values reachable from its window.' : 'Frame not responding.');
1713 });
1714 }
1715
1716 // Manual WASM-memory detection (no polling). Updates the memory dropdown + status.
1717 // For a remote target this doubles as the connectivity check: a short real-time
1718 // timer (NOT a scan timeout — scans still wait indefinitely) flips the status to
1719 // "not responding" if the frame never answers, instead of hanging on "Detecting…".
1720 function refreshMemList() {
1721 if (scEngineName !== 'wasm') return;
1722 const id = scTargetId; let settled = false;
1723 setConn('idle', 'Detecting WASM memory…');
1724 if (id) origSetTimeout(function () {
1725 if (settled || scTargetId !== id || scEngineName !== 'wasm') return;
1726 settled = true; scMem.hidden = true;
1727 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.');
1728 }, 2500);
1729 scCmd({ op: 'list-memories' }).then(function (res) {
1730 if (settled || scEngineName !== 'wasm' || scTargetId !== id) return; // stale/superseded
1731 settled = true;
1732 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; }
1733 const mems = res.memories || [];
1734 const prev = scMem.value;
1735 scMem.textContent = '';
1736 mems.forEach(function (m, i) { const o = document.createElement('option'); o.value = String(i); o.textContent = m.label; scMem.appendChild(o); });
1737 if (prev && mems[+prev]) scMem.value = prev;
1738 scMem.hidden = mems.length === 0;
1739 scMemDetect.textContent = mems.length ? '↻ Re-detect WASM memory' : '↻ Detect WASM memory';
1740 if (mems.length === 0) setConn('warn', 'No WASM memory' + (id ? ' in that frame' : '') + ' yet. If the game is still loading, click “Detect” again.');
1741 else setConn('ok', mems.length + ' WASM memor' + (mems.length === 1 ? 'y' : 'ies') + ' found.');
1742 });
1743 }
1744
1745 function newScan() {
1746 scanActive = false;
1747 scResults.textContent = '';
1748 setScan('No scan yet.');
1749 showProgress(false);
1750 updateButtons();
1751 scCmd({ op: 'reset' });
1752 }
1753
1754 function setScanRunning(on) { scanRunning = on; updateButtons(); }
1755
1756 function onScanDone(res) {
1757 setScanRunning(false);
1758 if (!res || !res.ok) { setScan('Scan failed: ' + ((res && res.error) || 'unknown')); return; }
1759 if (res.cancelled) { setScan('Scan cancelled.'); updateButtons(); return; }
1760 scanActive = true;
1761 updateButtons();
1762 if (res.count === -1) {
1763 setScan('Unknown scan armed — change the value in-game, then refine (Up / Down / Changed).');
1764 } else if (res.capped) {
1765 setScan(res.count.toLocaleString() + ' matches — too many to list; refine to narrow.');
1766 } else {
1767 setScan(res.count.toLocaleString() + ' match' + (res.count === 1 ? '' : 'es') + (res.count > 200 ? ' (showing first 200)' : ''));
1768 }
1769 renderRows(res.rows || []);
1770 }
1771
1772 function startScan(cmd, progress) {
1773 if (scanRunning) return;
1774 setScanRunning(true);
1775 setScan(progress);
1776 showProgress(true);
1777 scCmd(cmd, onScanProgress).then(function (res) { showProgress(false); onScanDone(res); });
1778 }
1779 // Progress bar: starts indeterminate (animated stripe); flips to a determinate fill the
1780 // first time the engine reports a real fraction (frac >= 0). frac < 0 stays indeterminate
1781 // (the object-graph walk, whose total isn't known up front).
1782 function showProgress(on) {
1783 scProgress.hidden = !on;
1784 if (on) { scBar.classList.add('indet'); scBar.style.width = '0%'; }
1785 }
1786 function onScanProgress(frac) {
1787 if (typeof frac !== 'number' || frac < 0) return;
1788 scBar.classList.remove('indet');
1789 scBar.style.width = Math.max(0, Math.min(1, frac)) * 100 + '%';
1790 }
1791
1792 function renderRows(rows) {
1793 scResults.textContent = '';
1794 rows.forEach(function (r) {
1795 const row = document.createElement('div'); row.className = 'sc-row'; row.dataset.addr = r.address;
1796 const addr = document.createElement('span'); addr.className = 'sc-addr';
1797 addr.textContent = scanAddressLabel(scEngineName, r.address); addr.title = addr.textContent;
1798 const val = document.createElement('input'); val.className = 'sc-val'; val.value = (r.value == null ? '?' : r.value);
1799 val.addEventListener('change', function () { scCmd({ op: 'write', address: r.address, value: val.value }); });
1800 const save = document.createElement('button'); save.className = 'sc-mini'; save.textContent = '★';
1801 save.title = 'Save this result';
1802 save.addEventListener('click', function () { addSaved(r.address, r.type); });
1803 row.appendChild(addr); row.appendChild(val); row.appendChild(save);
1804 scResults.appendChild(row);
1805 });
1806 }
1807
1808 // live re-read of shown result rows + saved rows (real-time; unaffected by pause)
1809 const scPane = root.querySelector('.pane[data-pane="scan"]');
1810 function pollValues() {
1811 if (scPane && scPane.hidden) return; // only poll while the Scan tab is open
1812 const rowEls = Array.prototype.slice.call(scResults.querySelectorAll('.sc-row'));
1813 const addrs = rowEls.map(function (el) { return el.dataset.addr; });
1814 if (addrs.length) {
1815 scCmd({ op: 'read', addresses: addrs }).then(function (res) {
1816 if (!res || !res.values) return;
1817 const byAddr = {}; res.values.forEach(function (v) { byAddr[v.address] = v.value; });
1818 rowEls.forEach(function (el) {
1819 const inp = el.querySelector('.sc-val');
1820 if (inp && root.activeElement !== inp) inp.value = (byAddr[el.dataset.addr] == null ? '?' : byAddr[el.dataset.addr]);
1821 });
1822 });
1823 }
1824 pollSaved();
1825 }
1826
1827 /* ----- saved list ----- */
1828 function persistSaved() { store.set('scan:' + PAGE, savedScans); }
1829 function addSaved(address, ty) {
1830 savedScans.push({ name: scanAddressLabel(scEngineName, address), engine: scEngineName, type: (ty || scType.value), address: address });
1831 persistSaved(); renderSaved();
1832 }
1833 function renderSaved() {
1834 scSaved.textContent = '';
1835 savedScans.forEach(function (s, i) {
1836 const row = document.createElement('div'); row.className = 'sc-srow'; row.dataset.idx = i;
1837 const name = document.createElement('input'); name.className = 'sc-name'; name.value = s.name;
1838 name.title = scanAddressLabel(s.engine, s.address) + ' (' + s.type + ')';
1839 name.addEventListener('change', function () { s.name = name.value; persistSaved(); });
1840 const val = document.createElement('input'); val.className = 'sc-val'; val.value = '?';
1841 val.addEventListener('change', function () { sendScan(scTargetId, scTargetWin, { engine: s.engine, type: s.type, op: 'write', address: s.address, value: val.value }); });
1842 const del = document.createElement('button'); del.className = 'sc-mini'; del.textContent = '×'; del.title = 'Delete';
1843 del.addEventListener('click', function () { savedScans.splice(i, 1); persistSaved(); renderSaved(); });
1844 row.appendChild(name); row.appendChild(val); row.appendChild(del);
1845 scSaved.appendChild(row);
1846 });
1847 }
1848 function pollSaved() {
1849 const rowEls = Array.prototype.slice.call(scSaved.querySelectorAll('.sc-srow'));
1850 rowEls.forEach(function (el) {
1851 const s = savedScans[+el.dataset.idx]; if (!s) return;
1852 sendScan(scTargetId, scTargetWin, { engine: s.engine, type: s.type, op: 'read', addresses: [s.address] }).then(function (res) {
1853 const inp = el.querySelector('.sc-val'); if (!inp || root.activeElement === inp) return;
1854 const v = res && res.values && res.values[0] ? res.values[0].value : null;
1855 inp.value = (v == null ? '∅' : v);
1856 el.querySelector('.sc-name').classList.toggle('stale', v == null);
1857 });
1858 });
1859 }
1860
1861 // wire scan controls
1862 scEngineBtns.forEach(function (b) { b.addEventListener('click', function () { setEngine(b.dataset.engine); }); });
1863 scTarget.addEventListener('change', function () { applyTargetSelection(); setEngine(scEngineName); });
1864 scMem.addEventListener('change', function () { scCmd({ op: 'set-mem', mem: +scMem.value }).then(newScan); });
1865 scMemDetect.addEventListener('click', function () { refreshMemList(); });
1866 scType.addEventListener('change', function () { const o = scType.options[scType.selectedIndex]; scType.title = o ? o.title : ''; });
1867 scModeInputs.forEach(function (r) { r.addEventListener('change', updateButtons); });
1868 scFirst.addEventListener('click', function () {
1869 if (scanActive) { newScan(); return; } // acts as "New scan" once a scan exists
1870 if (getScMode() === 'unknown') { startScan({ op: 'first-unknown' }, 'Snapshotting…'); return; }
1871 const raw = scValue.value.trim();
1872 if (raw === '') { setScan('Enter a value, or choose “Unknown initial value”.'); return; }
1873 startScan({ op: 'first-exact', value: raw }, 'Scanning…');
1874 });
1875 scCancel.addEventListener('click', function () { setScan('Cancelling…'); scCmd({ op: 'cancel' }); });
1876 scRefineBtns.forEach(function (b) {
1877 b.addEventListener('click', function () {
1878 const crit = b.dataset.crit, raw = scValue.value.trim();
1879 startScan({ op: 'refine', criteria: crit, value: (crit === 'exact' && raw !== '') ? raw : undefined }, 'Refining…');
1880 });
1881 });
1882 scPause.addEventListener('click', function () {
1883 scCmd({ op: 'set-paused', value: !pausedLocalView }).then(function (res) {
1884 pausedLocalView = !!(res && res.paused);
1885 reflectPause();
1886 });
1887 });
1888 function reflectPause() {
1889 scPause.textContent = pausedLocalView ? '▶ Resume' : '⏸ Pause';
1890 scPause.classList.toggle('on', pausedLocalView);
1891 }
1892
1893 scType.title = scType.options[scType.selectedIndex] ? scType.options[scType.selectedIndex].title : '';
1894 renderSaved();
1895 setEngine('wasm');
1896 reflectPause();
1897 const scPoll = origSetInterval(pollValues, 600); // real-time; unaffected by speed/pause
1898
1899 let curMode;
1900 function setMode(m) {
1901 curMode = m;
1902 if (m === 'host') { title.textContent = 'Speedhack'; reattachBtn.hidden = true; refreshFrames(); }
1903 else { title.textContent = 'Speedhack (frame)'; framesBox.hidden = true; reattachBtn.hidden = false; }
1904 }
1905 setMode(mode || 'host');
1906
1907 // minimize (start minimized)
1908 function setMin(m) { panel.classList.toggle('min', m); btnMin.textContent = m ? '▢' : '–'; }
1909 setMin(true);
1910 btnMin.addEventListener('click', function (e) { e.stopPropagation(); setMin(!panel.classList.contains('min')); });
1911
1912 // close + remember dialog
1913 btnClose.addEventListener('click', function (e) { e.stopPropagation(); $('#dlgurl').textContent = PAGE; dlg.hidden = false; });
1914 $('#dlgCancel').addEventListener('click', function () { dlg.hidden = true; });
1915 $('#dlgNo').addEventListener('click', function () { doClose(false); });
1916 $('#dlgYes').addEventListener('click', function () { doClose(true); });
1917 function doClose(remember) {
1918 if (remember) store.set(CLOSED_KEY, true);
1919 Object.keys(hooks).forEach(function (n) { try { hooks[n].uninstall(); } catch (e) {} });
1920 // The host is going away, so there's nothing left to re-attach to. Tell EVERY
1921 // frame (attached or already-detached) to become its own standalone main panel
1922 // rather than a detached one with a dead "re-attach" button. `hostClosed` also
1923 // makes us hand off any iframe that announces itself AFTER this point.
1924 if (isHost) {
1925 hostClosed = true;
1926 frames.forEach(function (f, src) { f.attached = false; postTo(src, { type: 'host-closing' }); });
1927 }
1928 destroyPanel();
1929 }
1930
1931 // drag (and tap-to-expand when minimized)
1932 let dragging = false, moved = false, sx, sy, ox, oy;
1933 bar.addEventListener('pointerdown', function (e) {
1934 if (e.target.closest('button')) return;
1935 dragging = true; moved = false;
1936 const r = panel.getBoundingClientRect();
1937 panel.style.left = r.left + 'px'; panel.style.top = r.top + 'px';
1938 panel.style.right = 'auto'; panel.style.bottom = 'auto';
1939 sx = e.clientX; sy = e.clientY; ox = r.left; oy = r.top;
1940 try { bar.setPointerCapture(e.pointerId); } catch (_) {}
1941 });
1942 bar.addEventListener('pointermove', function (e) {
1943 if (!dragging) return;
1944 const dx = e.clientX - sx, dy = e.clientY - sy;
1945 if (Math.abs(dx) > 4 || Math.abs(dy) > 4) moved = true;
1946 panel.style.left = Math.max(0, Math.min(window.innerWidth - 30, ox + dx)) + 'px';
1947 panel.style.top = Math.max(0, Math.min(window.innerHeight - 20, oy + dy)) + 'px';
1948 });
1949 bar.addEventListener('pointerup', function (e) {
1950 if (!dragging) return; dragging = false;
1951 try { bar.releasePointerCapture(e.pointerId); } catch (_) {}
1952 if (!moved && panel.classList.contains('min')) setMin(false);
1953 });
1954
1955 return {
1956 removeNode: function () { try { origClearInterval(scPoll); } catch (e) {} try { host.remove(); } catch (e) {} if (panelHost === host) panelHost = null; clicker.listening = false; },
1957 refreshFrames: refreshFrames,
1958 refreshScan: refreshScanTargets,
1959 setMode: setMode,
1960 sync: sync,
1961 syncClicker: syncClicker
1962 };
1963 }
1964
1965 /* ------------------------------------------------------------------ *
1966 * Panel lifecycle — build lazily once a host node exists; rebuildable
1967 * (a child may gain/lose a panel as it detaches / re-attaches).
1968 * ------------------------------------------------------------------ */
1969 let panelCtl = null;
1970 let pendingMode = 'host';
1971 function whenBody(fn) {
1972 if (document.body) { fn(); return; }
1973 const obs = new MutationObserver(function () { if (document.body) { obs.disconnect(); fn(); } });
1974 try { obs.observe(document.documentElement, { childList: true, subtree: true }); } catch (e) {}
1975 document.addEventListener('DOMContentLoaded', function () { try { obs.disconnect(); } catch (e) {} fn(); }, { once: true });
1976 }
1977 function ensurePanel(mode) {
1978 pendingMode = mode;
1979 if (panelCtl) { panelCtl.setMode(mode); return; }
1980 whenBody(function () {
1981 if (panelCtl) { panelCtl.setMode(pendingMode); return; }
1982 if (!(document.body || document.documentElement)) return;
1983 panelCtl = buildUI(pendingMode);
1984 });
1985 }
1986 function destroyPanel() {
1987 if (panelCtl) { panelCtl.removeNode(); panelCtl = null; }
1988 }
1989
1990 /* ------------------------------------------------------------------ *
1991 * Boot. Top frame hosts and rolls call; a child announces itself and
1992 * arms a real-time 5s fallback to promote if no host ever answers.
1993 * ------------------------------------------------------------------ */
1994 if (isTop) {
1995 ensurePanel('host');
1996 rollcall();
1997 } else {
1998 postTo(window.top, { type: 'hello', url: location.href, frameId: SELF_ID });
1999 // origSetTimeout (real time) so scaling can't distort the 5s fallback window.
2000 origSetTimeout(function () { if (!gotHost) promoteToHost(); }, 5000);
2001 }
2002})();
2003