romdevtools 0.29.0 → 0.40.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.
Files changed (103) hide show
  1. package/AGENTS.md +14 -5
  2. package/CHANGELOG.md +114 -12
  3. package/README.md +2 -1
  4. package/examples/gb/templates/tile_engine.c +1 -1
  5. package/examples/gbc/templates/tile_engine.c +1 -1
  6. package/examples/genesis/templates/two_plane_parallax.c +4 -4
  7. package/examples/nes/templates/tile_engine.c +1 -1
  8. package/package.json +14 -12
  9. package/src/analysis/analyze.js +263 -0
  10. package/src/analysis/decompile.js +108 -0
  11. package/src/analysis/decompiler/sleigh/6502.cspec +34 -0
  12. package/src/analysis/decompiler/sleigh/6502.ldefs +33 -0
  13. package/src/analysis/decompiler/sleigh/6502.pspec +16 -0
  14. package/src/analysis/decompiler/sleigh/6502.sla +3414 -0
  15. package/src/analysis/decompiler/sleigh/65816-snes.pspec +249 -0
  16. package/src/analysis/decompiler/sleigh/65816.cspec +17 -0
  17. package/src/analysis/decompiler/sleigh/65816.ldefs +15 -0
  18. package/src/analysis/decompiler/sleigh/65816.sla +23670 -0
  19. package/src/analysis/decompiler/sleigh/65c02.sla +4683 -0
  20. package/src/analysis/decompiler/sleigh/68000.cspec +67 -0
  21. package/src/analysis/decompiler/sleigh/68000.ldefs +68 -0
  22. package/src/analysis/decompiler/sleigh/68000.pspec +9 -0
  23. package/src/analysis/decompiler/sleigh/68000_register.cspec +118 -0
  24. package/src/analysis/decompiler/sleigh/68040.sla +81693 -0
  25. package/src/analysis/decompiler/sleigh/ARM.ldefs +377 -0
  26. package/src/analysis/decompiler/sleigh/ARM4t_le.sla +53758 -0
  27. package/src/analysis/decompiler/sleigh/ARM_v45.cspec +209 -0
  28. package/src/analysis/decompiler/sleigh/ARM_v45.pspec +40 -0
  29. package/src/analysis/decompiler/sleigh/ARMt_v45.pspec +43 -0
  30. package/src/analysis/decompiler/sleigh/HuC6280.cspec +67 -0
  31. package/src/analysis/decompiler/sleigh/HuC6280.ldefs +18 -0
  32. package/src/analysis/decompiler/sleigh/HuC6280.pspec +150 -0
  33. package/src/analysis/decompiler/sleigh/HuC6280.sla +7524 -0
  34. package/src/analysis/decompiler/sleigh/sm83.cspec +70 -0
  35. package/src/analysis/decompiler/sleigh/sm83.ldefs +29 -0
  36. package/src/analysis/decompiler/sleigh/sm83.pspec +19 -0
  37. package/src/analysis/decompiler/sleigh/sm83.sla +7695 -0
  38. package/src/analysis/decompiler/sleigh/z80.cspec +122 -0
  39. package/src/analysis/decompiler/sleigh/z80.ldefs +57 -0
  40. package/src/analysis/decompiler/sleigh/z80.pspec +43 -0
  41. package/src/analysis/decompiler/sleigh/z80.sla +20518 -0
  42. package/src/analysis/decompiler/wasm/decompile.js +2 -0
  43. package/src/analysis/decompiler/wasm/decompile.wasm +0 -0
  44. package/src/analysis/rizin.js +129 -0
  45. package/src/analysis/wasm/rizin.js +6032 -0
  46. package/src/analysis/wasm/rizin.wasm +0 -0
  47. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  48. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  49. package/src/cores/wasm/fceumm_libretro.js +1 -1
  50. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  51. package/src/cores/wasm/gambatte_libretro.js +1 -1
  52. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  53. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  54. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  55. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  56. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  57. package/src/cores/wasm/handy_libretro.js +1 -1
  58. package/src/cores/wasm/handy_libretro.wasm +0 -0
  59. package/src/cores/wasm/mgba_libretro.js +1 -1
  60. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  61. package/src/cores/wasm/prosystem_libretro.js +1 -1
  62. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  63. package/src/cores/wasm/snes9x_libretro.js +1 -1
  64. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  65. package/src/cores/wasm/stella2014_libretro.js +1 -1
  66. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  67. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  68. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  69. package/src/host/LibretroHost.js +25 -7
  70. package/src/http/routes.js +1 -1
  71. package/src/http/skill-doc.js +1 -1
  72. package/src/mcp/tools/cart-parts.js +5 -2
  73. package/src/mcp/tools/disasm.js +32 -5
  74. package/src/mcp/tools/font-map.js +3 -3
  75. package/src/mcp/tools/index.js +2 -2
  76. package/src/mcp/tools/memory.js +131 -24
  77. package/src/mcp/tools/project.js +1 -1
  78. package/src/mcp/tools/record.js +6 -7
  79. package/src/mcp/tools/reinject.js +1 -1
  80. package/src/mcp/tools/run-until.js +8 -2
  81. package/src/mcp/tools/symbols.js +10 -4
  82. package/src/mcp/tools/trace-vram-source.js +1 -1
  83. package/src/mcp/tools/watch-memory.js +50 -8
  84. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +80 -6
  85. package/src/platforms/atari2600/MENTAL_MODEL.md +6 -0
  86. package/src/platforms/atari7800/MENTAL_MODEL.md +6 -0
  87. package/src/platforms/c64/MENTAL_MODEL.md +6 -0
  88. package/src/platforms/gb/MENTAL_MODEL.md +6 -0
  89. package/src/platforms/gb/lib/c/README.md +1 -1
  90. package/src/platforms/gba/MENTAL_MODEL.md +7 -1
  91. package/src/platforms/gbc/MENTAL_MODEL.md +6 -0
  92. package/src/platforms/gbc/lib/c/README.md +1 -1
  93. package/src/platforms/genesis/MENTAL_MODEL.md +8 -2
  94. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  95. package/src/platforms/genesis/lib/wram.s +1 -1
  96. package/src/platforms/gg/MENTAL_MODEL.md +6 -0
  97. package/src/platforms/lynx/MENTAL_MODEL.md +6 -0
  98. package/src/platforms/msx/MENTAL_MODEL.md +6 -0
  99. package/src/platforms/nes/MENTAL_MODEL.md +6 -0
  100. package/src/platforms/pce/MENTAL_MODEL.md +6 -0
  101. package/src/platforms/sms/MENTAL_MODEL.md +6 -0
  102. package/src/platforms/snes/MENTAL_MODEL.md +10 -4
  103. package/src/toolchains/_worker/wasm-worker.js +5 -0
