romdevtools 0.16.0 → 0.22.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 (209) hide show
  1. package/AGENTS.md +75 -16
  2. package/CHANGELOG.md +316 -0
  3. package/examples/README.md +2 -0
  4. package/examples/atari2600/templates/platformer.asm +460 -0
  5. package/examples/atari2600/templates/racing.asm +463 -0
  6. package/examples/atari2600/templates/shmup.asm +386 -0
  7. package/examples/atari2600/templates/sports.asm +362 -0
  8. package/examples/atari7800/templates/default.c +49 -5
  9. package/examples/atari7800/templates/hello_sprite.c +48 -4
  10. package/examples/atari7800/templates/music_demo.c +47 -2
  11. package/examples/atari7800/templates/platformer.c +43 -4
  12. package/examples/atari7800/templates/puzzle.c +39 -4
  13. package/examples/atari7800/templates/racing.c +39 -4
  14. package/examples/atari7800/templates/shmup.c +40 -2
  15. package/examples/atari7800/templates/sports.c +36 -5
  16. package/examples/c64/templates/platformer.c +19 -5
  17. package/examples/c64/templates/puzzle.c +32 -2
  18. package/examples/c64/templates/shmup.c +28 -2
  19. package/examples/c64/templates/sports.c +30 -2
  20. package/examples/c64/templates/tile_engine.c +77 -27
  21. package/examples/gb/templates/default.c +110 -16
  22. package/examples/gb/templates/hello_sprite.c +15 -6
  23. package/examples/gb/templates/music_demo.c +36 -0
  24. package/examples/gb/templates/platformer.c +28 -6
  25. package/examples/gb/templates/puzzle.c +35 -4
  26. package/examples/gb/templates/racing.c +75 -10
  27. package/examples/gb/templates/shmup.c +41 -3
  28. package/examples/gb/templates/sports.c +51 -3
  29. package/examples/gb/templates/tile_engine.c +3 -2
  30. package/examples/gba/templates/gba_hello.c +29 -11
  31. package/examples/gba/templates/maxmod_demo.c +36 -2
  32. package/examples/gba/templates/platformer.c +3 -1
  33. package/examples/gba/templates/puzzle.c +15 -3
  34. package/examples/gba/templates/racing.c +65 -3
  35. package/examples/gba/templates/shmup.c +41 -4
  36. package/examples/gba/templates/sports.c +36 -2
  37. package/examples/gba/templates/tonc_hello.c +41 -5
  38. package/examples/gba/templates/tonc_hello_sprite.c +35 -1
  39. package/examples/gbc/templates/default.c +103 -26
  40. package/examples/gbc/templates/hello_sprite.c +12 -3
  41. package/examples/gbc/templates/music_demo.c +56 -12
  42. package/examples/gbc/templates/platformer.c +28 -6
  43. package/examples/gbc/templates/puzzle.c +35 -4
  44. package/examples/gbc/templates/racing.c +88 -21
  45. package/examples/gbc/templates/shmup.c +37 -3
  46. package/examples/gbc/templates/sports.c +48 -3
  47. package/examples/gbc/templates/tile_engine.c +3 -2
  48. package/examples/genesis/main.s +53 -1
  49. package/examples/genesis/templates/hello_sprite.c +25 -3
  50. package/examples/genesis/templates/puzzle.c +37 -3
  51. package/examples/genesis/templates/racing.c +44 -11
  52. package/examples/genesis/templates/sgdk_hello.c +34 -1
  53. package/examples/genesis/templates/shmup.c +31 -1
  54. package/examples/genesis/templates/shmup_2p.c +31 -0
  55. package/examples/genesis/templates/xgm2_demo.c +20 -0
  56. package/examples/gg/templates/default.c +56 -18
  57. package/examples/gg/templates/hello_sprite.c +25 -2
  58. package/examples/gg/templates/music_demo.c +24 -2
  59. package/examples/gg/templates/platformer.c +18 -12
  60. package/examples/gg/templates/puzzle.c +38 -7
  61. package/examples/gg/templates/racing.c +58 -9
  62. package/examples/gg/templates/shmup.c +47 -3
  63. package/examples/gg/templates/sports.c +57 -16
  64. package/examples/gg/templates/tile_engine.c +12 -6
  65. package/examples/lynx/templates/default.c +39 -8
  66. package/examples/lynx/templates/hello_sprite.c +15 -1
  67. package/examples/lynx/templates/music_demo.c +13 -1
  68. package/examples/lynx/templates/puzzle.c +28 -1
  69. package/examples/lynx/templates/racing.c +34 -7
  70. package/examples/lynx/templates/shmup.c +42 -3
  71. package/examples/lynx/templates/sports.c +29 -2
  72. package/examples/msx/platformer/main.c +213 -0
  73. package/examples/msx/puzzle/main.c +250 -0
  74. package/examples/msx/racing/main.c +249 -0
  75. package/examples/msx/shmup/main.c +288 -0
  76. package/examples/msx/sports/main.c +182 -0
  77. package/examples/nes/templates/default.c +67 -19
  78. package/examples/nes/templates/hello_sprite.c +35 -0
  79. package/examples/nes/templates/music_demo.c +40 -0
  80. package/examples/nes/templates/platformer.c +65 -6
  81. package/examples/nes/templates/puzzle.c +67 -6
  82. package/examples/nes/templates/racing.c +45 -13
  83. package/examples/nes/templates/shmup.c +51 -2
  84. package/examples/nes/templates/sports.c +51 -6
  85. package/examples/pce/catch_game/main.c +22 -3
  86. package/examples/pce/music_sfx/main.c +28 -1
  87. package/examples/pce/platformer/main.c +283 -0
  88. package/examples/pce/puzzle/main.c +304 -0
  89. package/examples/pce/racing/main.c +304 -0
  90. package/examples/pce/shmup/main.c +346 -0
  91. package/examples/pce/sports/main.c +254 -0
  92. package/examples/pce/sprite_move/main.c +7 -2
  93. package/examples/sms/main.c +35 -6
  94. package/examples/sms/templates/hello_sprite.c +29 -3
  95. package/examples/sms/templates/music_demo.c +18 -4
  96. package/examples/sms/templates/puzzle.c +34 -5
  97. package/examples/sms/templates/racing.c +39 -2
  98. package/examples/sms/templates/shmup.c +41 -2
  99. package/examples/sms/templates/shmup_2p.c +24 -1
  100. package/examples/sms/templates/sports.c +47 -4
  101. package/examples/snes/main.asm +108 -17
  102. package/examples/snes/templates/c-hello-data.asm +23 -0
  103. package/examples/snes/templates/c-hello.c +18 -1
  104. package/examples/snes/templates/default.c +50 -28
  105. package/examples/snes/templates/hello_sprite-data.asm +23 -0
  106. package/examples/snes/templates/hello_sprite.c +17 -1
  107. package/examples/snes/templates/music_demo-data.asm +23 -0
  108. package/examples/snes/templates/music_demo.c +22 -4
  109. package/examples/snes/templates/platformer-data.asm +22 -0
  110. package/examples/snes/templates/platformer.c +20 -2
  111. package/examples/snes/templates/puzzle-data.asm +22 -0
  112. package/examples/snes/templates/puzzle.c +21 -2
  113. package/examples/snes/templates/racing-data.asm +22 -0
  114. package/examples/snes/templates/racing.c +17 -1
  115. package/examples/snes/templates/shmup-data.asm +22 -0
  116. package/examples/snes/templates/shmup.c +20 -1
  117. package/examples/snes/templates/sports-data.asm +22 -0
  118. package/examples/snes/templates/sports.c +16 -1
  119. package/package.json +1 -1
  120. package/src/cheats/gamegenie.js +0 -1
  121. package/src/cli/smoke.js +1 -3
  122. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  123. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  124. package/src/host/LibretroHost.js +191 -16
  125. package/src/host/callbacks.js +9 -1
  126. package/src/host/chafa-render.js +2 -0
  127. package/src/host/dsp-state.js +2 -2
  128. package/src/host/gpgx-state.js +4 -0
  129. package/src/host/types.js +15 -8
  130. package/src/http/routes.js +1 -1
  131. package/src/http/tool-registry.js +26 -1
  132. package/src/mcp/server.js +1 -1
  133. package/src/mcp/state.js +36 -0
  134. package/src/mcp/tools/address-to-symbol.js +0 -1
  135. package/src/mcp/tools/art-loaders.js +1 -1
  136. package/src/mcp/tools/cart-parts.js +75 -4
  137. package/src/mcp/tools/classify-region.js +1 -1
  138. package/src/mcp/tools/diff-roms.js +1 -1
  139. package/src/mcp/tools/disasm-rebuild.js +507 -0
  140. package/src/mcp/tools/disasm.js +97 -9
  141. package/src/mcp/tools/find-references.js +1 -2
  142. package/src/mcp/tools/font-map.js +1 -1
  143. package/src/mcp/tools/frame.js +168 -3
  144. package/src/mcp/tools/index.js +0 -49
  145. package/src/mcp/tools/input-layout.js +0 -1
  146. package/src/mcp/tools/input.js +33 -3
  147. package/src/mcp/tools/lifecycle.js +18 -4
  148. package/src/mcp/tools/lospec.js +0 -19
  149. package/src/mcp/tools/platform-docs.js +1 -1
  150. package/src/mcp/tools/platform-tools.js +4 -4
  151. package/src/mcp/tools/project.js +54 -11
  152. package/src/mcp/tools/reinject.js +0 -1
  153. package/src/mcp/tools/rom-id.js +2 -2
  154. package/src/mcp/tools/snippets.js +2 -2
  155. package/src/mcp/tools/sprite-pipeline.js +1 -2
  156. package/src/mcp/tools/state.js +201 -14
  157. package/src/mcp/tools/tile-inspect.js +1 -1
  158. package/src/mcp/tools/toolchain.js +105 -12
  159. package/src/mcp/tools/watch-memory.js +137 -16
  160. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +34 -0
  161. package/src/platforms/atari2600/TROUBLESHOOTING.md +6 -0
  162. package/src/platforms/atari7800/TROUBLESHOOTING.md +6 -0
  163. package/src/platforms/c64/MENTAL_MODEL.md +45 -1
  164. package/src/platforms/c64/TROUBLESHOOTING.md +6 -0
  165. package/src/platforms/c64/d64.js +280 -0
  166. package/src/platforms/c64/sid.js +0 -2
  167. package/src/platforms/common/metasprite-adapters.js +1 -1
  168. package/src/platforms/common/metasprite-codegen.js +3 -3
  169. package/src/platforms/common/registers.js +5 -3
  170. package/src/platforms/gb/MENTAL_MODEL.md +10 -0
  171. package/src/platforms/gb/TROUBLESHOOTING.md +6 -0
  172. package/src/platforms/gb/lib/c/gb_runtime.c +4 -4
  173. package/src/platforms/gba/TROUBLESHOOTING.md +6 -0
  174. package/src/platforms/gbc/TROUBLESHOOTING.md +6 -0
  175. package/src/platforms/gbc/lib/c/gb_runtime.c +4 -4
  176. package/src/platforms/genesis/TROUBLESHOOTING.md +6 -0
  177. package/src/platforms/gg/TROUBLESHOOTING.md +6 -0
  178. package/src/platforms/lynx/TROUBLESHOOTING.md +6 -0
  179. package/src/platforms/msx/MENTAL_MODEL.md +10 -6
  180. package/src/platforms/msx/TROUBLESHOOTING.md +6 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +63 -2
  182. package/src/platforms/nes/TROUBLESHOOTING.md +6 -0
  183. package/src/platforms/nes/image-to-tilemap.js +3 -0
  184. package/src/platforms/nes/lib/asm/famitone2.s +5 -1
  185. package/src/platforms/pce/MENTAL_MODEL.md +9 -4
  186. package/src/platforms/pce/TROUBLESHOOTING.md +6 -0
  187. package/src/platforms/pce/lib/c/pce_video.c +1 -1
  188. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  189. package/src/platforms/snes/TROUBLESHOOTING.md +6 -0
  190. package/src/platforms/snes/brr.js +0 -2
  191. package/src/playtest/playtest.js +0 -7
  192. package/src/rom-id/identifier.js +15 -0
  193. package/src/toolchains/asar/asar.js +0 -9
  194. package/src/toolchains/assemble-snippet.js +30 -12
  195. package/src/toolchains/cc65/ines.js +145 -0
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +14 -1
  197. package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
  198. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
  199. package/src/toolchains/common/reassemble.js +10 -3
  200. package/src/toolchains/common/sdk-cache.js +1 -1
  201. package/src/toolchains/genesis-c/genesis-c.js +5 -3
  202. package/src/toolchains/index.js +27 -3
  203. package/src/toolchains/parse-errors.js +78 -1
  204. package/src/toolchains/sdcc/preflight-lint.js +5 -1
  205. package/src/toolchains/sdcc/sdcc.js +1 -1
  206. package/src/toolchains/sjasm/sjasm.js +1 -1
  207. package/src/toolchains/snes-c/snes-c.js +2 -2
  208. package/src/toolchains/vasm68k/vasm68k.js +2 -4
  209. package/src/toolchains/wladx/wladx.js +1 -1
