float range for js objects, scan progress, ui improvements
Mspeedhack.js
@@ -536,15 +536,27 @@
if (scanHandledQ.length > 400) scanHandled.delete(scanHandledQ.shift());
const replyWin = e.source;
const scmd = m.cmd || m; // command payload is nested under `cmd` (avoids type-field collision)
runScanCommand(scmd).then(function (res) {
let lastFrac = -2; // throttle determinate progress to ~2% steps (indeterminate always relayed)
runScanCommand(scmd, function (frac) {
if (frac >= 0 && frac !== 1 && frac - lastFrac < 0.02) return;
lastFrac = frac;
try { if (replyWin) postTo(replyWin, { type: 'scan-progress', reqId: m.reqId, targetFrame: m.from, frac: frac }); } catch (e2) {}
}).then(function (res) {
res.type = 'scan-result'; res.reqId = m.reqId; res.targetFrame = m.from;
try { if (replyWin) postTo(replyWin, res); } catch (e2) {} // reply to the sender directly...
broadcastScanMsg(res); // ...and via window.top (reliable upward)
});
break;
}
case 'scan-progress': { // controller: forward incremental progress to the UI
if (m.targetFrame && m.targetFrame !== SELF_ID) break;
const cb = scanProgress.get(m.reqId);
if (cb) cb(m.frac);
break;
}
case 'scan-result': { // controller: resolve the matching pending request
if (m.targetFrame && m.targetFrame !== SELF_ID) break;
scanProgress.delete(m.reqId);
const resolve = scanPending.get(m.reqId);
if (resolve) { scanPending.delete(m.reqId); resolve(m); }
break;
@@ -781,6 +793,16 @@
if (base < 0) { const lo = base - p, hi = base; return { value: v, match: function (x) { return x > lo && x <= hi; } }; }
const lo = base, hi = base + p; return { value: v, match: function (x) { return x >= lo && x < hi; } };
}
// Union matcher for a type group ('all'/'allint'/'allfloat'): matches if ANY expanded
// type's exact matcher does. Object-graph values are all f64, so this widens a whole
// number like "15" to the float window [15,16) (matching 15.5) the way the WASM backend
// already does per-type — otherwise a group would collapse to typeList[0]'s int matcher.
function makeGroupMatcher(typeList, raw) {
if (raw == null) return null;
const ms = typeList.map(function (t) { return makeExactMatcher(t, raw); }).filter(Boolean);
if (!ms.length) return null;
return { match: function (x) { for (let i = 0; i < ms.length; i++) if (ms[i].match(x)) return true; return false; } };
}
// refine criteria: 'exact' uses the typed-precision matcher; the rest compare a
// fresh read `cur` against the stored previous value `prev`.
function passesCriteria(type, criteria, cur, prev, matcher) {
@@ -848,6 +870,7 @@
if (accept(t, cur, prev)) { count++; if (out.length < SCAN_STORE_CAP) out.push({ off: off, val: cur, ty: t }); else capped = true; }
}
if (off + sz > cap) { ti++; off = 0; }
if (job.onProgress) job.onProgress(Math.min(1, (ti + (cap > 0 ? Math.min(1, off / cap) : 1)) / types.length));
return ti < types.length;
}, function (cancelled) {
if (cancelled) { done({ cancelled: true }); return; }
@@ -893,6 +916,7 @@
let cur; try { cur = SCAN_TYPES[c.ty].get(d, c.off); } catch (e) { continue; }
if (passesCriteria(c.ty, criteria, cur, c.val, mfor(c.ty))) kept.push({ off: c.off, val: cur, ty: c.ty });
}
if (job.onProgress) job.onProgress(src.length ? i / src.length : 1);
return i < src.length;
}, function (cancelled) {
if (cancelled) { done({ cancelled: true }); return; }
@@ -938,7 +962,8 @@
* ------------------------------------------------------------------- */
const objectScan = (function () {
const NODE_CAP = 200000, DEPTH_CAP = 12, BUDGET = 15000; // nodes per event-loop slice
let type = 'f64';
let type = 'f64'; // display / default-write type (typeList[0]); values are all f64
let types = ['f64']; // full expanded type list of the current scan (for group matchers)
let candidates = null; // [{ path:[...], val }]
let snapshot = null; // Map(pathKey -> { path, val }) for "unknown initial value"
@@ -973,6 +998,7 @@
}
}
}
if (job.onProgress) job.onProgress(-1); // total is unknown up front → indeterminate
return stack.length > 0 && nodes < NODE_CAP;
}, done);
}
@@ -983,8 +1009,8 @@
// All JS numbers are f64, so the width only tunes equality (exact int vs. float
// cell); a multi-type selection just uses the first type's matcher here.
function firstExact(typeList, raw, job, done) {
type = typeList[0] || 'f64'; snapshot = null; candidates = null;
const matcher = makeExactMatcher(type, raw);
types = typeList.slice(); type = typeList[0] || 'f64'; snapshot = null; candidates = null;
const matcher = makeGroupMatcher(types, raw);
if (!matcher) { done({ error: 'bad value' }); return; }
const out = []; let count = 0, capped = false;
walkAsync(function (path, v) { count++; if (out.length < SCAN_STORE_CAP) out.push({ path: path, val: v }); else capped = true; },
@@ -994,7 +1020,7 @@
});
}
function firstUnknown(typeList, job, done) {
type = typeList[0] || 'f64'; candidates = null;
types = typeList.slice(); type = typeList[0] || 'f64'; candidates = null;
const snap = new Map();
walkAsync(function (path, v) { snap.set(pathKey(path), { path: path, val: v }); }, null, job, function (cancelled) {
if (cancelled) { done({ cancelled: true }); return; }
@@ -1003,7 +1029,7 @@
}
function materialize(criteria, raw, job, done) {
if (!snapshot) { done({ error: 'no snapshot' }); return; }
const matcher = (raw != null) ? makeExactMatcher(type, raw) : null;
const matcher = (raw != null) ? makeGroupMatcher(types, raw) : null;
const entries = Array.from(snapshot.values());
const out = []; let count = 0, capped = false, i = 0;
chunkLoop(job, function () {
@@ -1014,6 +1040,7 @@
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; }
entry.val = cur; // re-baseline
}
if (job.onProgress) job.onProgress(entries.length ? i / entries.length : 1);
return i < entries.length;
}, function (cancelled) {
if (cancelled) { done({ cancelled: true }); return; }
@@ -1023,7 +1050,7 @@
function refine(criteria, raw, job, done) {
if (candidates === null && snapshot !== null) { materialize(criteria, raw, job, done); return; }
if (candidates === null) { done({ error: 'no scan in progress' }); return; }
const matcher = (raw != null) ? makeExactMatcher(type, raw) : null;
const matcher = (raw != null) ? makeGroupMatcher(types, raw) : null;
const kept = [];
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i]; const cur = resolve(c.path);
@@ -1072,7 +1099,7 @@
// cancelled — there are no timeouts anywhere; callers wait until done or cancel.
const SCAN_ROW_LIMIT = 200; // most rows we ship/render at once
let scanJob = null;
function runScanCommand(cmd) {
function runScanCommand(cmd, onProgress) {
return new Promise(function (resolve) {
try {
const eng = scanEngine(cmd.engine);
@@ -1093,7 +1120,7 @@
}
case 'first-exact': case 'first-unknown': case 'refine': {
if (scanJob) scanJob.cancelled = true; // supersede any prior scan
const job = { cancelled: false }; scanJob = job;
const job = { cancelled: false, onProgress: onProgress || null }; scanJob = job;
const done = function (r) {
if (scanJob === job) scanJob = null;
if (!r || r.error) { resolve({ ok: false, error: (r && r.error) || 'scan failed' }); return; }
@@ -1123,13 +1150,15 @@
* ------------------------------------------------------------------ */
let scanSeq = 1;
const scanPending = new Map(); // reqId -> resolve (controller side)
const scanProgress = new Map(); // reqId -> onProgress(frac) (controller side, remote scans)
const scanHandled = new Set(); // "from:reqId" of cmds already run (target side, dedup)
const scanHandledQ = []; // FIFO to bound scanHandled
function sendScan(targetId, targetWin, cmd) {
if (!targetId) return runScanCommand(cmd); // null → this frame (local engine, no messaging)
function sendScan(targetId, targetWin, cmd, onProgress) {
if (!targetId) return runScanCommand(cmd, onProgress); // null → this frame (local engine, no messaging)
return new Promise(function (resolve) {
const reqId = scanSeq++;
scanPending.set(reqId, resolve);
if (onProgress) scanProgress.set(reqId, onProgress);
// Nest the command under `cmd` rather than flattening it into the envelope:
// the scan value-type field is also called `type` and would otherwise overwrite
// the envelope's `type: 'scan-cmd'`, so the target saw an unknown message type
@@ -1225,6 +1254,15 @@
.sc-cancel:hover { background: #4a2e1d; color: #ffcf9a; }
.sc-cancel[hidden], #scMemDetect[hidden] { display: none; }
#scRefine[hidden] { display: none; }
.sc-mode { display: flex; gap: 14px; flex-wrap: wrap; }
.sc-mode .tg { flex: 0 0 auto; }
.sc-mode input:disabled + span { opacity: .45; }
#scValue:disabled { opacity: .45; }
.sc-progress { height: 4px; background: #14161a; border-radius: 3px; overflow: hidden; }
.sc-progress[hidden] { display: none; }
.sc-bar { height: 100%; width: 0; background: #2f6df6; transition: width .12s linear; }
.sc-bar.indet { width: 40%; animation: shx-indet 1s linear infinite; }
@keyframes shx-indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
.sc-count { color: #aeb2bb; font-size: 10.5px; }
.sc-results { display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow: auto; }
.sc-results:empty { display: none; }
@@ -1328,6 +1366,10 @@
</div>
<select id="scMem" class="sc-sel" hidden></select>
<button class="clk-btn sc-pause" id="scMemDetect" hidden>↻ Detect WASM memory</button>
<div class="sc-mode" id="scMode">
<label class="tg"><input type="radio" name="shxScMode" value="exact" checked><span>Known value</span></label>
<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>
</div>
<div class="sc-rowx">
<select id="scType" class="sc-sel sc-type">
<option value="i32" title="32-bit signed integer. The most common type — scores, counts, HP, currency in many games.">int32</option>
@@ -1347,7 +1389,6 @@
</div>
<div class="sc-btns">
<button class="clk-btn" id="scFirst">First scan</button>
<button class="clk-btn" id="scUnknown" title="Snapshot all values now, then narrow by how they change (Up/Down/Changed) — use when you don't know the value.">Unknown initial</button>
<button class="clk-btn sc-cancel" id="scCancel" hidden>Cancel</button>
</div>
<div class="sc-btns" id="scRefine" hidden>
@@ -1357,6 +1398,7 @@
<button class="clk-btn" data-crit="increased">▲ Up</button>
<button class="clk-btn" data-crit="decreased">▼ Down</button>
</div>
<div id="scProgress" class="sc-progress" hidden><div id="scBar" class="sc-bar"></div></div>
<div id="scCount" class="sc-count">No scan yet.</div>
<div id="scResults" class="sc-results"></div>
<div class="sc-saved">
@@ -1575,8 +1617,14 @@
/* ----- scan controls ----- */
const scTarget = $('#scTarget'), scPause = $('#scPause'), scStatus = $('#scStatus');
const scMem = $('#scMem'), scMemDetect = $('#scMemDetect'), scType = $('#scType'), scValue = $('#scValue');
const scFirst = $('#scFirst'), scUnknown = $('#scUnknown'), scCancel = $('#scCancel');
const scFirst = $('#scFirst'), scCancel = $('#scCancel');
const scProgress = $('#scProgress'), scBar = $('#scBar');
const scModeInputs = root.querySelectorAll('#scMode input');
const scRefine = $('#scRefine'), scCount = $('#scCount'), scResults = $('#scResults'), scSaved = $('#scSaved');
function getScMode() {
for (let i = 0; i < scModeInputs.length; i++) if (scModeInputs[i].checked) return scModeInputs[i].value;
return 'exact';
}
const scEngineBtns = root.querySelectorAll('#scEngine button');
const scRefineBtns = root.querySelectorAll('#scRefine button');
@@ -1589,10 +1637,10 @@
let scanRunning = false; // a scan op is in flight
let pausedLocalView = false;
function scCmd(extra) {
function scCmd(extra, onProgress) {
const cmd = { engine: scEngineName, type: scType.value };
for (const k in extra) cmd[k] = extra[k];
return sendScan(scTargetId, scTargetWin, cmd);
return sendScan(scTargetId, scTargetWin, cmd, onProgress);
}
// Two independent status lines so they never clobber each other:
@@ -1629,12 +1677,16 @@
function updateButtons() {
scFirst.textContent = scanActive ? 'New scan' : 'First scan';
scFirst.disabled = scanRunning;
scUnknown.hidden = scanActive;
scUnknown.disabled = scanRunning;
// Scan mode (Known value / Unknown initial) is locked once a scan exists — it only
// applies to starting a NEW scan. Start a new scan to change it.
scModeInputs.forEach(function (r) { r.disabled = scanRunning || scanActive; });
scCancel.hidden = !scanRunning;
scRefine.hidden = !scanActive;
scRefineBtns.forEach(function (b) { b.disabled = scanRunning || !scanActive; });
scType.disabled = scanRunning; scValue.disabled = scanRunning;
scType.disabled = scanRunning;
// The value box stays editable during refine (so "Exact" refine can take a new value);
// it's disabled only while a scan runs, or for a fresh "Unknown initial" scan (no value).
scValue.disabled = scanRunning || (!scanActive && getScMode() === 'unknown');
scMemDetect.disabled = scanRunning;
}
@@ -1694,6 +1746,7 @@
scanActive = false;
scResults.textContent = '';
setScan('No scan yet.');
showProgress(false);
updateButtons();
scCmd({ op: 'reset' });
}
@@ -1720,7 +1773,20 @@
if (scanRunning) return;
setScanRunning(true);
setScan(progress);
scCmd(cmd).then(onScanDone);
showProgress(true);
scCmd(cmd, onScanProgress).then(function (res) { showProgress(false); onScanDone(res); });
}
// Progress bar: starts indeterminate (animated stripe); flips to a determinate fill the
// first time the engine reports a real fraction (frac >= 0). frac < 0 stays indeterminate
// (the object-graph walk, whose total isn't known up front).
function showProgress(on) {
scProgress.hidden = !on;
if (on) { scBar.classList.add('indet'); scBar.style.width = '0%'; }
}
function onScanProgress(frac) {
if (typeof frac !== 'number' || frac < 0) return;
scBar.classList.remove('indet');
scBar.style.width = Math.max(0, Math.min(1, frac)) * 100 + '%';
}
function renderRows(rows) {
@@ -1798,13 +1864,14 @@
scMem.addEventListener('change', function () { scCmd({ op: 'set-mem', mem: +scMem.value }).then(newScan); });
scMemDetect.addEventListener('click', function () { refreshMemList(); });
scType.addEventListener('change', function () { const o = scType.options[scType.selectedIndex]; scType.title = o ? o.title : ''; });
scModeInputs.forEach(function (r) { r.addEventListener('change', updateButtons); });
scFirst.addEventListener('click', function () {
if (scanActive) { newScan(); return; } // acts as "New scan" once a scan exists
if (getScMode() === 'unknown') { startScan({ op: 'first-unknown' }, 'Snapshotting…'); return; }
const raw = scValue.value.trim();
if (raw === '') { setScan('Enter a value, or use “Unknown initial”.'); return; }
if (raw === '') { setScan('Enter a value, or choose “Unknown initial value”.'); return; }
startScan({ op: 'first-exact', value: raw }, 'Scanning…');
});
scUnknown.addEventListener('click', function () { startScan({ op: 'first-unknown' }, 'Snapshotting…'); });
scCancel.addEventListener('click', function () { setScan('Cancelling…'); scCmd({ op: 'cancel' }); });
scRefineBtns.forEach(function (b) {
b.addEventListener('click', function () {