romdevtools 0.16.0 → 0.21.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 (110) hide show
  1. package/AGENTS.md +60 -12
  2. package/CHANGELOG.md +258 -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/platformer.c +43 -4
  10. package/examples/atari7800/templates/puzzle.c +39 -4
  11. package/examples/atari7800/templates/racing.c +39 -4
  12. package/examples/atari7800/templates/shmup.c +40 -2
  13. package/examples/atari7800/templates/sports.c +36 -5
  14. package/examples/c64/templates/platformer.c +19 -5
  15. package/examples/c64/templates/puzzle.c +32 -2
  16. package/examples/c64/templates/shmup.c +28 -2
  17. package/examples/c64/templates/sports.c +30 -2
  18. package/examples/gb/templates/default.c +110 -16
  19. package/examples/gb/templates/platformer.c +25 -4
  20. package/examples/gb/templates/puzzle.c +32 -2
  21. package/examples/gb/templates/racing.c +72 -8
  22. package/examples/gb/templates/shmup.c +38 -1
  23. package/examples/gb/templates/sports.c +48 -1
  24. package/examples/gba/templates/gba_hello.c +29 -11
  25. package/examples/gba/templates/puzzle.c +15 -3
  26. package/examples/gba/templates/racing.c +65 -3
  27. package/examples/gba/templates/shmup.c +41 -4
  28. package/examples/gba/templates/sports.c +36 -2
  29. package/examples/gba/templates/tonc_hello.c +41 -5
  30. package/examples/gbc/templates/default.c +103 -26
  31. package/examples/gbc/templates/platformer.c +25 -4
  32. package/examples/gbc/templates/puzzle.c +32 -2
  33. package/examples/gbc/templates/racing.c +85 -19
  34. package/examples/gbc/templates/shmup.c +34 -1
  35. package/examples/gbc/templates/sports.c +45 -1
  36. package/examples/genesis/templates/puzzle.c +37 -3
  37. package/examples/genesis/templates/racing.c +44 -11
  38. package/examples/genesis/templates/sgdk_hello.c +34 -1
  39. package/examples/genesis/templates/shmup.c +31 -1
  40. package/examples/gg/templates/default.c +56 -18
  41. package/examples/gg/templates/platformer.c +18 -12
  42. package/examples/gg/templates/puzzle.c +38 -7
  43. package/examples/gg/templates/racing.c +51 -5
  44. package/examples/gg/templates/shmup.c +47 -3
  45. package/examples/gg/templates/sports.c +46 -3
  46. package/examples/lynx/templates/default.c +39 -8
  47. package/examples/lynx/templates/puzzle.c +28 -1
  48. package/examples/lynx/templates/racing.c +34 -7
  49. package/examples/lynx/templates/shmup.c +42 -3
  50. package/examples/lynx/templates/sports.c +29 -2
  51. package/examples/msx/platformer/main.c +213 -0
  52. package/examples/msx/puzzle/main.c +250 -0
  53. package/examples/msx/racing/main.c +249 -0
  54. package/examples/msx/shmup/main.c +288 -0
  55. package/examples/msx/sports/main.c +182 -0
  56. package/examples/nes/templates/default.c +67 -19
  57. package/examples/nes/templates/platformer.c +65 -6
  58. package/examples/nes/templates/puzzle.c +67 -6
  59. package/examples/nes/templates/racing.c +45 -13
  60. package/examples/nes/templates/shmup.c +51 -2
  61. package/examples/nes/templates/sports.c +51 -6
  62. package/examples/pce/platformer/main.c +283 -0
  63. package/examples/pce/puzzle/main.c +304 -0
  64. package/examples/pce/racing/main.c +304 -0
  65. package/examples/pce/shmup/main.c +346 -0
  66. package/examples/pce/sports/main.c +254 -0
  67. package/examples/sms/main.c +35 -6
  68. package/examples/sms/templates/puzzle.c +34 -5
  69. package/examples/sms/templates/racing.c +39 -2
  70. package/examples/sms/templates/shmup.c +41 -2
  71. package/examples/sms/templates/sports.c +43 -2
  72. package/examples/snes/templates/default.c +50 -28
  73. package/examples/snes/templates/platformer-data.asm +22 -0
  74. package/examples/snes/templates/platformer.c +16 -1
  75. package/examples/snes/templates/puzzle-data.asm +22 -0
  76. package/examples/snes/templates/puzzle.c +17 -1
  77. package/examples/snes/templates/racing-data.asm +22 -0
  78. package/examples/snes/templates/racing.c +17 -1
  79. package/examples/snes/templates/shmup-data.asm +22 -0
  80. package/examples/snes/templates/shmup.c +20 -1
  81. package/examples/snes/templates/sports-data.asm +22 -0
  82. package/examples/snes/templates/sports.c +16 -1
  83. package/package.json +1 -1
  84. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  85. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  86. package/src/host/LibretroHost.js +122 -1
  87. package/src/host/callbacks.js +9 -1
  88. package/src/host/types.js +15 -8
  89. package/src/http/tool-registry.js +26 -1
  90. package/src/mcp/tools/cart-parts.js +75 -3
  91. package/src/mcp/tools/disasm-rebuild.js +507 -0
  92. package/src/mcp/tools/disasm.js +95 -6
  93. package/src/mcp/tools/frame.js +168 -3
  94. package/src/mcp/tools/lifecycle.js +4 -2
  95. package/src/mcp/tools/project.js +54 -9
  96. package/src/mcp/tools/state.js +201 -14
  97. package/src/mcp/tools/toolchain.js +76 -3
  98. package/src/mcp/tools/watch-memory.js +125 -14
  99. package/src/platforms/c64/MENTAL_MODEL.md +45 -1
  100. package/src/platforms/c64/d64.js +281 -0
  101. package/src/platforms/gb/MENTAL_MODEL.md +10 -0
  102. package/src/platforms/msx/MENTAL_MODEL.md +10 -6
  103. package/src/platforms/nes/MENTAL_MODEL.md +63 -2
  104. package/src/platforms/pce/MENTAL_MODEL.md +9 -4
  105. package/src/platforms/pce/lib/c/pce_video.c +1 -1
  106. package/src/rom-id/identifier.js +15 -0
  107. package/src/toolchains/cc65/ines.js +145 -0
  108. package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
  109. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
  110. package/src/toolchains/common/reassemble.js +10 -2
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { TOOLCHAINS } from "../../toolchains/registry.js";
5
5
  import { buildForPlatform } from "../../toolchains/index.js";