@@ -2,16 +2,36 @@ import { mkdir, mkdtemp, writeFile, readdir, readFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { TOOLCHAINS } from "../../toolchains/registry.js";
5
- import { buildForPlatform } from "../../toolchains/index.js";
5
+ import { buildForPlatform, rankIssues } from "../../toolchains/index.js";
6
6
  import { resolveLinkerConfig } from "../../toolchains/cc65/preset-resolver.js";
7
+ import { parseBuildLog } from "../../toolchains/parse-errors.js";
8
+ import { inesHeaderSource, charsSource, nromFlatCfg } from "../../toolchains/cc65/ines.js";
7
9
  import { resolveCore } from "../../cores/registry.js";
8
10
  import { resetHost, getDisclosure } from "../state.js";
9
11
  import { PLATFORM_VIRTUAL_EXT } from "../../host/LibretroHost.js";
10
- import { imageContent, jsonContent, safeTool, textContent } from "../util.js";
12
+ import { imageContent, jsonContent, safeTool } from "../util.js";
11
13
  import { isPlaytestRunning } from "./playtest.js";
12
14
  import { buildSourceWithDebugCore } from "./symbols.js";
13
15
  import { log as serverLog } from "../log.js";
14
16
 
17
+ // crt0 (the per-platform startup stub) is assembled BEHIND the user's build.
18
+ // When it fails the agent gets a raw assembler log — route it through the same
19
+ // parser build() uses so the error leads with the first file:line: message
20
+ // (the issues[]-style surfacing), full log appended for fallback.
21
+ function crt0AssemblyError(log) {
22
+ const issues = parseBuildLog(log ?? "");
23
+ const first = issues.find((i) => i.severity === "error") ?? issues[0];
24
+ const headline = first
25
+ ? `${first.file ? first.file + ":" : ""}${first.line ? first.line + ": " : ""}${first.message}`
26
+ : "no structured diagnostic found";
27
+ return new Error(
28
+ `crt0 (startup stub) assembly failed: ${headline}` +
29
+ (issues.length > 1 ? ` (+${issues.length - 1} more)` : "") +
30
+ `\nThis is the bundled startup code, not your source — if it's the only error, ` +
31
+ `report it. Full assembler log:\n${log ?? ""}`,
32
+ );
33
+ }
34
+
15
35
  // Record a build outcome into the /log ring buffer so a failed build's stage +
16
36
  // error tail are diagnosable later (the request was already traced; this adds
17
37
  // the RESULT). On failure we keep a chunk of the build log; on success just the
@@ -25,6 +45,47 @@ function logBuildResult(verb, platform, result) {
25
45
  }
26
46
  }
