romdevtools 0.28.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.
Files changed (179) hide show
  1. package/AGENTS.md +53 -43
  2. package/CHANGELOG.md +91 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +12 -12
  88. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  89. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  90. package/src/cores/wasm/fceumm_libretro.js +1 -1
  91. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  92. package/src/cores/wasm/gambatte_libretro.js +1 -1
  93. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  94. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  95. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  96. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  97. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  98. package/src/cores/wasm/handy_libretro.js +1 -1
  99. package/src/cores/wasm/handy_libretro.wasm +0 -0
  100. package/src/cores/wasm/mgba_libretro.js +1 -1
  101. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  102. package/src/cores/wasm/prosystem_libretro.js +1 -1
  103. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  104. package/src/cores/wasm/snes9x_libretro.js +1 -1
  105. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  106. package/src/cores/wasm/stella2014_libretro.js +1 -1
  107. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  108. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  109. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  110. package/src/host/LibretroHost.js +84 -8
  111. package/src/http/tool-registry.js +11 -11
  112. package/src/mcp/tools/cheats.js +2 -1
  113. package/src/mcp/tools/frame.js +3 -2
  114. package/src/mcp/tools/index.js +3 -3
  115. package/src/mcp/tools/input.js +5 -4
  116. package/src/mcp/tools/lifecycle.js +6 -4
  117. package/src/mcp/tools/memory.js +131 -24
  118. package/src/mcp/tools/platform-docs.js +1 -1
  119. package/src/mcp/tools/preview-tile.js +6 -2
  120. package/src/mcp/tools/project.js +1098 -130
  121. package/src/mcp/tools/record.js +6 -7
  122. package/src/mcp/tools/rom-id.js +5 -1
  123. package/src/mcp/tools/run-until.js +12 -4
  124. package/src/mcp/tools/snippets.js +6 -6
  125. package/src/mcp/tools/sprite-pipeline.js +14 -2
  126. package/src/mcp/tools/state.js +2 -1
  127. package/src/mcp/tools/tile-inspect.js +8 -1
  128. package/src/mcp/tools/toolchain.js +12 -1
  129. package/src/mcp/tools/watch-memory.js +53 -10
  130. package/src/observer/bus.js +73 -0
  131. package/src/observer/livestream.html +4 -2
  132. package/src/observer/tool-wrap.js +17 -14
  133. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
  134. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  135. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  136. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  137. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  138. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  139. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  140. package/src/platforms/gb/lib/c/README.md +10 -11
  141. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  142. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  143. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  144. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  145. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  146. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  147. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  148. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  149. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  150. package/src/platforms/gbc/lib/c/README.md +10 -11
  151. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  152. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  153. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  154. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  155. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  156. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  157. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  158. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  159. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  160. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  161. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  162. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  163. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  164. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  165. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  166. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  167. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  168. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  169. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  170. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  171. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  172. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  173. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  174. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  175. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  176. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  177. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  178. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  179. package/src/toolchains/index.js +27 -11
@@ -249,12 +249,13 @@ export function registerFrameTools(server, z, sessionKey) {
249
249
  // actively playing in the playtest window means this step raced their
250
250
  // real-time loop. Field only appears when the conflict is real.
251
251
  const coDrive = humanCoDriveWarning(sessionKey);
252
- return jsonContent({
252
+ // Livestream: the post-step frame (throttled to 1/2s per tool by the bus).
253
+ return attachObserverFrame(jsonContent({
253
254
  framesRun: n,
254
255
  frameCount: host.status.frameCount,
255
256
  framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight },
256
257
  ...(coDrive ? { humanCoDriveWarning: coDrive } : {}),
257
- });
258
+ }), host, `step ×${n}`);
258
259
  }
259
260
 
260
261
  // Contract: an image goes to disk (path) OR comes back inline (inline:true).