6
6
  import { resolveLinkerConfig } from "../../toolchains/cc65/preset-resolver.js";
7
+ import { inesHeaderSource, charsSource, nromFlatCfg } from "../../toolchains/cc65/ines.js";
7
8
  import { resolveCore } from "../../cores/registry.js";
8
9
  import { resetHost, getDisclosure } from "../state.js";
9
10
  import { PLATFORM_VIRTUAL_EXT } from "../../host/LibretroHost.js";
@@ -25,6 +26,47 @@ function logBuildResult(verb, platform, result) {
25
26
  }
26
27
  }
27
28
 
29
+ /**
30
+ * Apply the `inesHeader` NES NROM-rebuild convenience: synthesize the iNES HEADER
31
+ * segment (+ CHARS segment that .incbins the CHR blob when chrBanks>0) into the
32
+ * sources map and set a flat NROM linker .cfg. The agent supplies only the PRG
33
+ * disassembly + the CHR blob — no glue .s/.cfg, no hand-derived header bytes.
34
+ * Shared by build({output:'rom'|'run'}). See toolchains/cc65/ines.js.
35
+ *
36
+ * @param {{platform:string, inesHeader:any, sources:Record<string,string>|null|undefined, source:string|null|undefined, linkerConfig:string|undefined, mergedBinaryIncludes:Record<string,string>}} a
37
+ * @returns {{sources:Record<string,string>, source:null, linkerConfig:string}}
38
+ */
39
+ function applyInesHeader({ platform, inesHeader, sources, source, linkerConfig, mergedBinaryIncludes }) {
40
+ if (platform !== "nes") {
41
+ throw new Error(`inesHeader is NES-only (iNES is the NES cartridge format); platform was '${platform}'.`);
42
+ }
43
+ if (linkerConfig) {
44
+ throw new Error("Pass either `inesHeader` (auto-generates the NROM .cfg) OR `linkerConfig`, not both.");
45
+ }
46
+ const chr = inesHeader.chrBanks ?? 0;
47
+ // A rebuild is always multi-source (PRG disassembly + synthesized header), so
48
+ // normalize a lone `source` into the sources map.
49
+ const out = sources == null
50
+ ? (source != null ? { "main.s": source } : {})
51
+ : { ...sources };
52
+ if (out["ines_header.s"] == null) out["ines_header.s"] = inesHeaderSource(inesHeader);
53
+ if (chr > 0) {
54
+ const binNames = Object.keys(mergedBinaryIncludes);
55
+ const chrName = inesHeader.chrIncbin ?? (binNames.length === 1 ? binNames[0] : undefined);
56
+ if (!chrName) {
57
+ throw new Error(
58
+ `inesHeader.chrBanks=${chr} needs the CHR-ROM blob: pass it via binaryIncludePaths ` +
59
+ `(e.g. {"chr.bin":"/path/chr.bin"}). With more than one binary include, set inesHeader.chrIncbin to its name.`
60
+ );
61
+ }
62
+ if (mergedBinaryIncludes[chrName] == null) {
63
+ throw new Error(`inesHeader.chrIncbin '${chrName}' is not among the binary includes (${binNames.join(", ") || "none"}).`);
64
+ }
65
+ if (out["ines_chars.s"] == null) out["ines_chars.s"] = charsSource(chrName);
66
+ }
67
+ return { sources: out, source: null, linkerConfig: nromFlatCfg(inesHeader) };
68
+ }
69
+
28
70
  // One-shot "open playtest" hint state — per MCP session, set after the
