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