@@ -87,7 +87,7 @@ const CATEGORIES = [
87
87
  {
88
88
  name: "platforms",
89
89
  description: "Discover supported platforms, their cores, toolchains, and language matrices.",
90
- useWhen: ["scaffolding a new project", "checking which platforms are available", "looking up a platform's default language"],
90
+ useWhen: ["before forking an example for a new game", "checking which platforms are available", "looking up a platform's default language"],
91
91
  register: (s, z, k) => registerPlatformTools(s, z, k), // listPlatforms, resolvePlatform
92
92
  },
93
93
  {
@@ -138,8 +138,8 @@ const CATEGORIES = [
138
138
  },
139
139
  {
140
140
  name: "project",
141
- description: "Project scaffolding + starter snippets per platform.",
142
- useWhen: ["starting a new game from scratch", "looking up canonical patterns like NMI handler, OAM DMA, joypad read"],
141
+ description: "The example-game library (fork/list/show) + starter snippets per platform.",
142
+ useWhen: ["starting a new game (ALWAYS fork the nearest example — never a blank file)", "looking up canonical patterns like NMI handler, OAM DMA, joypad read"],
143
143
  register: (s, z, k) => { registerProjectTools(s, z, k); registerSnippetTools(s, z, k); registerPlatformDocsTools(s, z); },
144
144
  },
145
145
  {
@@ -2,6 +2,7 @@ import { getHost } from "../state.js";
2
2
  import { jsonContent, safeTool } from "../util.js";
3
3
  import { getInputLayoutCore } from "./input-layout.js";
4
4
  import { humanCoDriveWarning } from "./playtest.js";
5
+ import { attachObserverFrame } from "./watch-memory.js";
5
6
 
6
7
  // Spreadable co-drive conflict marker for every input-driving op: while a
7
8
  // human is actively playing in this session's playtest window, their input
@@ -257,21 +258,21 @@ export function registerInputTools(server, z, sessionKey) {
257
258
  switch (args.op) {
258
259
  case "set": {
259
260
  if (!args.ports) throw new Error("input({op:'set'}): `ports` is required.");
260
- return jsonContent(inputSetCore(args, sessionKey));
261
+ return attachObserverFrame(jsonContent(inputSetCore(args, sessionKey)), getHost(sessionKey), "input set");
261
262
  }
262
263
  case "press": {
263
264
  if (!args.button) throw new Error("input({op:'press'}): `button` is required.");
264
- return jsonContent(inputPressCore(args, sessionKey));
265
+ return attachObserverFrame(jsonContent(inputPressCore(args, sessionKey)), getHost(sessionKey), `press ${args.button}`);
265
266
  }
266
267
  case "sequence": {
267
268
  if (!args.steps) throw new Error("input({op:'sequence'}): `steps` is required.");
268
- return jsonContent(inputSequenceCore(args, sessionKey));
269
+ return attachObserverFrame(jsonContent(inputSequenceCore(args, sessionKey)), getHost(sessionKey), "input sequence");
269
270
  }
270
271
  case "navigate": {
271
272
  if (!args.steps) throw new Error("input({op:'navigate'}): `steps` is required.");
272
273
  // Fill per-step defaults the old navigate schema provided.
273
274
  const steps = args.steps.map((s) => ({ holdFrames: 2, maxWaitFrames: 120, settleFrames: 2, ...s }));
274
- return jsonContent(inputNavigateCore({ steps }, sessionKey));
275
+ return attachObserverFrame(jsonContent(inputNavigateCore({ steps }, sessionKey)), getHost(sessionKey), "navigate");
275
276
  }
276
277
  case "layout": {
277
278
  if (!args.platform) throw new Error("input({op:'layout'}): `platform` is required.");
@@ -2,6 +2,7 @@ import { resolveCore } from "../../cores/registry.js";
2
2
  import { clearHost, getHost, getHostOrNull, rememberLastMedia, resetHost } from "../state.js";
3
3
  import { jsonContent, safeTool, textContent } from "../util.js";
4
4
  import { resolveCheatCodeForApply } from "./cheats.js";
5
+ import { attachObserverFrame } from "./watch-memory.js";
5
6
 
6
7
  const MEDIA_KINDS = ["cartridge", "disk", "tape", "program"];
7
8
 
@@ -58,7 +59,8 @@ export function registerLifecycleTools(server, z, sessionKey) {
58
59
  // on dimensions, so we omit it until a frame has been stepped and point the
59
60
  // caller at stepFrames instead.
60
61
  const framebufferKnown = host.status.frameCount > 0;
61
- return jsonContent({
62
+ // Livestream: show what just loaded (the boot frame).
63
+ return attachObserverFrame(jsonContent({
62
64
  loaded: true,
63
65
  platform,
64
66
  core: resolved.coreName,
@@ -68,7 +70,7 @@ export function registerLifecycleTools(server, z, sessionKey) {
68
70
  ? { framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight } }
69
71
  : { framebufferNote: "Framebuffer dimensions are unknown until the core runs — call stepFrames first, then getStatus (the pre-boot default does not match the real output resolution)." }),
70
72
  ...(appliedCheats ? { cheats: appliedCheats } : {}),
71
- });
73
+ }), host, `loaded ${host.status.mediaPath ? host.status.mediaPath.split("/").pop() : platform}`);
72
74
  }
73
75
 
74
76
  server.tool(
@@ -125,10 +127,10 @@ export function registerLifecycleTools(server, z, sessionKey) {
125
127
  const host = getHost(sessionKey);
126
128
  if (hard) {
127
129
  const reloaded = await host.hardReset();
128
- return textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)");
130
+ return attachObserverFrame(textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)"), host, "reset (hard)");
129
131
  }
130
132
  host.reset();
131
- return textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)");
133
+ return attachObserverFrame(textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)"), host, "reset");
132
134
  }
133
135
  case "pause":
134
136
  getHost(sessionKey).pause();
@@ -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. */
@@ -69,7 +69,7 @@ export async function listPlatformDocsCore({ platform }) {
69
69
  platform,
70
70
  docs,
71
71
  note: docs.length === 0
72
- ? `No docs shipped for '${platform}' yet. Try a different platform or scaffold for boilerplate. (For RE/patching workflow, see platform({op:'doc', platform:'romhacking', name:'playbook'}).)`
72
+ ? `No docs shipped for '${platform}' yet. Try a different platform, or fork an example game (examples({op:'fork'})) for boilerplate. (For RE/patching workflow, see platform({op:'doc', platform:'romhacking', name:'playbook'}).)`
73
73
  : `Call platform({op:'doc', platform, name}) to read one. 'name' is 'mental_model' or 'troubleshooting'. For RE/patching workflow across platforms, see platform({op:'doc', platform:'romhacking', name:'playbook'}).`,
74
74
  };
75
75
  }
@@ -409,7 +409,10 @@ export async function previewTileArtCore(args) {
409
409
 
410
410
  if (outputPath) {
411
411
  await writeFile(outputPath, png);
412
- return { ...result, outputPath, note: `${png.length} bytes of PNG written to ${outputPath}.` };
412
+ // Livestream sideband: the human sees the rendered sheet even though the
413
+ // agent only gets the path.
414
+ return { ...result, outputPath, note: `${png.length} bytes of PNG written to ${outputPath}.`,
415
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: png.toString("base64") }] };
413
416
  }
414
417
  return { ...result, pngBase64: png.toString("base64") };
415
418
  }
@@ -467,7 +470,8 @@ async function previewMsxScreen2(args, d) {
467
470
  };
468
471
  if (outputPath) {
469
472
  await writeFile(outputPath, buf);
470
- return { ...result, outputPath };
473
+ return { ...result, outputPath,
474
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: buf.toString("base64") }] };
471
475
  }
472
476
  return { ...result, pngBase64: buf.toString("base64") };
473
477
  }