29
71
  // hint has been delivered once so we don't keep nagging legitimate
30
72
  // headless flows (CI, automated tests, batch RE work). Keyed by the
@@ -205,7 +247,7 @@ export function installToolchainCore({ id }) {
205
247
  }
206
248
 
207
249
  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 }) {
250
+ 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
251
  // Reject conflicting inline vs path args — fail loud, not silent.
210
252
  if (source != null && sourcePath != null) {
211
253
  throw new Error("build({output:'rom'}): pass either `source` OR `sourcePath`, not both.");
@@ -252,6 +294,12 @@ export function registerToolchainTools(server, z, sessionKey) {
252
294
  mergedBinaryIncludes[name] = bytes.toString("base64");
253
295
  }
254
296
  }
297
+ // inesHeader — NES NROM rebuild convenience (see applyInesHeader).
298
+ if (inesHeader) {
299
+ ({ sources, source, linkerConfig } = applyInesHeader({
300
+ platform, inesHeader, sources, source, linkerConfig, mergedBinaryIncludes,
301
+ }));
302
+ }
255
303
  const { cfg: resolvedLinkerConfig, supportSources } = await resolveLinkerConfig(platform, linkerConfig);
256
304
  // Splice preset support sources (e.g. custom crt0) into the project.
257
305
  // User sources take precedence — never overwrite a source the agent
@@ -397,7 +445,7 @@ export function registerToolchainTools(server, z, sessionKey) {
397
445
  return jsonContent(payload);
398
446
  }
399
447
 
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 }) {
448
+ 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
449
  const { buildForPlatform } = await import("../../toolchains/index.js");
402
450
  const resolved = resolveCore(platform);
403
451
  if (!resolved) throw new Error(`no core available for platform '${platform}'`);
@@ -470,6 +518,12 @@ export function registerToolchainTools(server, z, sessionKey) {
470
518
  mergedBinaryIncludes[name] = bytes.toString("base64");
471
519
  }
472
520
  }