27
47
 
48
+ /**
49
+ * Apply the `inesHeader` NES NROM-rebuild convenience: synthesize the iNES HEADER
50
+ * segment (+ CHARS segment that .incbins the CHR blob when chrBanks>0) into the
51
+ * sources map and set a flat NROM linker .cfg. The agent supplies only the PRG
52
+ * disassembly + the CHR blob — no glue .s/.cfg, no hand-derived header bytes.
53
+ * Shared by build({output:'rom'|'run'}). See toolchains/cc65/ines.js.
54
+ *
55
+ * @param {{platform:string, inesHeader:any, sources:Record<string,string>|null|undefined, source:string|null|undefined, linkerConfig:string|undefined, mergedBinaryIncludes:Record<string,string>}} a
56
+ * @returns {{sources:Record<string,string>, source:null, linkerConfig:string}}
57
+ */
58
+ function applyInesHeader({ platform, inesHeader, sources, source, linkerConfig, mergedBinaryIncludes }) {
59
+ if (platform !== "nes") {
60
+ throw new Error(`inesHeader is NES-only (iNES is the NES cartridge format); platform was '${platform}'.`);
61
+ }
62
+ if (linkerConfig) {
63
+ throw new Error("Pass either `inesHeader` (auto-generates the NROM .cfg) OR `linkerConfig`, not both.");
64
+ }
65
+ const chr = inesHeader.chrBanks ?? 0;
66
+ // A rebuild is always multi-source (PRG disassembly + synthesized header), so
67
+ // normalize a lone `source` into the sources map.
68
+ const out = sources == null
69
+ ? (source != null ? { "main.s": source } : {})
70
+ : { ...sources };
71
+ if (out["ines_header.s"] == null) out["ines_header.s"] = inesHeaderSource(inesHeader);
72
+ if (chr > 0) {
73
+ const binNames = Object.keys(mergedBinaryIncludes);
74
+ const chrName = inesHeader.chrIncbin ?? (binNames.length === 1 ? binNames[0] : undefined);
75
+ if (!chrName) {
76
+ throw new Error(
77
+ `inesHeader.chrBanks=${chr} needs the CHR-ROM blob: pass it via binaryIncludePaths ` +
78
+ `(e.g. {"chr.bin":"/path/chr.bin"}). With more than one binary include, set inesHeader.chrIncbin to its name.`
79
+ );
80
+ }
81
+ if (mergedBinaryIncludes[chrName] == null) {
82
+ throw new Error(`inesHeader.chrIncbin '${chrName}' is not among the binary includes (${binNames.join(", ") || "none"}).`);
83
+ }
84
+ if (out["ines_chars.s"] == null) out["ines_chars.s"] = charsSource(chrName);
85
+ }
86
+ return { sources: out, source: null, linkerConfig: nromFlatCfg(inesHeader) };
87
+ }
88
+
28
89
  // One-shot "open playtest" hint state — per MCP session, set after the
29
90
  // hint has been delivered once so we don't keep nagging legitimate
30
91
  // headless flows (CI, automated tests, batch RE work). Keyed by the
@@ -205,7 +266,7 @@ export function installToolchainCore({ id }) {
205
266
  }
206
267
 
207
268
  export function registerToolchainTools(server, z, sessionKey) {
208
- async function buildSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, crt0, crt0Path, codeLoc, dataLoc, options, linkerConfig, outputPath, inline = false, includeSymbols = false, lint = "advisory", runtime, maxmod, rebuildSdk }) {
269
+ async function buildSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, crt0, crt0Path, codeLoc, dataLoc, options, linkerConfig, inesHeader, outputPath, inline = false, includeSymbols = false, lint = "advisory", runtime, maxmod, rebuildSdk }) {
209
270
  // Reject conflicting inline vs path args — fail loud, not silent.
210
271
  if (source != null && sourcePath != null) {
211
272
  throw new Error("build({output:'rom'}): pass either `source` OR `sourcePath`, not both.");
@@ -252,6 +313,12 @@ export function registerToolchainTools(server, z, sessionKey) {
252
313
  mergedBinaryIncludes[name] = bytes.toString("base64");
253
314
  }
254
315
  }
316
+ // inesHeader — NES NROM rebuild convenience (see applyInesHeader).
317
+ if (inesHeader) {
318
+ ({ sources, source, linkerConfig } = applyInesHeader({
319
+ platform, inesHeader, sources, source, linkerConfig, mergedBinaryIncludes,
320
+ }));
321
+ }
255
322
  const { cfg: resolvedLinkerConfig, supportSources } = await resolveLinkerConfig(platform, linkerConfig);
256
323
  // Splice preset support sources (e.g. custom crt0) into the project.
257
324
  // User sources take precedence — never overwrite a source the agent
@@ -270,7 +337,7 @@ export function registerToolchainTools(server, z, sessionKey) {
270
337
  const { runSdasgb, runSdasz80 } = await import("../../toolchains/sdcc/sdcc.js");
271
338
  const asm = isSm83 ? await runSdasgb({ source: crt0 }) : await runSdasz80({ source: crt0 });
272
339
  if (!asm.rel) {
273
- throw new Error(`crt0 assembly failed:\n${asm.log}`);
340
+ throw crt0AssemblyError(asm.log);
274
341
  }
275
342
  crt0Rel = asm.rel;
276
343
  }
@@ -352,7 +419,7 @@ export function registerToolchainTools(server, z, sessionKey) {
352
419
  ...(result.stage ? { stage: result.stage } : {}),
353
420
  ...(result.sdkEditIgnored ? { sdkEditIgnored: result.sdkEditIgnored } : {}),
354
421
  ...(await logField(result.log, inline, logSibling, result.ok)),
355
- issues: result.issues ?? [],
422
+ issues: rankIssues(result.issues ?? []),
356
423
  ...(showHint ? { hint: showHint } : {}),
357
424
  };
358
425
  // When a build failed on a specific TU (multi-source SDCC build),
@@ -397,7 +464,7 @@ export function registerToolchainTools(server, z, sessionKey) {
397
464
  return jsonContent(payload);
398
465
  }
399
466
 
400
- async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
467
+ async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, inesHeader, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
401
468
  const { buildForPlatform } = await import("../../toolchains/index.js");
402
469
  const resolved = resolveCore(platform);
403
470
  if (!resolved) throw new Error(`no core available for platform '${platform}'`);
@@ -470,6 +537,12 @@ export function registerToolchainTools(server, z, sessionKey) {
470
537
  mergedBinaryIncludes[name] = bytes.toString("base64");
471
538
  }
472
539
  }
