float range for js objects, scan progress, ui improvements

AuthorKonata <konata@posteo.jp>
Date
Commit93e495265217436e104414eac7bcc0ed502a4570
Parent40356a1
1 file changed, 88 insertions(+), 21 deletions(-)
Mspeedhack.js
@@ -536,15 +536,27 @@
536536 if (scanHandledQ.length > 400) scanHandled.delete(scanHandledQ.shift());
537537 const replyWin = e.source;
538538 const scmd = m.cmd || m; // command payload is nested under `cmd` (avoids type-field collision)
539- runScanCommand(scmd).then(function (res) {
539+ let lastFrac = -2; // throttle determinate progress to ~2% steps (indeterminate always relayed)
540+ runScanCommand(scmd, function (frac) {
541+ if (frac >= 0 && frac !== 1 && frac - lastFrac < 0.02) return;
542+ lastFrac = frac;
543+ try { if (replyWin) postTo(replyWin, { type: 'scan-progress', reqId: m.reqId, targetFrame: m.from, frac: frac }); } catch (e2) {}
544+ }).then(function (res) {
540545 res.type = 'scan-result'; res.reqId = m.reqId; res.targetFrame = m.from;
541546 try { if (replyWin) postTo(replyWin, res); } catch (e2) {} // reply to the sender directly...
542547 broadcastScanMsg(res); // ...and via window.top (reliable upward)
543548 });
544549 break;
545550 }
551+ case 'scan-progress': { // controller: forward incremental progress to the UI
552+ if (m.targetFrame && m.targetFrame !== SELF_ID) break;
553+ const cb = scanProgress.get(m.reqId);
554+ if (cb) cb(m.frac);
555+ break;
556+ }
546557 case 'scan-result': { // controller: resolve the matching pending request
547558 if (m.targetFrame && m.targetFrame !== SELF_ID) break;
559+ scanProgress.delete(m.reqId);
548560 const resolve = scanPending.get(m.reqId);
549561 if (resolve) { scanPending.delete(m.reqId); resolve(m); }
550562 break;
@@ -781,6 +793,16 @@
781793 if (base < 0) { const lo = base - p, hi = base; return { value: v, match: function (x) { return x > lo && x <= hi; } }; }
782794 const lo = base, hi = base + p; return { value: v, match: function (x) { return x >= lo && x < hi; } };
783795 }
796+ // Union matcher for a type group ('all'/'allint'/'allfloat'): matches if ANY expanded
797+ // type's exact matcher does. Object-graph values are all f64, so this widens a whole
798+ // number like "15" to the float window [15,16) (matching 15.5) the way the WASM backend
799+ // already does per-type — otherwise a group would collapse to typeList[0]'s int matcher.
800+ function makeGroupMatcher(typeList, raw) {
801+ if (raw == null) return null;
802+ const ms = typeList.map(function (t) { return makeExactMatcher(t, raw); }).filter(Boolean);
803+ if (!ms.length) return null;
804+ return { match: function (x) { for (let i = 0; i < ms.length; i++) if (ms[i].match(x)) return true; return false; } };
805+ }
784806 // refine criteria: 'exact' uses the typed-precision matcher; the rest compare a
785807 // fresh read `cur` against the stored previous value `prev`.
786808 function passesCriteria(type, criteria, cur, prev, matcher) {
@@ -848,6 +870,7 @@
848870 if (accept(t, cur, prev)) { count++; if (out.length < SCAN_STORE_CAP) out.push({ off: off, val: cur, ty: t }); else capped = true; }
849871 }
850872 if (off + sz > cap) { ti++; off = 0; }
873+ if (job.onProgress) job.onProgress(Math.min(1, (ti + (cap > 0 ? Math.min(1, off / cap) : 1)) / types.length));
851874 return ti < types.length;
852875 }, function (cancelled) {
853876 if (cancelled) { done({ cancelled: true }); return; }
@@ -893,6 +916,7 @@
893916 let cur; try { cur = SCAN_TYPES[c.ty].get(d, c.off); } catch (e) { continue; }
894917 if (passesCriteria(c.ty, criteria, cur, c.val, mfor(c.ty))) kept.push({ off: c.off, val: cur, ty: c.ty });
895918 }
919+ if (job.onProgress) job.onProgress(src.length ? i / src.length : 1);
896920 return i < src.length;
897921 }, function (cancelled) {
898922 if (cancelled) { done({ cancelled: true }); return; }
@@ -938,7 +962,8 @@
938962 * ------------------------------------------------------------------- */
939963 const objectScan = (function () {
940964 const NODE_CAP = 200000, DEPTH_CAP = 12, BUDGET = 15000; // nodes per event-loop slice
941- let type = 'f64';
965+ let type = 'f64'; // display / default-write type (typeList[0]); values are all f64
966+ let types = ['f64']; // full expanded type list of the current scan (for group matchers)
942967 let candidates = null; // [{ path:[...], val }]
943968 let snapshot = null; // Map(pathKey -> { path, val }) for "unknown initial value"
944969
@@ -973,6 +998,7 @@
973998 }
974999 }
9751000 }
1001+ if (job.onProgress) job.onProgress(-1); // total is unknown up front → indeterminate
9761002 return stack.length > 0 && nodes < NODE_CAP;
9771003 }, done);
9781004 }
@@ -983,8 +1009,8 @@
9831009 // All JS numbers are f64, so the width only tunes equality (exact int vs. float
9841010 // cell); a multi-type selection just uses the first type's matcher here.
9851011 function firstExact(typeList, raw, job, done) {
986- type = typeList[0] || 'f64'; snapshot = null; candidates = null;
987- const matcher = makeExactMatcher(type, raw);
1012+ types = typeList.slice(); type = typeList[0] || 'f64'; snapshot = null; candidates = null;
1013+ const matcher = makeGroupMatcher(types, raw);
9881014 if (!matcher) { done({ error: 'bad value' }); return; }
9891015 const out = []; let count = 0, capped = false;
9901016 walkAsync(function (path, v) { count++; if (out.length < SCAN_STORE_CAP) out.push({ path: path, val: v }); else capped = true; },
@@ -994,7 +1020,7 @@
9941020 });
9951021 }
9961022 function firstUnknown(typeList, job, done) {
997- type = typeList[0] || 'f64'; candidates = null;
1023+ types = typeList.slice(); type = typeList[0] || 'f64'; candidates = null;
9981024 const snap = new Map();
9991025 walkAsync(function (path, v) { snap.set(pathKey(path), { path: path, val: v }); }, null, job, function (cancelled) {
10001026 if (cancelled) { done({ cancelled: true }); return; }
@@ -1003,7 +1029,7 @@
10031029 }
10041030 function materialize(criteria, raw, job, done) {
10051031 if (!snapshot) { done({ error: 'no snapshot' }); return; }
1006- const matcher = (raw != null) ? makeExactMatcher(type, raw) : null;
1032+ const matcher = (raw != null) ? makeGroupMatcher(types, raw) : null;
10071033 const entries = Array.from(snapshot.values());
10081034 const out = []; let count = 0, capped = false, i = 0;
10091035 chunkLoop(job, function () {
@@ -1014,6 +1040,7 @@
10141040 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; }
10151041 entry.val = cur; // re-baseline
10161042 }
1043+ if (job.onProgress) job.onProgress(entries.length ? i / entries.length : 1);
10171044 return i < entries.length;
10181045 }, function (cancelled) {
10191046 if (cancelled) { done({ cancelled: true }); return; }
@@ -1023,7 +1050,7 @@
10231050 function refine(criteria, raw, job, done) {
10241051 if (candidates === null && snapshot !== null) { materialize(criteria, raw, job, done); return; }
10251052 if (candidates === null) { done({ error: 'no scan in progress' }); return; }
1026- const matcher = (raw != null) ? makeExactMatcher(type, raw) : null;
1053+ const matcher = (raw != null) ? makeGroupMatcher(types, raw) : null;
10271054 const kept = [];
10281055 for (let i = 0; i < candidates.length; i++) {
10291056 const c = candidates[i]; const cur = resolve(c.path);
@@ -1072,7 +1099,7 @@
10721099 // cancelled — there are no timeouts anywhere; callers wait until done or cancel.
10731100 const SCAN_ROW_LIMIT = 200; // most rows we ship/render at once
10741101 let scanJob = null;
1075- function runScanCommand(cmd) {
1102+ function runScanCommand(cmd, onProgress) {
10761103 return new Promise(function (resolve) {
10771104 try {
10781105 const eng = scanEngine(cmd.engine);
@@ -1093,7 +1120,7 @@
10931120 }
10941121 case 'first-exact': case 'first-unknown': case 'refine': {
10951122 if (scanJob) scanJob.cancelled = true; // supersede any prior scan
1096- const job = { cancelled: false }; scanJob = job;
1123+ const job = { cancelled: false, onProgress: onProgress || null }; scanJob = job;
10971124 const done = function (r) {
10981125 if (scanJob === job) scanJob = null;
10991126 if (!r || r.error) { resolve({ ok: false, error: (r && r.error) || 'scan failed' }); return; }
@@ -1123,13 +1150,15 @@
11231150 * ------------------------------------------------------------------ */
11241151 let scanSeq = 1;
11251152 const scanPending = new Map(); // reqId -> resolve (controller side)
1153+ const scanProgress = new Map(); // reqId -> onProgress(frac) (controller side, remote scans)
11261154 const scanHandled = new Set(); // "from:reqId" of cmds already run (target side, dedup)
11271155 const scanHandledQ = []; // FIFO to bound scanHandled
1128- function sendScan(targetId, targetWin, cmd) {
1129- if (!targetId) return runScanCommand(cmd); // null → this frame (local engine, no messaging)
1156+ function sendScan(targetId, targetWin, cmd, onProgress) {
1157+ if (!targetId) return runScanCommand(cmd, onProgress); // null → this frame (local engine, no messaging)
11301158 return new Promise(function (resolve) {
11311159 const reqId = scanSeq++;
11321160 scanPending.set(reqId, resolve);
1161+ if (onProgress) scanProgress.set(reqId, onProgress);
11331162 // Nest the command under `cmd` rather than flattening it into the envelope:
11341163 // the scan value-type field is also called `type` and would otherwise overwrite
11351164 // the envelope's `type: 'scan-cmd'`, so the target saw an unknown message type
@@ -1225,6 +1254,15 @@
12251254 .sc-cancel:hover { background: #4a2e1d; color: #ffcf9a; }
12261255 .sc-cancel[hidden], #scMemDetect[hidden] { display: none; }
12271256 #scRefine[hidden] { display: none; }
1257+ .sc-mode { display: flex; gap: 14px; flex-wrap: wrap; }
1258+ .sc-mode .tg { flex: 0 0 auto; }
1259+ .sc-mode input:disabled + span { opacity: .45; }
1260+ #scValue:disabled { opacity: .45; }
1261+ .sc-progress { height: 4px; background: #14161a; border-radius: 3px; overflow: hidden; }
1262+ .sc-progress[hidden] { display: none; }
1263+ .sc-bar { height: 100%; width: 0; background: #2f6df6; transition: width .12s linear; }
1264+ .sc-bar.indet { width: 40%; animation: shx-indet 1s linear infinite; }
1265+ @keyframes shx-indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
12281266 .sc-count { color: #aeb2bb; font-size: 10.5px; }
12291267 .sc-results { display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow: auto; }
12301268 .sc-results:empty { display: none; }
@@ -1328,6 +1366,10 @@
13281366 </div>
13291367 <select id="scMem" class="sc-sel" hidden></select>
13301368 <button class="clk-btn sc-pause" id="scMemDetect" hidden>↻ Detect WASM memory</button>
1369+ <div class="sc-mode" id="scMode">
1370+ <label class="tg"><input type="radio" name="shxScMode" value="exact" checked><span>Known value</span></label>
1371+ <label class="tg" title="Snapshot all values now, then narrow by how they change (Up/Down/Changed) — use when you don't know the value."><input type="radio" name="shxScMode" value="unknown"><span>Unknown initial value</span></label>
1372+ </div>
13311373 <div class="sc-rowx">
13321374 <select id="scType" class="sc-sel sc-type">
13331375 <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 @@
13471389 </div>
13481390 <div class="sc-btns">
13491391 <button class="clk-btn" id="scFirst">First scan</button>
1350- <button class="clk-btn" id="scUnknown" title="Snapshot all values now, then narrow by how they change (Up/Down/Changed) — use when you don't know the value.">Unknown initial</button>
13511392 <button class="clk-btn sc-cancel" id="scCancel" hidden>Cancel</button>
13521393 </div>
13531394 <div class="sc-btns" id="scRefine" hidden>
@@ -1357,6 +1398,7 @@
13571398 <button class="clk-btn" data-crit="increased">▲ Up</button>
13581399 <button class="clk-btn" data-crit="decreased">▼ Down</button>
13591400 </div>
1401+ <div id="scProgress" class="sc-progress" hidden><div id="scBar" class="sc-bar"></div></div>
13601402 <div id="scCount" class="sc-count">No scan yet.</div>
13611403 <div id="scResults" class="sc-results"></div>
13621404 <div class="sc-saved">
@@ -1575,8 +1617,14 @@
15751617 /* ----- scan controls ----- */
15761618 const scTarget = $('#scTarget'), scPause = $('#scPause'), scStatus = $('#scStatus');
15771619 const scMem = $('#scMem'), scMemDetect = $('#scMemDetect'), scType = $('#scType'), scValue = $('#scValue');
1578- const scFirst = $('#scFirst'), scUnknown = $('#scUnknown'), scCancel = $('#scCancel');
1620+ const scFirst = $('#scFirst'), scCancel = $('#scCancel');
1621+ const scProgress = $('#scProgress'), scBar = $('#scBar');
1622+ const scModeInputs = root.querySelectorAll('#scMode input');
15791623 const scRefine = $('#scRefine'), scCount = $('#scCount'), scResults = $('#scResults'), scSaved = $('#scSaved');
1624+ function getScMode() {
1625+ for (let i = 0; i < scModeInputs.length; i++) if (scModeInputs[i].checked) return scModeInputs[i].value;
1626+ return 'exact';
1627+ }
15801628 const scEngineBtns = root.querySelectorAll('#scEngine button');
15811629 const scRefineBtns = root.querySelectorAll('#scRefine button');
15821630
@@ -1589,10 +1637,10 @@
15891637 let scanRunning = false; // a scan op is in flight
15901638 let pausedLocalView = false;
15911639
1592- function scCmd(extra) {
1640+ function scCmd(extra, onProgress) {
15931641 const cmd = { engine: scEngineName, type: scType.value };
15941642 for (const k in extra) cmd[k] = extra[k];
1595- return sendScan(scTargetId, scTargetWin, cmd);
1643+ return sendScan(scTargetId, scTargetWin, cmd, onProgress);
15961644 }
15971645
15981646 // Two independent status lines so they never clobber each other:
@@ -1629,12 +1677,16 @@
16291677 function updateButtons() {
16301678 scFirst.textContent = scanActive ? 'New scan' : 'First scan';
16311679 scFirst.disabled = scanRunning;
1632- scUnknown.hidden = scanActive;
1633- scUnknown.disabled = scanRunning;
1680+ // Scan mode (Known value / Unknown initial) is locked once a scan exists — it only
1681+ // applies to starting a NEW scan. Start a new scan to change it.
1682+ scModeInputs.forEach(function (r) { r.disabled = scanRunning || scanActive; });
16341683 scCancel.hidden = !scanRunning;
16351684 scRefine.hidden = !scanActive;
16361685 scRefineBtns.forEach(function (b) { b.disabled = scanRunning || !scanActive; });
1637- scType.disabled = scanRunning; scValue.disabled = scanRunning;
1686+ scType.disabled = scanRunning;
1687+ // The value box stays editable during refine (so "Exact" refine can take a new value);
1688+ // it's disabled only while a scan runs, or for a fresh "Unknown initial" scan (no value).
1689+ scValue.disabled = scanRunning || (!scanActive && getScMode() === 'unknown');
16381690 scMemDetect.disabled = scanRunning;
16391691 }
16401692
@@ -1694,6 +1746,7 @@
16941746 scanActive = false;
16951747 scResults.textContent = '';
16961748 setScan('No scan yet.');
1749+ showProgress(false);
16971750 updateButtons();
16981751 scCmd({ op: 'reset' });
16991752 }
@@ -1720,7 +1773,20 @@
17201773 if (scanRunning) return;
17211774 setScanRunning(true);
17221775 setScan(progress);
1723- scCmd(cmd).then(onScanDone);
1776+ showProgress(true);
1777+ scCmd(cmd, onScanProgress).then(function (res) { showProgress(false); onScanDone(res); });
1778+ }
1779+ // Progress bar: starts indeterminate (animated stripe); flips to a determinate fill the
1780+ // first time the engine reports a real fraction (frac >= 0). frac < 0 stays indeterminate
1781+ // (the object-graph walk, whose total isn't known up front).
1782+ function showProgress(on) {
1783+ scProgress.hidden = !on;
1784+ if (on) { scBar.classList.add('indet'); scBar.style.width = '0%'; }
1785+ }
1786+ function onScanProgress(frac) {
1787+ if (typeof frac !== 'number' || frac < 0) return;
1788+ scBar.classList.remove('indet');
1789+ scBar.style.width = Math.max(0, Math.min(1, frac)) * 100 + '%';
17241790 }
17251791
17261792 function renderRows(rows) {
@@ -1798,13 +1864,14 @@
17981864 scMem.addEventListener('change', function () { scCmd({ op: 'set-mem', mem: +scMem.value }).then(newScan); });
17991865 scMemDetect.addEventListener('click', function () { refreshMemList(); });
18001866 scType.addEventListener('change', function () { const o = scType.options[scType.selectedIndex]; scType.title = o ? o.title : ''; });
1867+ scModeInputs.forEach(function (r) { r.addEventListener('change', updateButtons); });
18011868 scFirst.addEventListener('click', function () {
18021869 if (scanActive) { newScan(); return; } // acts as "New scan" once a scan exists
1870+ if (getScMode() === 'unknown') { startScan({ op: 'first-unknown' }, 'Snapshotting…'); return; }
18031871 const raw = scValue.value.trim();
1804- if (raw === '') { setScan('Enter a value, or use “Unknown initial”.'); return; }
1872+ if (raw === '') { setScan('Enter a value, or choose “Unknown initial value”.'); return; }
18051873 startScan({ op: 'first-exact', value: raw }, 'Scanning…');
18061874 });
1807- scUnknown.addEventListener('click', function () { startScan({ op: 'first-unknown' }, 'Snapshotting…'); });
18081875 scCancel.addEventListener('click', function () { setScan('Cancelling…'); scCmd({ op: 'cancel' }); });
18091876 scRefineBtns.forEach(function (b) {
18101877 b.addEventListener('click', function () {