@@ -1,7 +1,7 @@
1
1
  // learnFontMap / encodeTextForRom / findEncodedText — text-hack workflow.
2
2
  //
3
- // Every retro game maps characters to tile-IDs differently (Excitebike:
4
- // A=$0A, B=$0B, ..., Z=$23; Mario: ASCII offset; FF: sparse table). The
3
+ // Every retro game maps characters to tile-IDs differently (one NES racer:
4
+ // A=$0A, B=$0B, ..., Z=$23; another game: ASCII offset; a third: sparse table). The
5
5
  // agent currently reverse-engineers this by hand each session. These
6
6
  // three tools automate it:
7
7
  //
@@ -186,7 +186,7 @@ async function makeTilemapReader(host, platform, which) {
186
186
  * Decide whether a run of (char, tileId) reads from a live tilemap is FONT TEXT
187
187
  * (a reusable character→tile map) or a PRE-RENDERED GRAPHIC (a name/logo drawn as
188
188
  * a bitmap, where each cell is a unique tile). The trap a long RE session hit:
189
- * NBA Jam's player names are bitmaps, so patching the ASCII string did nothing.
189
+ * Some games' player names are bitmaps, so patching the ASCII string does nothing.
190
190
  * Signals a graphic when: (1) a repeated character used a DIFFERENT tile each
191
191
  * time (a real font reuses one tile per letter) — the direct proof; OR (2) every
192
192
  * tile is unique AND the ids form a near-contiguous run (tiles X,X+1,X+2,… = one
@@ -116,8 +116,8 @@ const CATEGORIES = [
116
116
  },
117
117
  {
118
118
  name: "debug",
119
- description: "Cross-platform debugging primitives: inspectSprites, inspectPalette, getCPUState (main/spc700/z80), getAudioState (dsp/psg/ym2612), disassemble, symbol lookup.",
120
- useWhen: ["sprites rendering wrong", "audio silent or distorted", "CPU stuck in unknown state", "need to read what existing ROM bytes do"],
119
+ description: "Cross-platform debugging + reverse-engineering: inspectSprites, inspectPalette, getCPUState (main/spc700/z80), getAudioState (dsp/psg/ym2612), and the disasm/RE engine — disassemble (raw/ROM/rebuildable-project), plus the Rizin/Ghidra ops disasm({target:'cfg'|'xrefs'|'functions'|'decompile'}) (control-flow graphs, deep xrefs, auto-detected functions, and C pseudocode — all 14 platforms) and symbols({op:'analyze'}) (one-shot structural map).",
120
+ useWhen: ["sprites rendering wrong", "audio silent or distorted", "CPU stuck in unknown state", "need to read what existing ROM bytes do", "reverse-engineering an unknown ROM — carve its functions/structure before labeling them live", "want C-like pseudocode to understand a routine"],
121
121
  register: (s, z, k) => {
122
122
  registerPlatformSpecificTools(s, z, k); // inspectSprites/Palette, getCPUState, getDspState, ...
123
123
  registerSymbolTools(s, z, k); // buildSourceWithDebug, resolveSymbol, lookupAddress, ...
@@ -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. minDelta filters OUT small wiggles
303
- // (|after - before| < minDelta) so "find the position byte amid OAM/RNG
304
- // churn" is one cheap call instead of a raw dump + client-side filtering
305
- // (0.27.0 feedback #5).
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
- if (snap.bytes[i] === now[i]) continue;
309
- if (minDelta != null && Math.abs(now[i] - snap.bytes[i]) < minDelta) continue;
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
- return jsonContent({
369
+ const result = {
322
370
  region, name, view, baseOffset: snap.offset, length: snap.bytes.length,
323
- changedCount, changes,
324
- ...(changedCount > changes.length ? { truncated: true, note: `${changedCount} bytes changed; showing first ${changes.length} (raise maxChanges).` } : {}),
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
- return jsonContent({
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 it."),
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 + "" + name;
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. */
@@ -1464,7 +1464,7 @@ TEMPLATES.genesis = {
1464
1464
  runtimeDirs: SGDK_RUNTIME_DIRS,
1465
1465
  lang: SGDK_LANG,
1466
1466
  ext: ".bin",
1467
- describe: "Two-plane parallax SCROLLING scaffold — the smooth-feel starting point for a Uridium/Sonic-style side-scroller. Plane A = a painted foreground world (ground + platform blocks), Plane B = a repeated starfield, one player sprite. The frame loop does HARDWARE SCROLL ONLY (two VDP_setHorizontalScroll writes + one VDP_updateSprites) — ZERO tilemap writes per frame, which is what keeps movement smooth (rewriting a plane each frame is the #1 'choppy horizontal movement' bug). Plane B scrolls at 1/4 speed for depth. Exposes volatile g_player_x / g_cam_x so you can motion-trace it headlessly (symbols->memory->recordSession). Extend by streaming one offscreen column per 8-px camera step for worlds wider than 512 px — see Genesis MENTAL_MODEL.md 'Scrolling, parallax & the feel trap'.",
1467
+ describe: "Two-plane parallax SCROLLING scaffold — the smooth-feel starting point for a parallax side-scroller. Plane A = a painted foreground world (ground + platform blocks), Plane B = a repeated starfield, one player sprite. The frame loop does HARDWARE SCROLL ONLY (two VDP_setHorizontalScroll writes + one VDP_updateSprites) — ZERO tilemap writes per frame, which is what keeps movement smooth (rewriting a plane each frame is the #1 'choppy horizontal movement' bug). Plane B scrolls at 1/4 speed for depth. Exposes volatile g_player_x / g_cam_x so you can motion-trace it headlessly (symbols->memory->recordSession). Extend by streaming one offscreen column per 8-px camera step for worlds wider than 512 px — see Genesis MENTAL_MODEL.md 'Scrolling, parallax & the feel trap'.",
1468
1468
  },
1469
1469
  puzzle: {
1470
1470
  main: "templates/puzzle.c",
@@ -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.enum(SAMPLE_REGIONS),
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
  }),
@@ -390,7 +390,7 @@ const PLATFORM_REGISTRY = {
390
390
  msx: { forms: msxPointerForms, verdict: "literal-escape", formats: ["raw", "konami-rle"],
391
391
  note: "RAW when uncompressed; Konami/other RLE has a literal-run token, so a literal-escape stored block is producible." },
392
392
  genesis: { forms: genesisPointerForms, verdict: "literal-escape", formats: ["raw", "kosinski-literal"],
393
- note: "Pointers are 32-bit BE = ROM offset (1:1 at $000000). Kosinski has an all-literal path (experimental terminator — self-verify). Nemesis is Huffman — NO stored escape; custom LZ (e.g. NBA Jam) — confirm the literal-run shape per game." },
393
+ note: "Pointers are 32-bit BE = ROM offset (1:1 at $000000). Kosinski has an all-literal path (experimental terminator — self-verify). Nemesis is Huffman — NO stored escape; custom LZ (e.g. some Genesis sports titles) — confirm the literal-run shape per game." },
394
394
  snes: { forms: snesPointerForms, verdict: "literal-escape", formats: ["raw", "lz2-direct"],
395
395
  note: "Pointers 16-bit (bank-implied) or 24-bit long LE; needs LoROM/HiROM (auto-detected). LC_LZ2 has a clean direct-copy (000) literal command + 0xFF end — common but per-game; confirm the codec." },
396
396
  gba: { forms: gbaPointerForms, verdict: "literal-escape", formats: ["raw", "lz77-literal"],
@@ -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: z.enum(["system_ram", "save_ram", "video_ram", "rtc", "nes_nametables", "nes_palette", "nes_oam", "nes_chr"]),
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: z.enum(["system_ram", "save_ram", "video_ram", "rtc", "nes_nametables", "nes_palette", "nes_oam", "nes_chr"]),
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.");
@@ -10,6 +10,7 @@
10
10
  import { writeFileSync } from "node:fs";
11
11
  import { readFile } from "node:fs/promises";
12
12
  import { jsonContent, safeTool, writeOutput } from "../util.js";
13
+ import { analyzeStructure } from "../../analysis/analyze.js";
13
14
  import { addressToSymbolCore } from "./address-to-symbol.js";
14
15
 
15
16
  // Tail length kept inline when a big log is written to a sibling file.
@@ -459,7 +460,7 @@ function registerSymbolsTool(server, z) {
459
460
  "Auto-detects GNU ld (Genesis/m68k + GBA/ARM), sdld (`XXXX _name`, GB/GBC/SMS/GG/MSX), and ld65 VICE " +
460
461
  "(`al XXXX .name`, cc65/dasm).",
461
462
  {
462
- op: z.enum(["resolve", "lookup", "map", "list", "addr"]).describe("resolve name→addr; lookup addr→sym; map = layout by region; list all; addr = PC→nearest symbol."),
463
+ op: z.enum(["resolve", "lookup", "map", "list", "addr", "analyze"]).describe("resolve name→addr; lookup addr→sym; map = layout by region; list all; addr = PC→nearest symbol; analyze = Rizin structural map of a ROM (no .dbg/.map needed — auto-detected functions + strings + entrypoints)."),
463
464
  dbg: z.string().optional().describe("op=resolve/lookup/list/map: cc65 .dbg text from build({output:'romWithDebug'}) (NES/C64/Atari7800/Lynx/PCE). Pass this OR `map`/`dbgPath`/`mapPath`."),
464
465
  map: z.string().optional().describe("op=resolve/lookup/list/map: .map text (build's `mapText`/`symbols`) — auto-detects sdld (GB/GBC/SMS/GG/MSX) vs GNU ld (Genesis/m68k). Pass this OR `dbg`/`dbgPath`/`mapPath`."),
465
466
  dbgPath: z.string().optional().describe("op=resolve/lookup/list/map: ABSOLUTE path to a cc65 .dbg on disk (the `dbgPath` build({output:'romWithDebug'}) returned). Server reads it — the map never enters your context. Inline `dbg` wins if both passed."),
@@ -472,11 +473,16 @@ function registerSymbolsTool(server, z) {
472
473
  pc: z.number().int().min(0).max(0xFFFFFF).optional().describe("op=addr: CPU address to look up (e.g. 0x01A7)."),
473
474
  symbolsText: z.string().optional().describe("op=addr: inline .map/.sym text (build's `symbols`). Takes precedence over symbolsPath."),
474
475
  symbolsPath: z.string().optional().describe("op=addr: path to a .map/.sym file (used only if symbolsText absent)."),
476
+ // analyze
477
+ romPath: z.string().optional().describe("op=analyze: ABSOLUTE path to the ROM file to structurally map via Rizin."),
475
478
  },
476
479
  safeTool(async (args) => {
477
- // dbgPath/mapPath read the .dbg/.map TEXT off disk so resolve/lookup/map/
478
- // list work without the agent ever loading the map into context. (addr has
479
- // its own symbolsPath handling.)
480
+ // op=analyze and op=addr don't take a .dbg/.map source. Everything else
481
+ // resolves dbgPath/mapPath text off disk so the map never enters context.
482
+ if (args.op === "analyze") {
483
+ if (!args.romPath) throw new Error("symbols op='analyze' requires `romPath` (the ROM file).");
484
+ return jsonContent(await analyzeStructure(args.romPath, args.platform));
485
+ }
480
486
  const a = args.op === "addr" ? args : { ...args, ...(await loadDebugSource(args)) };
481
487
  switch (a.op) {
482
488
  case "resolve": return jsonContent(await resolveSymbolCore(a));
@@ -1,6 +1,6 @@
1
1
  // traceVramSource (Genesis) — "which ROM offset did this VRAM graphic come from?"
2
2
  //
3
- // The trap it kills: NBA Jam-style player names are pre-rendered tile bitmaps
3
+ // The trap it kills: some sports games' player names are pre-rendered tile bitmaps
4
4
  // DMA'd into VRAM from ROM, not font-rendered from a string. You can SEE the
5
5
  // name but patching the ASCII string does nothing — the source is the bitmap in
6
6
  // ROM. Genesis makes this traceable: a memory→VRAM DMA leaves its SOURCE address
@@ -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: z.enum(MEMORY_REGIONS),
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
- host.setWatchpoint(address, true);
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) { result = { ...w, framesStepped: i + 1 }; break; }
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: z.enum(MEMORY_REGIONS).optional().describe("on:'write' precision:'sampled' — region whose byte to watch for change."),
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: z.enum(MEMORY_REGIONS).optional().describe("memory region (default system_ram)"),
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: z.enum(MEMORY_REGIONS).describe("memory region to read"),
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')"),
@@ -985,7 +1027,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
985
1027
  "hours diffing a CORRECT codec against that poisoned output. Prefer pure for every decompressor/codec call.\n" +
986
1028
  "• op:'decompress' — convenience wrapper over op:'call' for the common decompressor shape: call `entryPC` with " +
987
1029
  "A0=`sourceAddress` (and optionally A1=`destAddress`), run until it returns, then read `destAddress`. For the " +
988
- "NBA-Jam-style 'name + portrait are LZ-compressed' wall: point it at the game's own decompressor.",
1030
+ "the 'pre-rendered name + portrait are LZ-compressed' wall: point it at the game's own decompressor.",
989
1031
  {
990
1032
  op: z.enum(["read", "setReg", "call", "decompress"])
991
1033
  .describe("read=CPU registers/flags; setReg=write one register; call=drive a subroutine until it returns; decompress=call shortcut (A0=source, A1=dest)."),