540
+ // inesHeader — NES NROM rebuild convenience (see applyInesHeader).
541
+ if (inesHeader) {
542
+ ({ sources, source, linkerConfig } = applyInesHeader({
543
+ platform, inesHeader, sources, source, linkerConfig, mergedBinaryIncludes,
544
+ }));
545
+ }
473
546
  const { cfg: resolvedLinkerConfig2, supportSources: supportSources2 } = await resolveLinkerConfig(platform, linkerConfig);
474
547
  const mergedSources2 = sources
475
548
  ? { ...supportSources2, ...sources }
@@ -484,7 +557,7 @@ export function registerToolchainTools(server, z, sessionKey) {
484
557
  const { runSdasgb, runSdasz80 } = await import("../../toolchains/sdcc/sdcc.js");
485
558
  const asm = isSm83 ? await runSdasgb({ source: crt0 }) : await runSdasz80({ source: crt0 });
486
559
  if (!asm.rel) {
487
- throw new Error(`crt0 assembly failed:\n${asm.log}`);
560
+ throw crt0AssemblyError(asm.log);
488
561
  }
489
562
  crt0Rel2 = asm.rel;
490
563
  }
@@ -515,7 +588,7 @@ export function registerToolchainTools(server, z, sessionKey) {
515
588
  toolchain: build.toolchain,
516
589
  exitCode: build.exitCode,
517
590
  ...(await logField(build.log, false, null)),
518
- issues: build.issues ?? [],
591
+ issues: rankIssues(build.issues ?? []),
519
592
  });
520
593
  }
521
594
 
@@ -571,7 +644,7 @@ export function registerToolchainTools(server, z, sessionKey) {
571
644
  // Surface lint/build issues even on successful runs so agents see
572
645
  // linter warnings BEFORE the next iteration (was: runSource silently
573
646
  // ran with warnings, agent missed them, hit the crash 100 functions later).
574
- ...((build.issues ?? []).length > 0 ? { issues: build.issues } : {}),
647
+ ...((build.issues ?? []).length > 0 ? { issues: rankIssues(build.issues) } : {}),
575
648
  ...(hint ? { hint } : {}),
576
649
  };
577
650
 
