romdevtools 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +45 -0
- package/package.json +12 -12
- package/src/cores/wasm/bluemsx_libretro.js +1 -1
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +1 -1
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +1 -1
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +1 -1
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +1 -1
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +1 -1
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +1 -1
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +1 -1
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +1 -1
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +25 -7
- package/src/mcp/tools/memory.js +131 -24
- package/src/mcp/tools/record.js +6 -7
- package/src/mcp/tools/run-until.js +8 -2
- package/src/mcp/tools/watch-memory.js +49 -7
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
package/src/mcp/tools/memory.js
CHANGED
|
@@ -3,6 +3,7 @@ import { MemoryRegionToRetro } from "../../host/types.js";
|
|
|
3
3
|
import { jsonContent, safeTool, textContent, writeOutput } from "../util.js";
|
|
4
4
|
import { classifyBytes } from "./classify-region.js";
|
|
5
5
|
import { clusterChanges } from "./diff-cluster.js";
|
|
6
|
+
import { mapNesAddress, mapSnesAddress } from "./disasm.js";
|
|
6
7
|
|
|
7
8
|
// Small reads stay inline (hex) for ergonomics; large reads must go to disk
|
|
8
9
|
// (raw bytes) unless inline:true. The common case — peeking a few bytes of
|
|
@@ -193,9 +194,41 @@ async function memWrite(sessionKey, { region, offset = 0, hex, base64, data, byt
|
|
|
193
194
|
return textContent(`wrote ${buf.length} bytes to ${region}+${offset}`);
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
async function memReadCart(sessionKey, { offset = 0, length = 16, outputPath, inline, echo }) {
|
|
197
|
+
async function memReadCart(sessionKey, { offset = 0, length = 16, cpuAddress, bank, mapper, outputPath, inline, echo }) {
|
|
197
198
|
const host = getHost(sessionKey);
|
|
198
199
|
const rom = host.getCartRom();
|
|
200
|
+
|
|
201
|
+
// Banked CPU-address read (0.28.0 feedback #2a): map {cpuAddress, bank?} →
|
|
202
|
+
// PRG bytes, the inverse of the breakpoint result's bank/prgOffset. Saves
|
|
203
|
+
// the caller the hand-computed `cpuAddr - 0x8000 + bank*0x4000` arithmetic
|
|
204
|
+
// that bit them twice. NES + SNES today (reuses the disasm mappers).
|
|
205
|
+
if (cpuAddress != null) {
|
|
206
|
+
let m;
|
|
207
|
+
if (rom.platform === "nes") {
|
|
208
|
+
m = mapNesAddress(rom.raw, cpuAddress >>> 0, length, bank);
|
|
209
|
+
} else if (rom.platform === "snes") {
|
|
210
|
+
m = mapSnesAddress(rom.raw, cpuAddress >>> 0, length, mapper);
|
|
211
|
+
} else {
|
|
212
|
+
throw new Error(`memory({op:'readCart', cpuAddress}): banked CPU-address mapping is NES/SNES only (got '${rom.platform}'). Use a flat 'offset' for this platform.`);
|
|
213
|
+
}
|
|
214
|
+
const hex = Array.from(m.bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
215
|
+
const meta = {
|
|
216
|
+
platform: rom.platform,
|
|
217
|
+
cpuAddress: "0x" + (cpuAddress >>> 0).toString(16).toUpperCase(),
|
|
218
|
+
...(bank != null ? { bank } : {}),
|
|
219
|
+
fileOffset: "0x" + m.fileOffset.toString(16).toUpperCase(),
|
|
220
|
+
prgOffset: "0x" + (m.fileOffset - (m.prgFileStart ?? 0)).toString(16).toUpperCase(),
|
|
221
|
+
length: m.bytes.length,
|
|
222
|
+
note: m.note,
|
|
223
|
+
};
|
|
224
|
+
if (outputPath) {
|
|
225
|
+
const { path, bytes: written } = writeOutput(Uint8Array.from(m.bytes), { outputPath, what: "readCartRom" });
|
|
226
|
+
if (echo === false) return jsonContent({ ...meta, path, bytes: written });
|
|
227
|
+
return jsonContent({ ...meta, path, bytes: written, hex });
|
|
228
|
+
}
|
|
229
|
+
return jsonContent({ ...meta, hex });
|
|
230
|
+
}
|
|
231
|
+
|
|
199
232
|
if (offset >= rom.bytes.length) {
|
|
200
233
|
throw new Error(`readCartRom: offset ${offset} is past the end of the ${rom.platform} ROM (size ${rom.bytes.length}, header skipped ${rom.headerSkipped}).`);
|
|
201
234
|
}
|
|
@@ -293,23 +326,38 @@ async function memDiffRuns(sessionKey, { region, frames = 60, portsA, portsB, of
|
|
|
293
326
|
});
|
|
294
327
|
}
|
|
295
328
|
|
|
296
|
-
async function memDiff(sessionKey, { region, name = "default", view = "summary", maxChanges = 4096, maxClusters = 64, gap = 4, minDelta }) {
|
|
329
|
+
async function memDiff(sessionKey, { region, name = "default", view = "summary", maxChanges = 4096, maxClusters = 64, gap = 4, minDelta, changeDir, beforeMin, beforeMax, afterMin, afterMax, deltaEq, outputPath, echo = true }) {
|
|
297
330
|
const host = getHost(sessionKey);
|
|
298
331
|
const snap = memSnapshots(sessionKey).get(snapKey(region, name));
|
|
299
332
|
if (!snap) throw new Error(`memory({op:'diff'}): no snapshot named '${name}' for region '${region}'. Call memory({op:'snapshot', region, name}) first.`);
|
|
300
333
|
const now = host.readMemory(region, snap.offset, snap.bytes.length);
|
|
301
334
|
|
|
302
|
-
// Collect changed offsets once
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
// (0.27.0
|
|
335
|
+
// Collect changed offsets once, applying server-side predicate filters so
|
|
336
|
+
// the lives/score/ammo hunt is ONE call instead of dumping the whole diff
|
|
337
|
+
// and filtering client-side (0.28.0 feedback #3). All filters AND together:
|
|
338
|
+
// minDelta — |after-before| >= minDelta (drop small wiggles; 0.27.0 #5)
|
|
339
|
+
// changeDir — 'dec' (after<before) | 'inc' (after>before)
|
|
340
|
+
// deltaEq — after-before === deltaEq EXACTLY (signed; e.g. -1 for "lost one life")
|
|
341
|
+
// beforeMin/Max, afterMin/Max — value-range gates on the old/new byte
|
|
342
|
+
// Example: a 537-byte death diff → the ~3 "decreased by exactly 1 from a
|
|
343
|
+
// small value" rows with {changeDir:'dec', beforeMax:9, deltaEq:-1}.
|
|
306
344
|
const changedOffsets = [];
|
|
307
345
|
for (let i = 0; i < snap.bytes.length; i++) {
|
|
308
|
-
|
|
309
|
-
if (
|
|
346
|
+
const b = snap.bytes[i], a = now[i];
|
|
347
|
+
if (b === a) continue;
|
|
348
|
+
if (minDelta != null && Math.abs(a - b) < minDelta) continue;
|
|
349
|
+
if (changeDir === "dec" && !(a < b)) continue;
|
|
350
|
+
if (changeDir === "inc" && !(a > b)) continue;
|
|
351
|
+
if (deltaEq != null && (a - b) !== deltaEq) continue;
|
|
352
|
+
if (beforeMin != null && b < beforeMin) continue;
|
|
353
|
+
if (beforeMax != null && b > beforeMax) continue;
|
|
354
|
+
if (afterMin != null && a < afterMin) continue;
|
|
355
|
+
if (afterMax != null && a > afterMax) continue;
|
|
310
356
|
changedOffsets.push(i);
|
|
311
357
|
}
|
|
312
358
|
const changedCount = changedOffsets.length;
|
|
359
|
+
const filtered = (changeDir != null || deltaEq != null || beforeMin != null ||
|
|
360
|
+
beforeMax != null || afterMin != null || afterMax != null);
|
|
313
361
|
|
|
314
362
|
if (view === "raw") {
|
|
315
363
|
const changes = changedOffsets.slice(0, maxChanges).map((i) => ({
|
|
@@ -318,11 +366,13 @@ async function memDiff(sessionKey, { region, name = "default", view = "summary",
|
|
|
318
366
|
before: snap.bytes[i].toString(16).padStart(2, "0"),
|
|
319
367
|
after: now[i].toString(16).padStart(2, "0"),
|
|
320
368
|
}));
|
|
321
|
-
|
|
369
|
+
const result = {
|
|
322
370
|
region, name, view, baseOffset: snap.offset, length: snap.bytes.length,
|
|
323
|
-
changedCount
|
|
324
|
-
|
|
325
|
-
|
|
371
|
+
...(filtered ? { filterMatches: changedCount } : { changedCount }),
|
|
372
|
+
changes,
|
|
373
|
+
...(changedCount > changes.length ? { truncated: true, note: `${changedCount} ${filtered ? "matching " : ""}bytes changed; showing first ${changes.length} (raise maxChanges).` } : {}),
|
|
374
|
+
};
|
|
375
|
+
return diffOut(result, { outputPath, echo, region, heavyKey: "changes", count: changedCount });
|
|
326
376
|
}
|
|
327
377
|
|
|
328
378
|
// SUMMARY: cluster adjacent changes (within `gap`) into ranges + stride.
|
|
@@ -353,18 +403,33 @@ async function memDiff(sessionKey, { region, name = "default", view = "summary",
|
|
|
353
403
|
}
|
|
354
404
|
return entry;
|
|
355
405
|
});
|
|
356
|
-
|
|
406
|
+
const result = {
|
|
357
407
|
region, name, view, baseOffset: snap.offset, length: snap.bytes.length,
|
|
358
|
-
changedCount, clusterCount: clusters.length,
|
|
408
|
+
...(filtered ? { filterMatches: changedCount } : { changedCount }), clusterCount: clusters.length,
|
|
359
409
|
clusters: out,
|
|
360
410
|
...(stride !== null ? { stride: "0x" + stride.toString(16), strideHint: strideNote } : {}),
|
|
361
411
|
...(clusters.length > out.length ? { truncated: true } : {}),
|
|
362
412
|
note: changedCount === 0
|
|
363
|
-
? "Nothing changed."
|
|
364
|
-
: `${changedCount} bytes changed in ${clusters.length} cluster(s). ` +
|
|
413
|
+
? (filtered ? "No changed byte matched the filters (try loosening changeDir/deltaEq/before*/after*)." : "Nothing changed.")
|
|
414
|
+
: `${changedCount} ${filtered ? "matching " : ""}bytes changed in ${clusters.length} cluster(s). ` +
|
|
365
415
|
(stride !== null ? strideNote + " " : "") +
|
|
366
|
-
"Use view:'raw' for exact before/after bytes (or narrow with a tighter event window). For 'find the address of value X' use memory({op:'search'}), not diff.",
|
|
367
|
-
}
|
|
416
|
+
"Use view:'raw' for exact before/after bytes (or narrow with a tighter event window / the changeDir/deltaEq/before*/after* filters). For 'find the address of value X' use memory({op:'search'}), not diff.",
|
|
417
|
+
};
|
|
418
|
+
return diffOut(result, { outputPath, echo, region, heavyKey: "clusters", count: changedCount });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Honor outputPath/echo for diff results, mirroring memRead (0.28.0 feedback
|
|
422
|
+
// #2): write the FULL JSON to outputPath regardless of size; with echo:false
|
|
423
|
+
// return only the slim envelope (counts + path), dropping the heavy array so a
|
|
424
|
+
// large diff never streams through context.
|
|
425
|
+
function diffOut(result, { outputPath, echo, region, heavyKey, count }) {
|
|
426
|
+
if (!outputPath) return jsonContent(result);
|
|
427
|
+
const { path, bytes } = writeOutput(JSON.stringify(result, null, 2), { outputPath, what: `diff(${region})` });
|
|
428
|
+
if (echo === false) {
|
|
429
|
+
const { [heavyKey]: _omit, ...slim } = result;
|
|
430
|
+
return jsonContent({ ...slim, path, bytes, echo: false, note: `Full diff written to ${path} (${count} changes); '${heavyKey}' omitted (echo:false).` });
|
|
431
|
+
}
|
|
432
|
+
return jsonContent({ ...result, path, bytes });
|
|
368
433
|
}
|
|
369
434
|
|
|
370
435
|
// diffState lives in the `state` tool (state({op:'diff'})).
|
|
@@ -471,6 +536,37 @@ async function memSearch(sessionKey, { value, size = 1, as = "raw", region = "sy
|
|
|
471
536
|
});
|
|
472
537
|
}
|
|
473
538
|
|
|
539
|
+
// op:'searchUnknown' — the Cheat-Engine UNKNOWN-INITIAL-VALUE hunt: seed the
|
|
540
|
+
// candidate set to the WHOLE region (every size-aligned offset, baselined to
|
|
541
|
+
// its current value), with NO value filter. Then narrow across in-game events
|
|
542
|
+
// with searchNext compare:'dec'/'inc'/'unchanged'/'changed'/'gt'/'lt'. This is
|
|
543
|
+
// the canonical "find the lives/score/timer address you can't see" loop, which
|
|
544
|
+
// op:'search' (requires a value) can't do. (0.28.0 feedback #1.)
|
|
545
|
+
async function memSearchUnknown(sessionKey, { size = 1, as = "raw", region = "system_ram", name = "default", maxCandidates = 64 }) {
|
|
546
|
+
const host = getHost(sessionKey);
|
|
547
|
+
if (as === "digits") throw new Error("memory({op:'searchUnknown'}): as:'digits' needs a value; use as:'raw' or 'bcd' for an unknown-value hunt.");
|
|
548
|
+
const info = REGION_INFO[region] ?? {};
|
|
549
|
+
const little = (info.endianness ?? genericEndianness(host.status.platform)) !== "big";
|
|
550
|
+
const buf = host.readMemory(region, 0, regionLength(host, region, 0));
|
|
551
|
+
const s = { region, size, little, as, digitLen: 0 };
|
|
552
|
+
// Seed EVERY size-aligned offset; baseline each to its current decoded
|
|
553
|
+
// value so the first searchNext relative compare works immediately.
|
|
554
|
+
const candidates = [];
|
|
555
|
+
const prevMap = new Map();
|
|
556
|
+
for (let i = 0; i + size <= buf.length; i += size) {
|
|
557
|
+
const cur = decodeAt(buf, i, s);
|
|
558
|
+
if (cur === null) continue;
|
|
559
|
+
candidates.push(i);
|
|
560
|
+
prevMap.set(i, cur);
|
|
561
|
+
}
|
|
562
|
+
searchSessions(sessionKey).set(name, { ...s, addrs: Uint32Array.from(candidates), prev: prevMap, kMap: null });
|
|
563
|
+
return jsonContent({
|
|
564
|
+
searchId: name, region, size, as, mode: "unknown",
|
|
565
|
+
count: candidates.length,
|
|
566
|
+
note: `Seeded ${candidates.length} candidates (the whole region, no value filter). Now cause the value to change in-game, then narrow with memory({op:'searchNext', name:'${name}', compare:'dec'|'inc'|'unchanged'|'changed'|'gt'|'lt'}) — e.g. 'dec' after losing a life, 'unchanged' across a frame where it shouldn't move. Repeat until 1-2 remain, then confirm with op:'write'.`,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
474
570
|
async function memSearchNext(sessionKey, { compare, value, name = "default", maxCandidates = 64 }) {
|
|
475
571
|
const host = getHost(sessionKey);
|
|
476
572
|
const s = searchSessions(sessionKey).get(name);
|
|
@@ -542,13 +638,17 @@ export function registerMemoryTools(server, z, sessionKey) {
|
|
|
542
638
|
"• op:'diff' — compare a region against a snapshot baseline → the CHANGED bytes. DEFAULT `view:'summary'` is a CLUSTERED summary (+ stride detection — '4 islands at stride 0x80' = a struct array) so a churny gameplay diff doesn't flood context; `view:'raw'` = the per-byte before/after list.\n" +
|
|
543
639
|
"• op:'classify' — heuristically classify the bytes at an offset BEFORE you trust a 'found table'. **Kills the classic trap: a run that 'matches' your stats is often ASCII TEXT (bytes 82/79/68 = 'ROD' from a taunt string) or code.** Returns looksLike/printableRatio/entropy/asciiPreview/confidence.\n" +
|
|
544
640
|
"• op:'search' — seed the iterative RAM value search (Cheat Engine / RetroArch style): all addresses currently holding `value` (`size` 1/2/4 bytes, region's endianness). The primitive for 'the screen shows X, find its RAM address' — better than snapshot+diff for this. STORED ≠ DISPLAYED is common — `as:'bcd'` (packed BCD scores) and `as:'digits'` (one byte per on-screen digit at ANY constant tile base, auto-detected per candidate) search those representations directly; for displayed−1 lives or ÷10 scores just seed the transformed number.\n" +
|
|
641
|
+
"• op:'searchUnknown' — the UNKNOWN-INITIAL-VALUE hunt (Cheat Engine's 'Unknown initial value'): seed the WHOLE region as candidates with NO value, then narrow across in-game events with op:'searchNext' compare 'dec'/'inc'/'unchanged'/'changed'/'gt'/'lt'. THE way to find a value you can't see (lives/timer/ammo not on the HUD): searchUnknown → lose a life → searchNext compare:'dec' → repeat. Use this when you don't know the number; use op:'search' when you do.\n" +
|
|
545
642
|
"• op:'searchNext' — narrow the active candidate list against CURRENT memory. `compare`: 'eq'/'gt'/'lt' (need `value`), 'changed'/'unchanged'/'inc'/'dec' (vs the previous read — usable as the FIRST narrow too; baselines are recorded at seed). Comparisons happen in the seed's `as` representation. Repeat until 1-2 remain, then confirm with op:'write'. (For values an INPUT drives — position, velocity — op:'diffRuns' is usually one call instead of a narrowing loop.)",
|
|
546
643
|
{
|
|
547
|
-
op: z.enum(["read", "write", "readCart", "snapshot", "diff", "diffRuns", "classify", "search", "searchNext"])
|
|
548
|
-
.describe("read=bytes→hex; write=hex/base64→region; readCart=loaded cart ROM image; snapshot=capture a baseline; diff=changed bytes vs a baseline; diffRuns=run the SAME start state twice under two different held inputs and return only the DIVERGENT bytes (THE input→RAM mapping primitive — replaces save/run/dump/restore/run/dump/python-diff); classify=what kind of data is here; search=seed a value search; searchNext=narrow
|
|
644
|
+
op: z.enum(["read", "write", "readCart", "snapshot", "diff", "diffRuns", "classify", "search", "searchUnknown", "searchNext"])
|
|
645
|
+
.describe("read=bytes→hex; write=hex/base64→region; readCart=loaded cart ROM image; snapshot=capture a baseline; diff=changed bytes vs a baseline; diffRuns=run the SAME start state twice under two different held inputs and return only the DIVERGENT bytes (THE input→RAM mapping primitive — replaces save/run/dump/restore/run/dump/python-diff); classify=what kind of data is here; search=seed a value search (you know the number); searchUnknown=seed the whole region (you DON'T know the number); searchNext=narrow either."),
|
|
549
646
|
region: z.enum(REGIONS).optional().describe("Memory region. Required for read/write/snapshot/diff; defaults to system_ram for classify/search. (readCart targets the cart ROM image, not a region.)"),
|
|
550
647
|
offset: z.number().int().min(0).default(0).describe("Byte offset within the region (read/write/snapshot/classify) or the cart ROM image (readCart)."),
|
|
551
648
|
length: z.number().int().min(1).max(1 << 20).optional().describe("Bytes to read (max 1MB). op:read default 1; op:readCart default 16; op:snapshot default = whole region from offset; op:classify default 256."),
|
|
649
|
+
cpuAddress: z.number().int().min(0).optional().describe("op:readCart (NES/SNES) — read by a BANKED CPU ADDRESS instead of a flat offset (the inverse of the breakpoint result's bank/prgOffset). e.g. read a jump table at $8654 in bank 6: {op:'readCart', cpuAddress:0x8654, bank:6}. A $C000+ NES address resolves to the fixed top bank. Saves the cpuAddr-0x8000+bank*0x4000 hand-arithmetic."),
|
|
650
|
+
bank: z.number().int().min(0).optional().describe("op:readCart with cpuAddress — which 16KB PRG bank is mapped into the switchable $8000-$BFFF window (NES). Ignored for $C000+ (fixed top bank) and for non-banked ROMs."),
|
|
651
|
+
mapper: z.enum(["lorom", "hirom"]).optional().describe("op:readCart with cpuAddress (SNES) — force LoROM/HiROM mapping if auto-detect is wrong."),
|
|
552
652
|
offsets: offsetsShape.optional().describe("op:read BATCH — a list of addresses (each read `length` bytes, default 1) or {offset,length} objects → reads:[{offset,length,hex}]. Takes precedence over offset/length."),
|
|
553
653
|
// write
|
|
554
654
|
hex: z.string().optional().describe("op:write — hex string, e.g. 'deadbeef' (even length)."),
|
|
@@ -563,6 +663,12 @@ export function registerMemoryTools(server, z, sessionKey) {
|
|
|
563
663
|
maxClusters: z.number().int().min(1).max(4096).default(64).describe("op:diff summary view — cap the cluster list (clusterCount is the true total)."),
|
|
564
664
|
gap: z.number().int().min(1).max(256).default(4).describe("op:diff summary view — merge changed bytes within this many bytes into one cluster (default 4)."),
|
|
565
665
|
minDelta: z.number().int().min(1).max(255).optional().describe("op:diff — ignore changes where |after-before| < minDelta (filters RNG/counter wiggle so a position byte that moved by the entity's speed stands out)."),
|
|
666
|
+
changeDir: z.enum(["inc", "dec"]).optional().describe("op:diff — keep only bytes that went UP ('inc', after>before) or DOWN ('dec', after<before). The lives/score/ammo hunt: a death window's 'dec' bytes are the candidates."),
|
|
667
|
+
deltaEq: z.number().int().min(-255).max(255).optional().describe("op:diff — keep only bytes whose signed change (after-before) is EXACTLY this. e.g. deltaEq:-1 = 'decreased by one' (lost a life); deltaEq:10 = '+10 score tick'."),
|
|
668
|
+
beforeMin: z.number().int().min(0).max(255).optional().describe("op:diff — keep only bytes whose BEFORE value was >= this."),
|
|
669
|
+
beforeMax: z.number().int().min(0).max(255).optional().describe("op:diff — keep only bytes whose BEFORE value was <= this (e.g. beforeMax:9 = a small counter like lives, not a coordinate)."),
|
|
670
|
+
afterMin: z.number().int().min(0).max(255).optional().describe("op:diff — keep only bytes whose AFTER value was >= this."),
|
|
671
|
+
afterMax: z.number().int().min(0).max(255).optional().describe("op:diff — keep only bytes whose AFTER value was <= this."),
|
|
566
672
|
frames: z.number().int().min(1).max(100000).default(60).describe("op:diffRuns — frames to run EACH scenario from the same start state."),
|
|
567
673
|
portsA: z.array(z.record(z.string(), z.boolean())).max(2).optional().describe("op:diffRuns — held input for run A (e.g. [{right:true}]). Default released."),
|
|
568
674
|
portsB: z.array(z.record(z.string(), z.boolean())).max(2).optional().describe("op:diffRuns — held input for run B. Default released — A-vs-idle is the classic 'which byte does this input drive?' probe."),
|
|
@@ -573,9 +679,9 @@ export function registerMemoryTools(server, z, sessionKey) {
|
|
|
573
679
|
compare: z.enum(["eq", "changed", "unchanged", "inc", "dec", "gt", "lt"]).optional().describe("op:searchNext — eq=now equals `value`; changed/unchanged vs the last read; inc/dec=went up/down. All of these work as the FIRST narrow too (baselines are recorded at seed). gt/lt=now >/< `value`."),
|
|
574
680
|
maxCandidates: z.number().int().min(1).max(8192).default(64).describe("op:search/searchNext — cap the candidates RETURNED (the full list is kept server-side; `count` is the true total)."),
|
|
575
681
|
// shared output
|
|
576
|
-
outputPath: z.string().optional().describe(`op:read/readCart — write RAW bytes here. Required for reads >${INLINE_HEX_LIMIT}B unless inline. Small reads honor it too (writes file AND returns hex), so 'dump to disk then diff two files' works at any size. (Ignored with offsets.)
|
|
682
|
+
outputPath: z.string().optional().describe(`op:read/readCart — write RAW bytes here. Required for reads >${INLINE_HEX_LIMIT}B unless inline. Small reads honor it too (writes file AND returns hex), so 'dump to disk then diff two files' works at any size. (Ignored with offsets.) op:diff — write the FULL diff JSON here regardless of size (so a big diff routes to YOUR path, not a harness path).`),
|
|
577
683
|
inline: z.boolean().default(false).describe(`op:read/readCart — for reads >${INLINE_HEX_LIMIT}B, return the hex in the response instead of writing to disk.`),
|
|
578
|
-
echo: z.boolean().default(true).describe("op:read/readCart with outputPath — false = return only {path, bytes} with NO inline hex (keeps a 2-4KB dump out of context; the raw bytes are in the file)."),
|
|
684
|
+
echo: z.boolean().default(true).describe("op:read/readCart with outputPath — false = return only {path, bytes} with NO inline hex (keeps a 2-4KB dump out of context; the raw bytes are in the file). op:diff with outputPath — false = return only the slim envelope (counts + path), omitting the changes/clusters array."),
|
|
579
685
|
},
|
|
580
686
|
safeTool(async (args) => {
|
|
581
687
|
switch (args.op) {
|
|
@@ -599,9 +705,10 @@ export function registerMemoryTools(server, z, sessionKey) {
|
|
|
599
705
|
}
|
|
600
706
|
case "classify": return await memClassify(sessionKey, args);
|
|
601
707
|
case "search": {
|
|
602
|
-
if (args.value == null) throw new Error("memory({op:'search'}): `value` is required.");
|
|
708
|
+
if (args.value == null) throw new Error("memory({op:'search'}): `value` is required (use op:'searchUnknown' for an unknown-value hunt).");
|
|
603
709
|
return await memSearch(sessionKey, args);
|
|
604
710
|
}
|
|
711
|
+
case "searchUnknown": return await memSearchUnknown(sessionKey, args);
|
|
605
712
|
case "searchNext": {
|
|
606
713
|
if (!args.compare) throw new Error("memory({op:'searchNext'}): `compare` is required.");
|
|
607
714
|
return await memSearchNext(sessionKey, args);
|
|
@@ -629,7 +736,7 @@ function searchSessions(key) { let m = _searchSessions.get(key); if (!m) { m = n
|
|
|
629
736
|
/** @type {Map<string, Map<string, {offset:number, bytes:Uint8Array}>>} */
|
|
630
737
|
const _memSnaps = new Map();
|
|
631
738
|
function memSnapshots(key) { let m = _memSnaps.get(key); if (!m) { m = new Map(); _memSnaps.set(key, m); } return m; }
|
|
632
|
-
const snapKey = (region, name) => region + "
|
|
739
|
+
const snapKey = (region, name) => region + "" + name;
|
|
633
740
|
|
|
634
741
|
/** Bytes from `offset` to the end of the region — for a whole-region snapshot
|
|
635
742
|
* when no explicit length is given. Uses the core-reported region size. */
|
package/src/mcp/tools/record.js
CHANGED
|
@@ -12,13 +12,12 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { getHost } from "../state.js";
|
|
14
14
|
import { jsonContent, safeTool } from "../util.js";
|
|
15
|
-
import { MemoryRegionToRetro } from "../../host/types.js";
|
|
16
|
-
|
|
17
|
-
// Single source of truth for memorySamples regions — the same canonical set
|
|
18
|
-
// readMemory accepts. Previously hardcoded to 8 NES regions, so Genesis and
|
|
19
|
-
// hardware-register regions (nes_apu_regs, etc.) couldn't be batch-sampled.
|
|
20
|
-
const SAMPLE_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
|
|
21
15
|
|
|
16
|
+
// memorySamples regions accept the same canonical set readMemory accepts (incl.
|
|
17
|
+
// hardware-register regions like nes_apu_regs). The region is a runtime-validated
|
|
18
|
+
// string rather than an inlined ~62-value schema enum — the per-sample
|
|
19
|
+
// host.readMemory(region,…) lookup throws on an unknown region with a clear
|
|
20
|
+
// message, so the schema enum was pure deferred-load weight (0.28.0 feedback #5).
|
|
22
21
|
export function registerRecordTools(server, z, sessionKey) {
|
|
23
22
|
const inputShape = z.object({
|
|
24
23
|
up: z.boolean().optional(), down: z.boolean().optional(),
|
|
@@ -54,7 +53,7 @@ export function registerRecordTools(server, z, sessionKey) {
|
|
|
54
53
|
.array(
|
|
55
54
|
z.object({
|
|
56
55
|
label: z.string(),
|
|
57
|
-
region: z.
|
|
56
|
+
region: z.string().describe("memory region (full readMemory set incl. hardware registers; validated at runtime)"),
|
|
58
57
|
offset: z.number().int().min(0),
|
|
59
58
|
length: z.number().int().min(1).max(256),
|
|
60
59
|
}),
|
|
@@ -13,9 +13,15 @@ import { jsonContent, safeTool } from "../util.js";
|
|
|
13
13
|
import { attachObserverFrame } from "./watch-memory.js";
|
|
14
14
|
|
|
15
15
|
export function registerRunUntilTools(server, z, sessionKey) {
|
|
16
|
+
// Condition `region` is a runtime-validated string, not a schema enum. It was
|
|
17
|
+
// an inlined 8-value list — which both bloated the schema AND silently rejected
|
|
18
|
+
// valid non-NES regions (genesis_*, c64_*, *_apu_regs) that host.readMemory
|
|
19
|
+
// accepts. The readMemory(region,…) call in the handler validates and throws a
|
|
20
|
+
// clear message on an unknown region (full canonical set, same as `memory`).
|
|
21
|
+
const regionStr = z.string().describe("memory region (full readMemory set, e.g. system_ram, nes_oam, genesis_vram, c64_color_ram; validated at runtime)");
|
|
16
22
|
const memoryCondition = z.object({
|
|
17
23
|
type: z.literal("memory"),
|
|
18
|
-
region:
|
|
24
|
+
region: regionStr,
|
|
19
25
|
offset: z.number().int().min(0),
|
|
20
26
|
equals: z.number().int().min(0).max(255).optional(),
|
|
21
27
|
notEquals: z.number().int().min(0).max(255).optional(),
|
|
@@ -24,7 +30,7 @@ export function registerRunUntilTools(server, z, sessionKey) {
|
|
|
24
30
|
|
|
25
31
|
const memoryChangedCondition = z.object({
|
|
26
32
|
type: z.literal("memoryChanged"),
|
|
27
|
-
region:
|
|
33
|
+
region: regionStr,
|
|
28
34
|
offset: z.number().int().min(0),
|
|
29
35
|
length: z.number().int().min(1).max(8192).default(1),
|
|
30
36
|
}).describe("Stop when memory[region][offset..offset+length] changes from its initial value.");
|
|
@@ -139,6 +139,19 @@ export function makePressDriver(host, presses) {
|
|
|
139
139
|
// never disagree again.
|
|
140
140
|
const MEMORY_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
|
|
141
141
|
|
|
142
|
+
// A region param that does NOT inline the full ~62-value enum into the JSON
|
|
143
|
+
// schema. The enum array is ~214 tokens PER param site; inlining it on every
|
|
144
|
+
// secondary region sub-param across this file was the dominant tool-schema
|
|
145
|
+
// bloat (0.28.0 feedback #5). Used on SECONDARY/sub params; the PRIMARY region
|
|
146
|
+
// inputs keep z.enum so the full list stays discoverable where the region IS
|
|
147
|
+
// the choice. A plain string — validated at RUNTIME by the handler (the
|
|
148
|
+
// host.readMemory / MemoryRegionToRetro lookup throws on an unknown region with
|
|
149
|
+
// a clear message), so dropping the schema enum here costs no safety.
|
|
150
|
+
// NOTE: `z` is passed into registerWatchMemoryTools (not a module import), so
|
|
151
|
+
// this factory takes `z` and is invoked once inside the register fn.
|
|
152
|
+
const makeRegionStr = (z) => (desc) =>
|
|
153
|
+
z.string().describe(desc + " (validated at runtime against the canonical region set).");
|
|
154
|
+
|
|
142
155
|
// Abort-guard for input-driven watchpoint runs: sample caller-named bytes each
|
|
143
156
|
// frame; the FIRST one to change stops the run with {label,addr,before,after}.
|
|
144
157
|
// Lets a derailed driven scenario (player died, scene flipped) return immediately
|
|
@@ -266,8 +279,9 @@ function downsample(arr, n) {
|
|
|
266
279
|
}
|
|
267
280
|
|
|
268
281
|
export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
282
|
+
const regionStr = makeRegionStr(z);
|
|
269
283
|
const rangeShape = z.object({
|
|
270
|
-
region:
|
|
284
|
+
region: regionStr("memory region for THIS range (same canonical set `memory` uses)"),
|
|
271
285
|
offset: z.number().int().min(0),
|
|
272
286
|
length: z.number().int().min(1).max(4096).default(1),
|
|
273
287
|
label: z.string().optional().describe("Name echoed on every event from this range — tells disjoint ranges apart in one stream."),
|
|
@@ -510,7 +524,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
510
524
|
|
|
511
525
|
// breakpoint({on:write|read|pc}) STOP-on-first. on:write precision:exact=bpFindWriter
|
|
512
526
|
// (core watchpoint, true PC under IRQ), precision:sampled=bpRunUntilWrite (frame PC).
|
|
513
|
-
async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf }) {
|
|
527
|
+
async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf, condition, conditionValue }) {
|
|
514
528
|
const host = getHost(sessionKey);
|
|
515
529
|
if (!host.watchpointSupported || !host.watchpointSupported()) {
|
|
516
530
|
return jsonContent({
|
|
@@ -519,7 +533,18 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
519
533
|
"Use watchMemory/runUntilWrite here — their pc is frame-sampled, so cross-check the value trace.",
|
|
520
534
|
});
|
|
521
535
|
}
|
|
522
|
-
|
|
536
|
+
if (condition === "equals" && conditionValue == null) {
|
|
537
|
+
throw new Error("breakpoint({on:'write', condition:'equals'}): `conditionValue` (the byte to stop on) is required.");
|
|
538
|
+
}
|
|
539
|
+
// Pass the condition to the core's watchpoint so its hook only COUNTS +
|
|
540
|
+
// records writes that satisfy it (qualifying writes), ignoring restoring/
|
|
541
|
+
// churn writes — and so the reported PC is a meaningful write, not just the
|
|
542
|
+
// last write of the frame. Core support is feature-detected; if the loaded
|
|
543
|
+
// core build predates condition support, we fall back to a host-side
|
|
544
|
+
// 'equals' filter on the reported value (inc/dec need the core's old byte).
|
|
545
|
+
const wantCond = condition != null;
|
|
546
|
+
const coreCond = host.setWatchpoint(address, true, wantCond ? { condition, value: conditionValue } : undefined);
|
|
547
|
+
const coreHandledCond = wantCond && coreCond && coreCond.conditionApplied === true;
|
|
523
548
|
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
524
549
|
const pressDriver = makePressDriver(host, presses);
|
|
525
550
|
// Abort-guard: sample caller-named "still valid?" bytes each frame; if any
|
|
@@ -533,7 +558,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
533
558
|
pressDriver.applyForFrame(i);
|
|
534
559
|
host.stepFrames(1);
|
|
535
560
|
const w = host.getWatchpoint();
|
|
536
|
-
if (w.hits > 0) {
|
|
561
|
+
if (w.hits > 0) {
|
|
562
|
+
// Host-side fallback for condition:'equals' on a core that didn't
|
|
563
|
+
// apply the condition itself: only accept when the reported (last)
|
|
564
|
+
// written value equals the target; otherwise keep waiting. (inc/dec
|
|
565
|
+
// can't be faked host-side — they need the core's pre-write byte, so
|
|
566
|
+
// we only reach here for them when the core DID handle the condition.)
|
|
567
|
+
if (wantCond && !coreHandledCond && condition === "equals" && (w.lastValue & 0xFF) !== (conditionValue & 0xFF)) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
result = { ...w, framesStepped: i + 1 }; break;
|
|
571
|
+
}
|
|
537
572
|
const ab = guard.check();
|
|
538
573
|
if (ab) { aborted = { ...ab, framesStepped: i + 1 }; break; }
|
|
539
574
|
}
|
|
@@ -584,12 +619,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
584
619
|
// address — a word/long store shows only its byte here, not the operand
|
|
585
620
|
// (a real session read 0x00 as "the move.l wrote zero").
|
|
586
621
|
valueByte: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
|
|
622
|
+
...(result.lastOldValue != null ? { oldValueByte: "0x" + (result.lastOldValue & 0xFF).toString(16).toUpperCase().padStart(2, "0") } : {}),
|
|
623
|
+
...(condition ? { condition, ...(coreHandledCond ? {} : { conditionAppliedBy: "host" }) } : {}),
|
|
587
624
|
hits: result.hits,
|
|
588
625
|
framesStepped: result.framesStepped,
|
|
589
626
|
...(wpRegs ? { registersAtHit: wpRegs } : {}),
|
|
590
627
|
...(bankInfo ? bankInfo : {}),
|
|
591
628
|
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
592
629
|
note: "pc is the EXACT writing instruction (captured in the CPU write path), not a frame sample. " +
|
|
630
|
+
(condition
|
|
631
|
+
? `condition:'${condition}' filtered to the MEANINGFUL write — pc/valueByte/hits reflect only qualifying writes${result.lastOldValue != null ? ` (oldValueByte→valueByte = ${"0x" + (result.lastOldValue & 0xFF).toString(16)}→${"0x" + result.lastValue.toString(16)})` : ""}. `
|
|
632
|
+
: "Without a `condition`, on:'write' runs to END OF FRAME and reports the LAST matching write of the frame (NOT the first) — `hits` is the count of all matching writes that frame. If a restoring/churn write hides the change you want, pass condition:'increase'|'decrease'|'equals'. ") +
|
|
593
633
|
"valueByte is the single byte written to the watched address (a 16/32-bit store shows only its byte here). " +
|
|
594
634
|
"hits counts watched-byte writes during the hit frame — the same instruction looping twice in one frame is hits:2, one event. " +
|
|
595
635
|
(wpRegs ? "registersAtHit is the register file frozen AT the write (the live regs drift for the rest of the frame — don't cpu({op:'read'}) instead). " : "") +
|
|
@@ -830,9 +870,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
830
870
|
precision: z.enum(["exact", "sampled"]).default("exact")
|
|
831
871
|
.describe("on:'write' ONLY. exact=core watchpoint, the real writing instruction PC even under interrupts (uses `address`). sampled=cheap frame-boundary PC (uses region/offset/length) — NOT the writer under IRQ. Ignored for on:read/pc (always exact)."),
|
|
832
872
|
address: z.number().int().min(0).optional().describe("on:'write' exact / on:'read' / on:'pc' — CPU address to break on (write target, read target, or instruction boundary). Required for those."),
|
|
833
|
-
region:
|
|
873
|
+
region: regionStr("on:'write' precision:'sampled' — region whose byte to watch for change.").optional(),
|
|
834
874
|
offset: z.number().int().min(0).optional().describe("on:'write' precision:'sampled' — offset within the region."),
|
|
835
875
|
length: z.number().int().min(1).max(4096).default(1).describe("on:'write' precision:'sampled' — bytes to watch from offset."),
|
|
876
|
+
condition: z.enum(["increase", "decrease", "equals"]).optional().describe("on:'write' precision:'exact' ONLY — stop only on the MEANINGFUL write, ignoring restoring/churn writes. 'decrease'/'increase' = the stored byte actually went down/up (e.g. a real lives−1, not a per-frame pointer-arithmetic restore); 'equals' = the byte became `value` (e.g. $00→$01 respawn re-arm). Without it, on:'write' reports the LAST matching write of the frame, which may be the churn, not the change you want."),
|
|
877
|
+
conditionValue: z.number().int().min(0).max(255).optional().describe("on:'write' condition:'equals' — the byte value to stop on (the NEW value written)."),
|
|
836
878
|
maxFrames: z.number().int().min(1).max(1_000_000).default(600).describe("Max frames to run while waiting for the condition."),
|
|
837
879
|
pressDuring: z.array(z.object({
|
|
838
880
|
frame: z.number().int().min(0),
|
|
@@ -841,12 +883,12 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
841
883
|
holdFrames: z.number().int().min(1).default(2),
|
|
842
884
|
})).optional().describe("Schedule input while waiting (drive the game to the state that triggers the condition). If OMITTED, this run inherits whatever input({op:'set'}) last held — same as frame({op:'step'}). If GIVEN, the schedule OWNS the pad for the whole run (a prior input({op:'set'}) is ignored); use it to drive the watched window itself. Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten."),
|
|
843
885
|
abortIf: z.array(z.object({
|
|
844
|
-
region:
|
|
886
|
+
region: regionStr("memory region (default system_ram)").optional(),
|
|
845
887
|
offset: z.number().int().min(0).describe("byte offset within the region"),
|
|
846
888
|
label: z.string().optional().describe("human name for this guard byte"),
|
|
847
889
|
})).optional().describe("on:'write' exact — ABORT GUARD for a pressDuring run: caller-named 'is this scenario still valid?' bytes (e.g. the area/scene id, the player object-active flag). If ANY changes mid-run the watchpoint stops IMMEDIATELY and returns {aborted:true, abortedBy, before, after} — so a driven scenario that derailed (player died → title screen) doesn't burn all maxFrames and return a meaningless found:false. Each is sampled once per frame (cheap)."),
|
|
848
890
|
captureMemory: z.array(z.object({
|
|
849
|
-
region:
|
|
891
|
+
region: regionStr("memory region to read"),
|
|
850
892
|
offset: z.number().int().min(0).describe("byte offset within the region"),
|
|
851
893
|
length: z.number().int().min(1).max(256).default(1).describe("bytes to read"),
|
|
852
894
|
label: z.string().optional().describe("human name for this read (else 'region+offset')"),
|
|
@@ -65,6 +65,14 @@ thousands of bytes and you'll drown).
|
|
|
65
65
|
|
|
66
66
|
This is the Cheat-Engine/RetroArch loop. It is THE bread-and-butter primitive.
|
|
67
67
|
|
|
68
|
+
**Don't know the value? (lives/timer/ammo not on the HUD)** Use the
|
|
69
|
+
unknown-initial-value hunt: `memory({op:'searchUnknown', region, size})` seeds
|
|
70
|
+
the WHOLE region with no value, then narrow across events with
|
|
71
|
+
`searchNext({compare:'dec'|'inc'|'unchanged'|'changed'|'gt'|'lt'})` — e.g.
|
|
72
|
+
`searchUnknown` → lose a life → `searchNext({compare:'dec'})` → repeat until 1–2
|
|
73
|
+
remain. `op:'search'` needs a value; `op:'searchUnknown'` is for when you can't
|
|
74
|
+
see the number.
|
|
75
|
+
|
|
68
76
|
**Stored ≠ displayed.** When a correct-looking seed returns 0, the byte usually isn't the
|
|
69
77
|
raw number: seed `as:'bcd'` for packed-BCD scores (2 decimal digits per byte — very common
|
|
70
78
|
on NES), or `as:'digits'` for one byte per ON-SCREEN digit at any constant tile base (HUD
|
|
@@ -78,7 +86,13 @@ not for value hunting. `memory({op:'diff'})` defaults to a **clustered summary**
|
|
|
78
86
|
stride) so it won't flood you — a reported stride (e.g. "islands at 0x80") is
|
|
79
87
|
usually a struct/entity array, each island one record. Small clusters (≤8 bytes) carry
|
|
80
88
|
`before`/`after` hex inline, and `minDelta:N` drops |after−before| < N so RNG/counter
|
|
81
|
-
wiggle disappears from the report.
|
|
89
|
+
wiggle disappears from the report. For the locate-value-via-diff case, predicate
|
|
90
|
+
filters cut a 500-byte death-window diff to the ~3 rows you want in one call:
|
|
91
|
+
`changeDir:'dec'|'inc'` (direction), `deltaEq:N` (signed exact delta — `deltaEq:-1`
|
|
92
|
+
= "lost one life"), and `beforeMin/Max` + `afterMin/Max` (value-range gates, e.g.
|
|
93
|
+
`beforeMax:9` = a small counter, not a coordinate). `outputPath` writes the full
|
|
94
|
+
diff JSON to your path regardless of size (`echo:false` returns just the
|
|
95
|
+
counts+path so a big diff never streams through context).
|
|
82
96
|
|
|
83
97
|
**"Which byte does this INPUT drive?" → `memory({op:'diffRuns'})`** — runs the same start
|
|
84
98
|
state twice (savestate restore in between) under two different held inputs (`portsA` vs
|
|
@@ -136,8 +150,11 @@ a string — find a terminator / font map before treating the bytes as values.
|
|
|
136
150
|
platforms (Genesis/Mega Drive, GB/GBC, SMS/GG, PCE, Lynx) the **file offset IS
|
|
137
151
|
the CPU ROM address** — `memory({op:'readCart', offset:0x21FF00})` answers "does the
|
|
138
152
|
running ROM have my bytes at 0x21FF00?" in one call. (NES/SNES: bytes are
|
|
139
|
-
correct but mapper-banked — `mapped:true` in the response
|
|
140
|
-
|
|
153
|
+
correct but mapper-banked — `mapped:true` in the response.) For a BANKED CPU
|
|
154
|
+
address, read it directly: `memory({op:'readCart', cpuAddress:0x8654, bank:6})`
|
|
155
|
+
maps the bank→PRG offset for you (NES/SNES) — the inverse of the breakpoint
|
|
156
|
+
result's bank/prgOffset, so you stop hand-computing `cpuAddr−0x8000+bank*0x4000`.
|
|
157
|
+
A NES `$C000+` address resolves to the fixed top bank automatically.
|
|
141
158
|
|
|
142
159
|
When a write "doesn't show up", check the ROM here before assuming the patch
|
|
143
160
|
failed — it's usually live and the bug is elsewhere (wrong source, see §2/§5).
|
|
@@ -170,6 +187,18 @@ can pass `{startAddress, bank}` to `disasm({target:'rom'})`. The lighter
|
|
|
170
187
|
and returns a frame-boundary PC — a lead, not a guarantee under interrupts; use it for the
|
|
171
188
|
value timeline or when you just want the change history, and cross-check the value trace.
|
|
172
189
|
|
|
190
|
+
**Stop on the MEANINGFUL write, not the churn.** `breakpoint({on:'write'})` runs
|
|
191
|
+
to END OF FRAME and reports the LAST matching write that frame (with `hits` =
|
|
192
|
+
the count of all matching writes) — so a frequent **restoring** write (a pointer-
|
|
193
|
+
arithmetic `inc`/`dec` that touches the byte every frame, a re-arm) can mask the
|
|
194
|
+
write you actually want. Filter to the real change with `condition` (all 14
|
|
195
|
+
platforms): `condition:'decrease'` / `'increase'` stop only when the stored byte
|
|
196
|
+
actually went down/up (a real lives−1, not a restore), and `condition:'equals',
|
|
197
|
+
conditionValue:N` stops on the byte becoming N (e.g. a $00→$01 respawn re-arm).
|
|
198
|
+
The hit then reports `oldValueByte`→`valueByte` so you see the exact transition.
|
|
199
|
+
This is the difference between pinning a genuine decrement instantly and chasing
|
|
200
|
+
net-zero restoring churn.
|
|
201
|
+
|
|
173
202
|
---
|
|
174
203
|
|
|
175
204
|
## 5b. To READ a register at an instruction — execution breakpoints (all 14)
|