521
+ // inesHeader — NES NROM rebuild convenience (see applyInesHeader).
522
+ if (inesHeader) {
523
+ ({ sources, source, linkerConfig } = applyInesHeader({
524
+ platform, inesHeader, sources, source, linkerConfig, mergedBinaryIncludes,
525
+ }));
526
+ }
473
527
  const { cfg: resolvedLinkerConfig2, supportSources: supportSources2 } = await resolveLinkerConfig(platform, linkerConfig);
474
528
  const mergedSources2 = sources
475
529
  ? { ...supportSources2, ...sources }
@@ -639,7 +693,15 @@ export function registerToolchainTools(server, z, sessionKey) {
639
693
  codeLoc: z.coerce.number().int().optional().describe("SDCC — _CODE load address (default $0000; GB/GBC bundled crt0 wants 0x150)."),
640
694
  dataLoc: z.coerce.number().int().optional().describe("SDCC — _DATA (WRAM) load address (default $C000 on Z80). NOT read by output:'romWithDebug'."),
641
695
  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."),
696
+ 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.**"),
697
+ inesHeader: z.object({
698
+ prgBanks: z.coerce.number().int().min(1).max(255).describe("16KB PRG-ROM banks (1 = NROM-128, 2 = NROM-256)."),
699
+ chrBanks: z.coerce.number().int().min(0).max(255).optional().describe("8KB CHR-ROM banks (0 = CHR-RAM, no CHARS segment). Default 0."),
700
+ mapper: z.coerce.number().int().min(0).max(255).optional().describe("iNES mapper number. Default 0 (NROM)."),
701
+ mirroring: z.enum(["horizontal", "vertical"]).optional().describe("Nametable mirroring. Default 'horizontal'."),
702
+ battery: z.boolean().optional().describe("PRG-RAM battery (flags6 bit 1). Default false."),
703
+ 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)."),
704
+ }).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
705
  runtime: z.string().optional().describe("GBA — runtime: 'libtonc' (default), 'libgba', or 'none'."),
644
706
  maxmod: z.boolean().optional().describe("GBA — link maxmod for music (libmm.a). You still call mmInit/mmStart + hook mmVBlank."),
645
707
  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 +791,17 @@ export function projectBuildRecipe(platform, names) {
729
791
  if (/crt0.*\.s$/i.test(n) || /\.cfg$/i.test(n)) r.skip.add(n);
730
792
  }
731
793
  }