@@ -616,6 +689,7 @@ export function registerToolchainTools(server, z, sessionKey) {
616
689
  server.tool(
617
690
  "build",
618
691
  "Compile/assemble source for a target platform; one tool keyed by `output`.\n" +
692
+ "ON FAILURE (ok:false): READ `issues[]` FIRST — it's the structured error list ({file,line,col,severity,message,stage}) and usually names the exact line to fix. Only fall back to the raw `log` if `issues[]` is empty. Don't guess or rebuild blindly before reading it.\n" +
619
693
  "• output:'rom' (default) — assemble or compile `source` (single) / `sources` ({name:contents}) / `sourcePath` / `sourcesPaths`. Returns the ROM (path by default; `inline:true` for binaryBase64) + build log. **`binaryIncludes`/`binaryIncludePaths` (base64/path CHR-ROM, music blobs for `.incbin`) — WITHOUT them no game with external assets builds.** `includes`/`includePaths` for `.include`d text. `linkerConfig` (cc65; NES preset 'chr-ram-runtime' RECOMMENDED). `crt0`/`crt0Path`/`codeLoc`/`dataLoc` (SDCC). `runtime`/`maxmod`/`rebuildSdk` (GBA/Genesis SDK). **`lint:'strict'` fails the build (stage:'lint', no binary) if the pre-flight SDCC crash-pattern scan flags anything (e.g. the uint8 loop-bound trap); 'advisory' (default) just lists hits in issues[].** **`includeSymbols:true` returns the .map text inline on a PLAIN rom build — distinct from output:'romWithDebug' which writes .dbg/.map FILES.** Language is inferred from extension/content — usually OMIT `language`.\n" +
620
694
  "• output:'romWithDebug' — like 'rom' but also emits linker debug info for the `symbols` tool: cc65 → `.dbg`, SDCC → sdld `.map`, Genesis m68k → GNU ld map (find where a RAM var landed). DEFAULT writes ROM + debug file + log to disk (`outputPath` required unless `inline:true`).\n" +
621
695
  "• output:'run' — BUILD + LOAD + RUN + SCREENSHOT in one round trip — the fastest iteration loop. Same build args; runs `frames` frames and returns the screenshot INLINE. `holdInputs` holds controller state; `screenshotPath` writes the PNG to disk instead; `projectName` titles the playtest window.\n" +
@@ -639,7 +713,15 @@ export function registerToolchainTools(server, z, sessionKey) {
639
713
  codeLoc: z.coerce.number().int().optional().describe("SDCC — _CODE load address (default $0000; GB/GBC bundled crt0 wants 0x150)."),
640
714
  dataLoc: z.coerce.number().int().optional().describe("SDCC — _DATA (WRAM) load address (default $C000 on Z80). NOT read by output:'romWithDebug'."),
641
715
  options: z.array(z.string()).optional().describe("output:'rom' — extra toolchain CLI options."),
642
- linkerConfig: z.string().optional().describe("ld65 linker config (cc65). NES preset 'chr-ram-runtime' (RECOMMENDED — full crt0 + iNES header + NMI w/ OAM DMA + `_shadow_oam` at $0200) or 'chr-ram' (bare nmi:rti stub), or full .cfg contents. Preset NAMES only resolve on output:'rom'/'run'; output:'romWithDebug' takes raw .cfg contents only."),
716
+ linkerConfig: z.string().optional().describe("ld65 linker config (cc65). NES presets: 'chr-ram-runtime' (RECOMMENDED for homebrew C — full crt0 + iNES header + NMI w/ OAM DMA + `_shadow_oam` at $0200), 'chr-ram' (bare nmi:rti stub), 'chr-rom' (cc65-C with FIXED CHR-ROM art — segment split + CHARS segment; supply CHR via binaryIncludePaths into a CHARS source + the header via `inesHeader`). Or full .cfg contents. Preset NAMES only resolve on output:'rom'/'run'; output:'romWithDebug' takes raw .cfg contents only. **For rebuilding a commercial NROM game from its disassembly, prefer `inesHeader` over a raw .cfg.**"),
717
+ inesHeader: z.object({
718
+ prgBanks: z.coerce.number().int().min(1).max(255).describe("16KB PRG-ROM banks (1 = NROM-128, 2 = NROM-256)."),
719
+ chrBanks: z.coerce.number().int().min(0).max(255).optional().describe("8KB CHR-ROM banks (0 = CHR-RAM, no CHARS segment). Default 0."),
720
+ mapper: z.coerce.number().int().min(0).max(255).optional().describe("iNES mapper number. Default 0 (NROM)."),
721
+ mirroring: z.enum(["horizontal", "vertical"]).optional().describe("Nametable mirroring. Default 'horizontal'."),
722
+ battery: z.boolean().optional().describe("PRG-RAM battery (flags6 bit 1). Default false."),
723
+ chrIncbin: z.string().optional().describe("Name of the binaryInclude holding the CHR-ROM blob to .incbin (only needed when chrBanks>0 AND there's more than one binary include; else the sole include is used)."),
724
+ }).optional().describe("NES iNES-header + NROM-rebuild convenience. Auto-emits the 16-byte iNES HEADER segment + (for chrBanks>0) a CHARS segment that .incbins the CHR blob (from binaryIncludePaths), and sets a flat NROM linker .cfg (HEADER+PRG+CHARS). The agent supplies ONLY the PRG disassembly source(s) + the CHR blob — no glue .s/.cfg files, no hand-derived header bytes. THE shape for rebuilding an NROM commercial game from `disasm({target:'project'})`. Mutually exclusive with `linkerConfig`."),
643
725
  runtime: z.string().optional().describe("GBA — runtime: 'libtonc' (default), 'libgba', or 'none'."),
644
726
  maxmod: z.boolean().optional().describe("GBA — link maxmod for music (libmm.a). You still call mmInit/mmStart + hook mmVBlank."),
645
727
  rebuildSdk: z.boolean().optional().describe("GBA + Genesis — rebuild the bundled SDK (libtonc/libgba/maxmod/SGDK) from vendored source instead of the prebuilt seed (~20-40s). Only if you edited SDK source (else an `sdkEditIgnored` warning fires)."),
@@ -729,6 +811,17 @@ export function projectBuildRecipe(platform, names) {
729
811
  if (/crt0.*\.s$/i.test(n) || /\.cfg$/i.test(n)) r.skip.add(n);
730
812
  }
731
813
  }
814
+ } else if (platform === "msx") {
815
+ // MSX ships msx_crt0.s — it MUST be passed AS the crt0 (replacing the stock
816
+ // SDCC z80 crt0.rel), NOT compiled as a plain source. The stock crt0 is a
817
+ // CP/M-style $0000 runtime with no MSX cartridge header; if it links, IT
818
+ // provides the $4010 entry (an SDCC gsinit stub = `nop nop nop ret`) and our
819
+ // msx_crt0.s _HEADER ("AB" + INIT pointer) gets dropped. The toolchain then
820
+ // synthesizes a header pointing INIT at $4010 = that stub, so the BIOS CALLs
821
+ // a no-op that returns immediately → "No cartridge found" (proven: real
822
+ // commercial ROMs boot in the same host; only our scaffolds failed). Routing
823
+ // msx_crt0.s through crt0 makes ITS header + init the cartridge entry.
824
+ if (has("msx_crt0.s")) { r.crt0File = "msx_crt0.s"; r.codeLoc = 0x4010; }
732
825
  } else if (platform === "sms" || platform === "gg") {
733
826
  // SMS/GG auto-inject their bundled crt0 inside buildForPlatform — so the
734
827
  // scaffold's own *_crt0.s would be a DUPLICATE. Skip it.
@@ -852,7 +945,7 @@ export async function buildProjectCore({ path: projPath, platform, outputPath })
852
945
  const isSm83 = platform === "gb" || platform === "gbc";
853
946
  const { runSdasgb, runSdasz80 } = await import("../../toolchains/sdcc/sdcc.js");
854
947
  const asm = isSm83 ? await runSdasgb({ source: crt0 }) : await runSdasz80({ source: crt0 });
855
- if (!asm.rel) throw new Error(`crt0 assembly failed:\n${asm.log}`);
948
+ if (!asm.rel) throw crt0AssemblyError(asm.log);
856
949
  crt0Rel = asm.rel;
857
950
  }
858
951
 
@@ -891,6 +984,6 @@ export async function buildProjectCore({ path: projPath, platform, outputPath })
891
984
  romLayout: describeRomLayout(platform, result.binary),
892
985
  ...(result.stage ? { stage: result.stage } : {}),
893
986
  ...(await logField(result.log, false, logSibling, result.ok)),
894
- issues: result.issues ?? [],
987
+ issues: rankIssues(result.issues ?? []),
895
988
  });
896
989
  }
@@ -12,7 +12,7 @@
12
12
  // touching this byte and what does the screen look like after," not a complete
13
13
  // CPU trace. Instruction-level tracing would need core-side breakpoint hooks.
14
14
 
15
- import { mkdir, writeFile } from "node:fs/promises";
15
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
16
16
  import path from "node:path";
17
17
  import { getHost } from "../state.js";
18
18
  import { jsonContent, safeTool } from "../util.js";
@@ -21,6 +21,30 @@ import { MemoryRegionToRetro } from "../../host/types.js";
21
21
  import { resolveButtonAlias } from "./input.js";
22
22
  import { getCPUStateCore } from "./platform-tools.js";
23
23
  import { traceVramSourceCore } from "./trace-vram-source.js";
24
+ import { resolveStatePath } from "./state.js";
25
+
26
+ // Restore a savestate (in-memory slot `fromState` OR disk file `fromStatePath`)
27
+ // before a trace, so on:'range'/'pc' run from a known moment. Returns a small
28
+ // {slot|path} descriptor for the response, or null if neither was given.
29
+ // Throws a clear error if both are given or the restore fails.
30
+ async function maybeRestoreState(host, fromState, fromStatePath) {
31
+ if (fromState && fromStatePath) {
32
+ throw new Error("watch: provide `fromState` (slot) OR `fromStatePath` (file), not both.");
33
+ }
34
+ if (fromStatePath) {
35
+ const resolved = resolveStatePath(fromStatePath, host);
36
+ let blob;
37
+ try { blob = new Uint8Array(await readFile(resolved)); }
38
+ catch (e) { throw new Error(`watch: can't read fromStatePath '${resolved}': ${e.message}`); }
39
+ host.unserializeState(blob);
40
+ return { path: resolved };
41
+ }
42
+ if (fromState) {
43
+ host.loadState(fromState); // throws if the slot doesn't exist
44
+ return { slot: fromState };
45
+ }
46
+ return null;
47
+ }
24
48
 
25
49
  // Let a human watching /livestream (or a playtest window) SEE what a
26
50
  // breakpoint/watch tool just did — the frozen breakpoint frame, the state when a
@@ -32,7 +56,7 @@ import { traceVramSourceCore } from "./trace-vram-source.js";
32
56
  // observer wrapper encodes it ASYNCHRONOUSLY, after the agent's response has
33
57
  // already gone out. The provider is stripped from the agent-visible result. The
34
58
  // frame is captured by reference now (correct frozen state) but rasterized later.
35
- function attachObserverFrame(json, host) {
59
+ export function attachObserverFrame(json, host) {
36
60
  json._observerFrameProvider = () => {
37
61
  try {
38
62
  const shot = host.screenshot(); // { pngBase64, width, height }
@@ -73,9 +97,18 @@ export function makePressDriver(host, presses) {
73
97
  let applied = 0; // how many scheduled presses actually got a frame
74
98
  let lastSet = null; // last setInput payload we pushed (to avoid churn)
75
99
  const platform = host.status?.platform;
100
+ // When NO pressDuring schedule is given, the driver must NOT touch input at
101
+ // all — it leaves whatever persistent state input({op:'set'}) established in
102
+ // place, so a watch/breakpoint inherits the held pad exactly like
103
+ // frame({op:'step'}) does. (Previously applyForFrame(0) pushed an empty
104
+ // [{},{}] payload on the first frame, silently neutralizing a held Right+A —
105
+ // the v0.16.0 movement-analysis bug.) A non-empty schedule still OWNS the
106
+ // pad (deterministic capture): it drives the buttons and releases on finish.
107
+ const driven = presses.length > 0;
76
108
  return {
77
109
  applied: () => applied,
78
110
  applyForFrame(i) {
111
+ if (!driven) return; // inherit persistent input({op:'set'}) state
79
112
  // Buttons whose [frame, frame+holdFrames) window covers frame i.
80
113
  const held = presses.filter((p) => i >= p.frame && i < p.frame + (p.holdFrames ?? 2));
81
114
  // Build a 2-port setInput payload from the held buttons.
@@ -90,6 +123,7 @@ export function makePressDriver(host, presses) {
90
123
  for (const p of held) { if (p.frame === i) applied++; }
91
124
  },
92
125
  finish() {
126
+ if (!driven) return; // we never touched input; leave it as the caller set it
93
127
  if (lastSet !== null && lastSet !== "[{},{}]") host.setInput({ ports: [{}, {}] });
94
128
  },
95
129
  };
@@ -104,6 +138,61 @@ export function makePressDriver(host, presses) {
104
138
  // never disagree again.
105
139
  const MEMORY_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
106
140
 
141
+ // Abort-guard for input-driven watchpoint runs: sample caller-named bytes each
142
+ // frame; the FIRST one to change stops the run with {label,addr,before,after}.
143
+ // Lets a derailed driven scenario (player died, scene flipped) return immediately
144
+ // with WHY, instead of burning all maxFrames on a meaningless miss.
145
+ function makeAbortGuard(host, abortIf) {
146
+ const specs = Array.isArray(abortIf) ? abortIf : [];
147
+ const watched = specs.map((s, _i) => {
148
+ const region = s.region ?? "system_ram";
149
+ const offset = s.offset ?? 0;
150
+ let before;
151
+ try { before = host.readMemory(region, offset, 1)[0]; } catch { before = null; }
152
+ const addr = "$" + (offset >>> 0).toString(16).toUpperCase();
153
+ return { region, offset, addr, label: s.label ?? `${region}${addr}`, before };
154
+ }).filter((w) => w.before != null);
155
+ return {
156
+ count: watched.length,
157
+ check() {
158
+ for (const w of watched) {
159
+ let now;
160
+ try { now = host.readMemory(w.region, w.offset, 1)[0]; } catch { continue; }
161
+ if (now !== w.before) {
162
+ return {
163
+ label: w.label, addr: w.addr,
164
+ before: "0x" + w.before.toString(16).padStart(2, "0").toUpperCase(),
165
+ after: "0x" + now.toString(16).padStart(2, "0").toUpperCase(),
166
+ };
167
+ }
168
+ }
169
+ return null;
170
+ },
171
+ };
172
+ }
173
+
174
+ // No-hit note for bpFindWriter. The full "two reasons" explainer (~100 tokens)
175
+ // is useful ONCE; as a repeated payload it's pure overhead (v0.15.0 feedback
176
+ // #2b). Emit the long form only on the first miss per MCP session, a one-liner
177
+ // after.
178
+ const _bpNoHitSeen = new Set();
179
+ function noHitNote(sessionKey) {
180
+ const short = "No per-byte CPU write to that address within maxFrames. Either the event didn't fire " +
181
+ "(raise maxFrames / drive it with pressDuring; add abortIf to stop early if the scenario derails), " +
182
+ "OR the region is rebuilt as a BLOCK (OAM/display-list/VRAM bulk-copy or DMA) so no single instruction " +
183
+ "writes it — watch the SOURCE struct the copy reads from instead.";
184
+ if (_bpNoHitSeen.has(sessionKey)) return short;
185
+ _bpNoHitSeen.add(sessionKey);
186
+ return "No per-byte CPU write to that address within maxFrames. Two common reasons: " +
187
+ "(1) the event didn't fire — increase maxFrames or drive the game with pressDuring to trigger it " +
188
+ "(and pass `abortIf` to abort early + say why if a driven run derails, e.g. the player dies). " +
189
+ "(2) this region is rebuilt as a BLOCK rather than written field-by-field — sprite/OAM shadow tables, " +
190
+ "display lists, and VRAM are typically bulk-copied (memcpy/loop) or DMA'd from a SOURCE struct elsewhere, " +
191
+ "so no single instruction writes this exact byte. In that case the address you want is the SOURCE: watch " +
192
+ "the struct the copy reads from (find it with searchValue on the live value), or for graphics trace the " +
193
+ "DMA/copy source (Genesis VRAM DMA source is in VDP regs). 'Address is wrong' is usually case (2), not a bad address.";
194
+ }
195
+
107
196
  function tryGetPC(host) {
108
197
  try {
109
198
  const platform = host.status?.platform;
@@ -420,7 +509,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
420
509
 
421
510
  // breakpoint({on:write|read|pc}) STOP-on-first. on:write precision:exact=bpFindWriter
422
511
  // (core watchpoint, true PC under IRQ), precision:sampled=bpRunUntilWrite (frame PC).
423
- async function bpFindWriter({ address, maxFrames = 600, pressDuring }) {
512
+ async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf }) {
424
513
  const host = getHost(sessionKey);
425
514
  if (!host.watchpointSupported || !host.watchpointSupported()) {
426
515
  return jsonContent({
@@ -432,26 +521,44 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
432
521
  host.setWatchpoint(address, true);
433
522
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
434
523
  const pressDriver = makePressDriver(host, presses);
524
+ // Abort-guard: sample caller-named "still valid?" bytes each frame; if any
525
+ // changes, a driven run that DERAILED (player died → title screen, scene
526
+ // flipped, …) stops immediately instead of burning all maxFrames and
527
+ // returning a meaningless found:false. (v0.15.0 feedback #2.)
528
+ const guard = makeAbortGuard(host, abortIf);
435
529
  let result = null;
530
+ let aborted = null;
436
531
  for (let i = 0; i < maxFrames; i++) {
437
532
  pressDriver.applyForFrame(i);
438
533
  host.stepFrames(1);
439
534
  const w = host.getWatchpoint();
440
535
  if (w.hits > 0) { result = { ...w, framesStepped: i + 1 }; break; }
536
+ const ab = guard.check();
537
+ if (ab) { aborted = { ...ab, framesStepped: i + 1 }; break; }
441
538
  }
442
539
  pressDriver.finish();
443
540
  host.setWatchpoint(address, false); // disarm
541
+ if (aborted) {
542
+ return jsonContent({
543
+ found: false, aborted: true, abortedBy: aborted.label,
544
+ abortAddress: aborted.addr, before: aborted.before, after: aborted.after,
545
+ framesStepped: aborted.framesStepped,
546
+ ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
547
+ note: `Run aborted early: the watched abort byte ${aborted.label} (${aborted.addr}) changed ` +
548
+ `${aborted.before}→${aborted.after} at frame ${aborted.framesStepped}, so the driven scenario left the ` +
549
+ `expected state (e.g. player died / scene changed) before the write fired. The found:false is NOT a real ` +
550
+ `miss — fix the input plan or pick a different start state, then re-run.`,
551
+ });
552
+ }
444
553
  if (!result) {
445
554
  return jsonContent({
446
555
  found: false, address: "$" + address.toString(16).toUpperCase(), framesStepped: maxFrames,
447
556
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
448
- note: "No per-byte CPU write to that address within maxFrames. Two common reasons: " +
449
- "(1) the event didn't fire — increase maxFrames or drive the game with pressDuring to trigger it. " +
450
- "(2) this region is rebuilt as a BLOCK rather than written field-by-fieldsprite/OAM shadow tables, " +
451
- "display lists, and VRAM are typically bulk-copied (memcpy/loop) or DMA'd from a SOURCE struct elsewhere, " +
452
- "so no single instruction writes this exact byte. In that case the address you want is the SOURCE: watch " +
453
- "the struct the copy reads from (find it with searchValue on the live value), or for graphics trace the " +
454
- "DMA/copy source (Genesis VRAM DMA source is in VDP regs). 'Address is wrong' is usually case (2), not a bad address.",
557
+ ...(abortIf && abortIf.length ? { abortIfArmed: guard.count } : {}),
558
+ // One-line hint by default; the full "two reasons" explainer is verbose
559
+ // boilerplate as a repeated payload (v0.15.0 feedback #2b)gated to the
560
+ // FIRST miss per session.
561
+ note: noHitNote(sessionKey),
455
562
  });
456
563
  }
457
564
  // When the core reports a PRG-ROM offset for the PC (fceumm/NES), it
@@ -654,7 +761,12 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
654
761
  button: z.string(),
655
762
  port: z.number().int().min(0).max(3).default(0),
656
763
  holdFrames: z.number().int().min(1).default(2),
657
- })).optional().describe("Schedule input while waiting (drive the game to the state that triggers the condition)."),
764
+ })).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."),
765
+ abortIf: z.array(z.object({
766
+ region: z.enum(MEMORY_REGIONS).optional().describe("memory region (default system_ram)"),
767
+ offset: z.number().int().min(0).describe("byte offset within the region"),
768
+ label: z.string().optional().describe("human name for this guard byte"),
769
+ })).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)."),
658
770
  },
659
771
  safeTool(async (args) => {
660
772
  switch (args.on) {
@@ -821,13 +933,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
821
933
 
822
934
  // ── Range watch + coverage trace (item 2, discovery) ────────────────────────
823
935
 
824
- async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200 }) {
936
+ async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200, fromState, fromStatePath }) {
825
937
  const host = getHost(sessionKey);
826
938
  if (!host.rangeWatchSupported || !host.rangeWatchSupported()) {
827
939
  return jsonContent({ notSupported: true, events: [],
828
940
  note: "This core build has no range watch (shipped on all 14 platforms as of 0.6.0 — update the core package). Use breakpoint({on:'write'/'read'}) for a single address." });
829
941
  }
830
942
  if (end < start) throw new Error("watch({on:'range'}): end must be >= start.");
943
+ // Optionally restore a savestate FIRST, so the trace runs from a known
944
+ // moment (the deterministic "jump to the boss fight, then see what writes
945
+ // HP" loop) instead of from wherever the live session happens to be.
946
+ const stateInfo = await maybeRestoreState(host, fromState, fromStatePath);
831
947
  // pressDuring is driven inside the frame loop; watchRange's host method owns
832
948
  // stepping, so for now apply presses up front if any (simple: hold for the run).
833
949
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
@@ -846,19 +962,21 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
846
962
  return attachObserverFrame(jsonContent({
847
963
  range: "$" + start.toString(16).toUpperCase() + "..$" + end.toString(16).toUpperCase(),
848
964
  kind, total: r.total, returned: events.length, truncated: r.truncated,
965
+ ...(stateInfo ? { restoredFrom: stateInfo } : {}),
849
966
  distinctPCs, events,
850
967
  note: "distinctPCs is the actionable summary — each is a routine that touches this range; disasm({target:'rom'}) one to identify the renderer/reader. " +
851
968
  (r.truncated ? "TRUNCATED: more events than the buffer held — narrow `start..end` or `frames` for the full set." : ""),
852
969
  }), host);
853
970
  }
854
971
 
855
- async function wLogPC({ start, end, frames = 120, pressDuring, limit = 512 }) {
972
+ async function wLogPC({ start, end, frames = 120, pressDuring, limit = 512, fromState, fromStatePath }) {
856
973
  const host = getHost(sessionKey);
857
974
  if (!host.rangeWatchSupported || !host.rangeWatchSupported()) {
858
975
  return jsonContent({ notSupported: true, pcs: [],
859
976
  note: "This core build has no coverage trace (shipped on all 14 platforms as of 0.6.0 — update the core package)." });
860
977
  }
861
978
  if (end < start) throw new Error("watch({on:'pc'}): end must be >= start.");
979
+ const stateInfo = await maybeRestoreState(host, fromState, fromStatePath);
862
980
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
863
981
  const pressDriver = makePressDriver(host, presses);
864
982
  if (presses.length) pressDriver.applyForFrame(0);
@@ -868,6 +986,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
868
986
  return attachObserverFrame(jsonContent({
869
987
  window: "$" + start.toString(16).toUpperCase() + "..$" + end.toString(16).toUpperCase(),
870
988
  distinct: r.distinct, total: r.total, returned: pcs.length, truncated: r.truncated,
989
+ ...(stateInfo ? { restoredFrom: stateInfo } : {}),
871
990
  pcs,
872
991
  note: "Each PC is code that EXECUTED in this window. disasm({target:'rom'}) them to find the routine you're hunting. " +
873
992
  (r.truncated ? "TRUNCATED — narrow the window for the full distinct set." : ""),
@@ -880,8 +999,8 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
880
999
  "• on:'mem' — the power tool: answer 'what code is touching this RAM byte?' OR extract a frame-accurate event timeline (music-driver note onsets, physics arcs). Reports every frame that changed a watched byte as {frame,offset,before,after,pc}. " +
881
1000
  "Extras: `ranges:[{region,offset,length,label}]` watches MANY disjoint regions in ONE pass (identical frames); `onChange:'reset'|'increase'|'decrease'|'any'` edge filter (reset = counter-reload = the note-onset signal); `valueFilter:{min,max}`; `format:'series'` = compact columnar value-vs-frame curve (~10× smaller for a ramp); `sampleEvery`; `groupByPC` (collapse by sampled PC); `cheatLabels` (auto-name addresses from the cheat DB); `outputPath` streams all events as NDJSON; `stopOnFirst` exits on the first match. " +
882
1001
  "**CAVEAT: frame-level, not instruction-level (last value per frame); the sampled `pc` is a frame-boundary sample — for ISR-driven writes use breakpoint({on:'write', precision:'exact'}) for the real writer.**\n" +
883
- "• on:'range' — DISCOVERY: log EVERY instruction that reads or writes ANYWHERE in [start,end]. The fix for 'I don't know which PC touches this'. Returns {pc,address,value}[] + the actionable distinctPCs. (Ring-buffered: `truncated:true` if it overflows.)\n" +
884
- "• on:'pc' — DISCOVERY (coverage trace): record every DISTINCT PC executed within [start,end] — 'what code runs here?'. Log execution in the bank where you suspect the renderer lives during the moment it draws, then disassemble the PCs.\n" +
1002
+ "• on:'range' — DISCOVERY: log EVERY instruction that reads or writes ANYWHERE in [start,end]. The fix for 'I don't know which PC touches this'. Returns {pc,address,value}[] + the actionable distinctPCs. (Ring-buffered: `truncated:true` if it overflows.) `fromState`/`fromStatePath` restores a savestate FIRST so the trace runs from a known moment (jump to the boss, then see what writes HP) — deterministic + repeatable.\n" +
1003
+ "• on:'pc' — DISCOVERY (coverage trace): record every DISTINCT PC executed within [start,end] — 'what code runs here?'. Log execution in the bank where you suspect the renderer lives during the moment it draws, then disassemble the PCs. Also takes `fromState`/`fromStatePath` to trace from a restored moment.\n" +
885
1004
  "• on:'dma' — GENESIS ONLY: trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — WHERE in ROM?', which on:'write' can't catch). `precision:'exact'` (default) logs every mem→VDP DMA with its VRAM DESTINATION + ROM SOURCE + length (filter by `vramDest`±`destWindow`; `dedupe` collapses the per-frame refresh; `sourceFilter:'rom-only'` drops RAM→VRAM noise; catches a same-frame second DMA). `precision:'sampled'` is the cheap frame-sampled source-register read (may miss two DMAs in one frame, dest-agnostic). On non-Genesis cores returns `notSupported`.",
886
1005
  {
887
1006
  on: z.enum(["mem", "range", "pc", "dma"])
@@ -921,7 +1040,9 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
921
1040
  button: z.string(),
922
1041
  port: z.number().int().min(0).max(3).default(0),
923
1042
  holdFrames: z.number().int().min(1).default(2),
924
- })).optional().describe("Schedule input while watching (drive the game to the state that touches the watched bytes/range, or uploads the graphic for on:'dma')."),
1043
+ })).optional().describe("Schedule input while watching (drive the game to the state that touches the watched bytes/range, or uploads the graphic for on:'dma'). 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)."),
1044
+ fromState: z.string().optional().describe("on:'range'/'pc' — restore an in-memory savestate SLOT (from state({op:'save', name})) BEFORE tracing, so the log runs from a known moment (jump to the boss fight, then see what writes HP). Deterministic + repeatable."),
1045
+ fromStatePath: z.string().optional().describe("on:'range'/'pc' — like fromState but restore from a savestate FILE on disk (state({op:'save', path})). Relative path resolves against the loaded ROM's dir."),
925
1046
  },
926
1047
  safeTool(async (args) => {
927
1048
  switch (args.on) {
@@ -218,6 +218,40 @@ whole attract sequence each time. `input({op:'set'})`'s `requested` echo is what
218
218
  not proof the pad saw it — verify via the held-buttons RAM byte or a state
219
219
  transition.
220
220
 
221
+ ## 6b. Iterative measurement — boot to gameplay ONCE, reload per run
222
+
223
+ When you run many measurements on the same starting state (frame-by-frame
224
+ velocity tables, per-input timing, A/B trials), do NOT replay the
225
+ `loadMedia → step to title → press start → step into the level` preamble every
226
+ time — that intro is often hundreds of frames and 4+ calls, and you pay it on
227
+ every iteration. Boot once, snapshot, and reload the snapshot per run:
228
+
229
+ ```
230
+ loadMedia({platform, path, cheats}) // cheats survive the save state
231
+ frame({op:'step', frames:180}) // title renders
232
+ input({op:'press', button:'start'})
233
+ frame({op:'step', frames:300}) // into gameplay
234
+ state({op:'save', name:'ready'}) // <-- the reusable starting point
235
+
236
+ // then per measurement:
237
+ state({op:'load', name:'ready'}) // 1 call instead of the whole boot
238
+ ... drive + watch ...
239
+ ```
240
+
241
+ A save state captures applied cheats and the exact RAM/PPU state, so every run
242
+ starts byte-identical — *more* repeatable than re-booting, not just cheaper. A
243
+ named slot (`name`) lives in memory for the session; a `path` persists to disk
244
+ across sessions. If a task says "restart before each run", a state reload
245
+ satisfies that intent far cheaper than a fresh `loadMedia`.
246
+
247
+ **Driving input through a watched run:** a `watch`/`breakpoint` with NO
248
+ `pressDuring` inherits whatever `input({op:'set'})` last held (same as
249
+ `frame({op:'step'})`). But if you pass `pressDuring`, that schedule OWNS the pad
250
+ for the whole run and a prior `input({op:'set'})` is ignored — so to hold a
251
+ button *through* a watched window, put it in `pressDuring`, not a preceding
252
+ `set`. (This is the documented contract; the schemas of `watch`/`breakpoint`
253
+ and `input({op:'set'})` state it too.)
254
+
221
255
  ---
222
256
 
223
257
  ## Quick reference
@@ -1,5 +1,11 @@
1
1
  # Atari 2600 / VCS — troubleshooting
2
2
 
3
+ > **A build failed? Read `issues[]` FIRST.** Every build/compile call returns
4
+ > `issues: [{file, line, col, severity, message, stage}]` — the structured error
5
+ > list. It almost always names the exact line to fix. Read that before matching a
6
+ > symptom below or touching your source. Fall back to the raw `log` only if
7
+ > `issues[]` is empty but `ok:false`.
8
+
3
9
  When something's broken. Read MENTAL_MODEL.md first
4
10
  (via `platform({op:'doc', platform:"atari2600", name:"mental_model"})`).
5
11
 
@@ -1,5 +1,11 @@
1
1
  # Atari 7800 — troubleshooting
2
2
 
3
+ > **A build failed? Read `issues[]` FIRST.** Every build/compile call returns
4
+ > `issues: [{file, line, col, severity, message, stage}]` — the structured error
5
+ > list. It almost always names the exact line to fix. Read that before matching a
6
+ > symptom below or touching your source. Fall back to the raw `log` only if
7
+ > `issues[]` is empty but `ok:false`.
8
+
3
9
  When something's broken. Read MENTAL_MODEL.md first
4
10
  (via `platform({op:'doc', platform:"atari7800", name:"mental_model"})`)
5
11
  for the "what's going on" version — the 7800 is the architectural outlier