794
+ } else if (platform === "msx") {
795
+ // MSX ships msx_crt0.s — it MUST be passed AS the crt0 (replacing the stock
796
+ // SDCC z80 crt0.rel), NOT compiled as a plain source. The stock crt0 is a
797
+ // CP/M-style $0000 runtime with no MSX cartridge header; if it links, IT
798
+ // provides the $4010 entry (an SDCC gsinit stub = `nop nop nop ret`) and our
799
+ // msx_crt0.s _HEADER ("AB" + INIT pointer) gets dropped. The toolchain then
800
+ // synthesizes a header pointing INIT at $4010 = that stub, so the BIOS CALLs
801
+ // a no-op that returns immediately → "No cartridge found" (proven: real
802
+ // commercial ROMs boot in the same host; only our scaffolds failed). Routing
803
+ // msx_crt0.s through crt0 makes ITS header + init the cartridge entry.
804
+ if (has("msx_crt0.s")) { r.crt0File = "msx_crt0.s"; r.codeLoc = 0x4010; }
732
805
  } else if (platform === "sms" || platform === "gg") {
733
806
  // SMS/GG auto-inject their bundled crt0 inside buildForPlatform — so the
734
807
  // scaffold's own *_crt0.s would be a DUPLICATE. Skip it.
@@ -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 }
@@ -104,6 +128,61 @@ export function makePressDriver(host, presses) {
104
128
  // never disagree again.
105
129
  const MEMORY_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
106
130
 
131
+ // Abort-guard for input-driven watchpoint runs: sample caller-named bytes each
132
+ // frame; the FIRST one to change stops the run with {label,addr,before,after}.
133
+ // Lets a derailed driven scenario (player died, scene flipped) return immediately
134
+ // with WHY, instead of burning all maxFrames on a meaningless miss.
135
+ function makeAbortGuard(host, abortIf) {
136
+ const specs = Array.isArray(abortIf) ? abortIf : [];
137
+ const watched = specs.map((s, i) => {
138
+ const region = s.region ?? "system_ram";
139
+ const offset = s.offset ?? 0;
140
+ let before;
141
+ try { before = host.readMemory(region, offset, 1)[0]; } catch { before = null; }
142
+ const addr = "$" + (offset >>> 0).toString(16).toUpperCase();
143
+ return { region, offset, addr, label: s.label ?? `${region}${addr}`, before };
144
+ }).filter((w) => w.before != null);
145
+ return {
146
+ count: watched.length,
147
+ check() {
148
+ for (const w of watched) {
149
+ let now;
150
+ try { now = host.readMemory(w.region, w.offset, 1)[0]; } catch { continue; }
151
+ if (now !== w.before) {
152
+ return {
153
+ label: w.label, addr: w.addr,
154
+ before: "0x" + w.before.toString(16).padStart(2, "0").toUpperCase(),
155
+ after: "0x" + now.toString(16).padStart(2, "0").toUpperCase(),
156
+ };
157
+ }
158
+ }
159
+ return null;
160
+ },
161
+ };
162
+ }
163
+
164
+ // No-hit note for bpFindWriter. The full "two reasons" explainer (~100 tokens)
165
+ // is useful ONCE; as a repeated payload it's pure overhead (v0.15.0 feedback
166
+ // #2b). Emit the long form only on the first miss per MCP session, a one-liner
167
+ // after.
168
+ const _bpNoHitSeen = new Set();
169
+ function noHitNote(sessionKey) {
170
+ const short = "No per-byte CPU write to that address within maxFrames. Either the event didn't fire " +
171
+ "(raise maxFrames / drive it with pressDuring; add abortIf to stop early if the scenario derails), " +
172
+ "OR the region is rebuilt as a BLOCK (OAM/display-list/VRAM bulk-copy or DMA) so no single instruction " +
173
+ "writes it — watch the SOURCE struct the copy reads from instead.";
174
+ if (_bpNoHitSeen.has(sessionKey)) return short;
175
+ _bpNoHitSeen.add(sessionKey);
176
+ return "No per-byte CPU write to that address within maxFrames. Two common reasons: " +
177
+ "(1) the event didn't fire — increase maxFrames or drive the game with pressDuring to trigger it " +
178
+ "(and pass `abortIf` to abort early + say why if a driven run derails, e.g. the player dies). " +
179
+ "(2) this region is rebuilt as a BLOCK rather than written field-by-field — sprite/OAM shadow tables, " +
180
+ "display lists, and VRAM are typically bulk-copied (memcpy/loop) or DMA'd from a SOURCE struct elsewhere, " +
181
+ "so no single instruction writes this exact byte. In that case the address you want is the SOURCE: watch " +
182
+ "the struct the copy reads from (find it with searchValue on the live value), or for graphics trace the " +
183
+ "DMA/copy source (Genesis VRAM DMA source is in VDP regs). 'Address is wrong' is usually case (2), not a bad address.";
184
+ }
185
+
107
186
  function tryGetPC(host) {
108
187
  try {
109
188
  const platform = host.status?.platform;
@@ -420,7 +499,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
420
499
 
421
500
  // breakpoint({on:write|read|pc}) STOP-on-first. on:write precision:exact=bpFindWriter
422
501
  // (core watchpoint, true PC under IRQ), precision:sampled=bpRunUntilWrite (frame PC).
423
- async function bpFindWriter({ address, maxFrames = 600, pressDuring }) {
502
+ async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf }) {
424
503
  const host = getHost(sessionKey);
425
504
  if (!host.watchpointSupported || !host.watchpointSupported()) {
426
505
  return jsonContent({
@@ -432,26 +511,44 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
432
511
  host.setWatchpoint(address, true);
433
512
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
434
513
  const pressDriver = makePressDriver(host, presses);
514
+ // Abort-guard: sample caller-named "still valid?" bytes each frame; if any
515
+ // changes, a driven run that DERAILED (player died → title screen, scene
516
+ // flipped, …) stops immediately instead of burning all maxFrames and
517
+ // returning a meaningless found:false. (v0.15.0 feedback #2.)
518
+ const guard = makeAbortGuard(host, abortIf);
435
519
  let result = null;
520
+ let aborted = null;
436
521
  for (let i = 0; i < maxFrames; i++) {
437
522
  pressDriver.applyForFrame(i);
438
523
  host.stepFrames(1);
439
524
  const w = host.getWatchpoint();
440
525
  if (w.hits > 0) { result = { ...w, framesStepped: i + 1 }; break; }
526
+ const ab = guard.check();
527
+ if (ab) { aborted = { ...ab, framesStepped: i + 1 }; break; }
441
528
  }
442
529
  pressDriver.finish();
443
530
  host.setWatchpoint(address, false); // disarm
531
+ if (aborted) {
532
+ return jsonContent({
533
+ found: false, aborted: true, abortedBy: aborted.label,
534
+ abortAddress: aborted.addr, before: aborted.before, after: aborted.after,
535
+ framesStepped: aborted.framesStepped,
536
+ ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
537
+ note: `Run aborted early: the watched abort byte ${aborted.label} (${aborted.addr}) changed ` +
538
+ `${aborted.before}→${aborted.after} at frame ${aborted.framesStepped}, so the driven scenario left the ` +
539
+ `expected state (e.g. player died / scene changed) before the write fired. The found:false is NOT a real ` +
540
+ `miss — fix the input plan or pick a different start state, then re-run.`,
541
+ });
542
+ }
444
543
  if (!result) {
445
544
  return jsonContent({
446
545
  found: false, address: "$" + address.toString(16).toUpperCase(), framesStepped: maxFrames,
447
546
  ...(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.",
547
+ ...(abortIf && abortIf.length ? { abortIfArmed: guard.count } : {}),
548
+ // One-line hint by default; the full "two reasons" explainer is verbose
549
+ // boilerplate as a repeated payload (v0.15.0 feedback #2b)gated to the
550
+ // FIRST miss per session.
551
+ note: noHitNote(sessionKey),
455
552
  });
456
553
  }
457
554
  // When the core reports a PRG-ROM offset for the PC (fceumm/NES), it
@@ -655,6 +752,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
655
752
  port: z.number().int().min(0).max(3).default(0),
656
753
  holdFrames: z.number().int().min(1).default(2),
657
754
  })).optional().describe("Schedule input while waiting (drive the game to the state that triggers the condition)."),
755
+ abortIf: z.array(z.object({
756
+ region: z.enum(MEMORY_REGIONS).optional().describe("memory region (default system_ram)"),
757
+ offset: z.number().int().min(0).describe("byte offset within the region"),
758
+ label: z.string().optional().describe("human name for this guard byte"),
759
+ })).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
760
  },
659
761
  safeTool(async (args) => {
660
762
  switch (args.on) {
@@ -821,13 +923,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
821
923
 
822
924
  // ── Range watch + coverage trace (item 2, discovery) ────────────────────────
823
925
 
824
- async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200 }) {
926
+ async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200, fromState, fromStatePath }) {
825
927
  const host = getHost(sessionKey);
826
928
  if (!host.rangeWatchSupported || !host.rangeWatchSupported()) {
827
929
  return jsonContent({ notSupported: true, events: [],
828
930
  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
931
  }
830
932
  if (end < start) throw new Error("watch({on:'range'}): end must be >= start.");
933
+ // Optionally restore a savestate FIRST, so the trace runs from a known
934
+ // moment (the deterministic "jump to the boss fight, then see what writes
935
+ // HP" loop) instead of from wherever the live session happens to be.
936
+ const stateInfo = await maybeRestoreState(host, fromState, fromStatePath);
831
937
  // pressDuring is driven inside the frame loop; watchRange's host method owns
832
938
  // stepping, so for now apply presses up front if any (simple: hold for the run).
833
939
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
@@ -846,19 +952,21 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
846
952
  return attachObserverFrame(jsonContent({
847
953
  range: "$" + start.toString(16).toUpperCase() + "..$" + end.toString(16).toUpperCase(),
848
954
  kind, total: r.total, returned: events.length, truncated: r.truncated,
955
+ ...(stateInfo ? { restoredFrom: stateInfo } : {}),
849
956
  distinctPCs, events,
850
957
  note: "distinctPCs is the actionable summary — each is a routine that touches this range; disasm({target:'rom'}) one to identify the renderer/reader. " +
851
958
  (r.truncated ? "TRUNCATED: more events than the buffer held — narrow `start..end` or `frames` for the full set." : ""),
852
959
  }), host);
853
960
  }
854
961
 
855
- async function wLogPC({ start, end, frames = 120, pressDuring, limit = 512 }) {
962
+ async function wLogPC({ start, end, frames = 120, pressDuring, limit = 512, fromState, fromStatePath }) {
856
963
  const host = getHost(sessionKey);
857
964
  if (!host.rangeWatchSupported || !host.rangeWatchSupported()) {
858
965
  return jsonContent({ notSupported: true, pcs: [],
859
966
  note: "This core build has no coverage trace (shipped on all 14 platforms as of 0.6.0 — update the core package)." });
860
967
  }
861
968
  if (end < start) throw new Error("watch({on:'pc'}): end must be >= start.");
969
+ const stateInfo = await maybeRestoreState(host, fromState, fromStatePath);
862
970
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
863
971
  const pressDriver = makePressDriver(host, presses);
864
972
  if (presses.length) pressDriver.applyForFrame(0);
@@ -868,6 +976,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
868
976
  return attachObserverFrame(jsonContent({
869
977
  window: "$" + start.toString(16).toUpperCase() + "..$" + end.toString(16).toUpperCase(),
870
978
  distinct: r.distinct, total: r.total, returned: pcs.length, truncated: r.truncated,
979
+ ...(stateInfo ? { restoredFrom: stateInfo } : {}),
871
980
  pcs,
872
981
  note: "Each PC is code that EXECUTED in this window. disasm({target:'rom'}) them to find the routine you're hunting. " +
873
982
  (r.truncated ? "TRUNCATED — narrow the window for the full distinct set." : ""),
@@ -880,8 +989,8 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
880
989
  "• 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
990
  "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
991
  "**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" +
992
+ "• 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" +
993
+ "• 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
994
  "• 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
995
  {
887
996
  on: z.enum(["mem", "range", "pc", "dma"])
@@ -922,6 +1031,8 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
922
1031
  port: z.number().int().min(0).max(3).default(0),
923
1032
  holdFrames: z.number().int().min(1).default(2),
924
1033
  })).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')."),
1034
+ 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."),
1035
+ 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
1036
  },
926
1037
  safeTool(async (args) => {
927
1038
  switch (args.on) {
@@ -145,6 +145,48 @@ stub that does a `SYS` to the C entry point.
145
145
  For game ROMs you typically just let the user load the .prg into the
146
146
  emulator and the rest takes care of itself.
147
147
 
148
+ ## Disk images (.d64) — loading real games & distributing yours
149
+
150
+ A bare `.prg` is fine for dev iteration, but the real C64 world — the new
151
+ Commodore 64 Ultimate / C64C Ultimate FPGA hardware and the homebrew/demo
152
+ scene — ships and loads games as **`.d64` disk images** (and `.crt` carts /
153
+ `.tap` tapes). romdev handles them:
154
+
155
+ - **Load & run a disk/cart/tape:** `loadMedia({platform:'c64', path:'game.d64'})`
156
+ (also `.t64 .tap .crt .g64`). VICE attaches it to drive 8 and **autostarts**
157
+ it (equivalent to `LOAD"*",8,1 : RUN`) under warp — give it a few hundred
158
+ frames to finish the emulated 1541 load, then it's running. `mediaKind` in
159
+ status reports `disk` / `tape` / `cartridge` / `program` so you know what you
160
+ loaded.
161
+ - **Distribute YOUR game as a disk:** build your `.prg` as usual, then
162
+ `cart({op:'packDisk', prgPath:'game.prg'})` → an autostart-able `game.d64`
163
+ in the exact format the Ultimate hardware and the scene load. (`cart({op:
164
+ 'extract', path:'x.d64'})` lists a disk's files; add `name:` to pull one out.)
165
+
166
+ ## Disk SAVES (the C64 save medium)
167
+
168
+ The C64 has no battery SRAM — games save by **writing files to the floppy**. The
169
+ disk IS the save, so romdev exposes the LIVE mounted `.d64` for save/restore
170
+ (the C64 analogue of SRAM `exportSram`/`importSram`):
171
+
172
+ - **Snapshot the disk** (captures any files the game wrote): `state({op:
173
+ 'exportDisk', path:'save.d64'})`. Re-load it later with `loadMedia` (autostarts)
174
+ or push it back into a running session with `state({op:'importDisk', path})`.
175
+ - **Inject a save file** a player made elsewhere, straight into the running disk:
176
+ `state({op:'putDiskFile', path:'progress.prg', name:'PROGRESS'})` writes one PRG
177
+ file via the drive. Read it back with `exportDisk` or `cart({op:'extract'})`.
178
+
179
+ These work on the standard 35-track 1541 `.d64` (174848 bytes).
180
+
181
+ **A game's OWN in-emulator `SAVE` works too.** When a running program does a
182
+ KERNAL `SAVE` to drive 8, VICE commits it into the live disk image (true-drive
183
+ GCR write-back) — so after the game saves, `state({op:'exportDisk', path})`
184
+ captures a `.d64` that includes the new file, and you can re-load it later to
185
+ resume. (The on-disk filename is stored in PETSCII; romdev's reader decodes it.)
186
+ So the normal flow is just: run the game, let it save, `exportDisk` to persist.
187
+ `putDiskFile`/`importDisk` are for *injecting* a save from outside (a save a
188
+ player made elsewhere), not a requirement for the game's own saves.
189
+
148
190
  ## Frame heartbeat
149
191
 
150
192
  The C64 has no dedicated vblank interrupt by default. Two approaches:
@@ -166,7 +208,9 @@ When you call `build({output:'rom', platform:"c64", language:"c"})`:
166
208
  3. ld65 links + the bundled c64.cfg → `.prg` with a 2-byte load-address
167
209
  header.
168
210
 
169
- Loadable via vice_x64 (`loadMedia`).
211
+ Loadable via vice_x64 (`loadMedia`). To ship it the way the scene/hardware
212
+ loads games, wrap the `.prg` into a `.d64`: `cart({op:'packDisk', prgPath})`
213
+ (see "Disk images" above).
170
214
 
171
215
  ## Horizontal scrolling (for side-scrollers)
172
216