romdevtools 0.27.0 → 0.28.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 (144) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +309 -0
  3. package/README.md +1 -1
  4. package/examples/README.md +1 -1
  5. package/examples/atari2600/templates/platformer.asm +18 -9
  6. package/examples/atari2600/templates/racing.asm +25 -4
  7. package/examples/atari2600/templates/shmup.asm +30 -5
  8. package/examples/atari2600/templates/sports.asm +41 -9
  9. package/examples/atari7800/templates/hello_sprite.c +8 -4
  10. package/examples/atari7800/templates/platformer.c +12 -8
  11. package/examples/atari7800/templates/puzzle.c +7 -4
  12. package/examples/atari7800/templates/racing.c +5 -2
  13. package/examples/atari7800/templates/shmup.c +8 -4
  14. package/examples/atari7800/templates/sports.c +6 -3
  15. package/examples/c64/templates/platformer.c +28 -24
  16. package/examples/c64/templates/puzzle.c +77 -16
  17. package/examples/c64/templates/racing.c +9 -0
  18. package/examples/c64/templates/shmup.c +13 -1
  19. package/examples/c64/templates/sports.c +9 -4
  20. package/examples/gb/templates/platformer.c +6 -2
  21. package/examples/gb/templates/puzzle.c +279 -101
  22. package/examples/gb/templates/racing.c +13 -1
  23. package/examples/gb/templates/shmup.c +13 -1
  24. package/examples/gb/templates/sports.c +9 -3
  25. package/examples/gba/templates/platformer.c +7 -13
  26. package/examples/gba/templates/puzzle.c +93 -15
  27. package/examples/gba/templates/racing.c +13 -1
  28. package/examples/gba/templates/shmup.c +13 -1
  29. package/examples/gba/templates/sports.c +17 -5
  30. package/examples/gbc/templates/platformer.c +6 -2
  31. package/examples/gbc/templates/puzzle.c +878 -178
  32. package/examples/gbc/templates/racing.c +13 -1
  33. package/examples/gbc/templates/shmup.c +13 -1
  34. package/examples/gbc/templates/sports.c +9 -3
  35. package/examples/genesis/templates/puzzle.c +76 -15
  36. package/examples/genesis/templates/racing.c +13 -1
  37. package/examples/genesis/templates/shmup_2p.c +13 -1
  38. package/examples/gg/templates/platformer.c +4 -0
  39. package/examples/gg/templates/puzzle.c +80 -14
  40. package/examples/gg/templates/racing.c +17 -1
  41. package/examples/gg/templates/shmup.c +17 -1
  42. package/examples/gg/templates/sports.c +4 -0
  43. package/examples/lynx/templates/platformer.c +25 -6
  44. package/examples/lynx/templates/puzzle.c +77 -14
  45. package/examples/lynx/templates/shmup.c +13 -1
  46. package/examples/lynx/templates/sports.c +5 -2
  47. package/examples/msx/platformer/main.c +2 -0
  48. package/examples/msx/puzzle/main.c +78 -15
  49. package/examples/msx/racing/main.c +1 -0
  50. package/examples/msx/shmup/main.c +1 -0
  51. package/examples/msx/sports/main.c +3 -2
  52. package/examples/nes/templates/platformer.c +11 -3
  53. package/examples/nes/templates/puzzle.c +81 -21
  54. package/examples/nes/templates/racing.c +15 -1
  55. package/examples/nes/templates/shmup.c +1 -0
  56. package/examples/nes/templates/sports.c +1 -0
  57. package/examples/pce/platformer/main.c +3 -1
  58. package/examples/pce/puzzle/main.c +78 -12
  59. package/examples/pce/racing/main.c +1 -0
  60. package/examples/pce/shmup/main.c +5 -4
  61. package/examples/pce/sports/main.c +4 -3
  62. package/examples/sms/templates/platformer.c +4 -0
  63. package/examples/sms/templates/puzzle.c +80 -14
  64. package/examples/sms/templates/racing.c +17 -1
  65. package/examples/sms/templates/shmup.c +17 -1
  66. package/examples/sms/templates/shmup_2p.c +17 -1
  67. package/examples/sms/templates/sports.c +4 -0
  68. package/examples/snes/templates/platformer.c +32 -15
  69. package/examples/snes/templates/puzzle.c +84 -16
  70. package/examples/snes/templates/racing.c +20 -1
  71. package/examples/snes/templates/shmup.c +20 -2
  72. package/examples/snes/templates/sports.c +7 -0
  73. package/package.json +12 -12
  74. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  75. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  76. package/src/cores/wasm/fceumm_libretro.js +1 -1
  77. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  78. package/src/cores/wasm/gambatte_libretro.js +1 -1
  79. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  80. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  81. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  82. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  83. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  84. package/src/cores/wasm/handy_libretro.js +1 -1
  85. package/src/cores/wasm/handy_libretro.wasm +0 -0
  86. package/src/cores/wasm/mgba_libretro.js +1 -1
  87. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  88. package/src/cores/wasm/prosystem_libretro.js +1 -1
  89. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  90. package/src/cores/wasm/snes9x_libretro.js +1 -1
  91. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  92. package/src/cores/wasm/stella2014_libretro.js +1 -1
  93. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  94. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  95. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  96. package/src/host/LibretroHost.js +245 -10
  97. package/src/mcp/server.js +6 -0
  98. package/src/mcp/tools/disasm-rebuild.js +315 -65
  99. package/src/mcp/tools/disasm.js +149 -28
  100. package/src/mcp/tools/find-references.js +216 -51
  101. package/src/mcp/tools/frame.js +11 -4
  102. package/src/mcp/tools/index.js +15 -1
  103. package/src/mcp/tools/input.js +26 -3
  104. package/src/mcp/tools/memory.js +208 -39
  105. package/src/mcp/tools/playtest.js +56 -4
  106. package/src/mcp/tools/project.js +35 -9
  107. package/src/mcp/tools/toolchain.js +43 -10
  108. package/src/mcp/tools/watch-memory.js +141 -24
  109. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  110. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  111. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  112. package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
  113. package/src/platforms/gb/MENTAL_MODEL.md +16 -1
  114. package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
  115. package/src/platforms/gb/lib/c/patch-header.js +7 -4
  116. package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
  117. package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
  118. package/src/platforms/gbc/lib/c/font.h +43 -0
  119. package/src/platforms/gbc/lib/c/patch-header.js +7 -4
  120. package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
  121. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  122. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  123. package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
  124. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  125. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  126. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  127. package/src/platforms/msx/MENTAL_MODEL.md +6 -0
  128. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  129. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  130. package/src/platforms/msx/lib/c/msx_hw.h +2 -0
  131. package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
  132. package/src/platforms/nes/MENTAL_MODEL.md +10 -3
  133. package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
  134. package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
  135. package/src/platforms/pce/MENTAL_MODEL.md +9 -0
  136. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  137. package/src/platforms/pce/lib/c/pce_hw.h +2 -1
  138. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  139. package/src/platforms/sms/MENTAL_MODEL.md +5 -0
  140. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  141. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  142. package/src/platforms/snes/MENTAL_MODEL.md +5 -0
  143. package/src/playtest/playtest.js +73 -3
  144. package/src/toolchains/index.js +37 -8
@@ -92,7 +92,7 @@ const TEMPLATES = {
92
92
  linkerConfig: { presetSrc: "presets/nes/chr-ram-runtime.cfg", dst: "chr-ram-runtime.cfg" },
93
93
  lang: "C (cc65)",
94
94
  ext: ".nes",
95
- describe: "Match-3 falling-block puzzle. 6×12 grid, 1×3 active piece (3 colors), rotate via A, soft-drop on DOWN, horizontal-triple clear.",
95
+ describe: "Match-3 falling-block puzzle. 6×12 grid, 1×3 active piece (3 colors), rotate via A, soft-drop on DOWN, 3+-in-a-row clears in all 4 directions (H/V/diagonals) with gravity + cascade chains.",
96
96
  },
97
97
  sports: {
98
98
  main: "templates/sports.c",
@@ -214,7 +214,7 @@ const TEMPLATES = {
214
214
  ],
215
215
  lang: "C (SDCC sm83)",
216
216
  ext: ".gb",
217
- describe: "Match-3 falling-block puzzle scaffold for GB. 6×12 grid rendered via BG tilemap, 1×3 active piece (3 colours via 3 BG tile shapes), rotate via A, hard-drop on START, horizontal-triple clear.",
217
+ describe: "Match-3 falling-block puzzle scaffold for GB. 6×12 grid rendered via BG tilemap, 1×3 active piece (3 colours via 3 BG tile shapes), rotate via A, hard-drop on START, 3+-in-a-row clears in all 4 directions (H/V/diagonals) with gravity + cascade chains.",
218
218
  },
219
219
  sports: {
220
220
  main: "templates/sports.c",
@@ -284,7 +284,7 @@ const TEMPLATES = {
284
284
  catch_game: mk("catch_game", "A complete tiny game: a paddle catches a falling object with the d-pad; full game loop with waitvsync(), two sprites, collision, scoring."),
285
285
  shmup: mk("shmup", "Vertical shoot-'em-up for PC Engine. Player ship + bullet/enemy object pools, a wave spawner, AABB collisions, score HUD, scrolling-band starfield BG. d-pad flies, button I fires. The base for any action shooter."),
286
286
  platformer: mk("platformer", "Side-scrolling platformer for PC Engine. Gravity + jump + land-on-top platform collision, a multi-screen world streamed via BG X-scroll (BXR), solid platform tiles, sub-pixel physics. d-pad moves, button I jumps."),
287
- puzzle: mk("puzzle", "Match-3 / falling-block puzzle for PC Engine. A 6x12 well drawn with BG tiles, a 1x3 active piece you move/rotate/soft-drop/hard-drop, horizontal-triple clears, score. d-pad moves, I rotates, II hard-drops."),
287
+ puzzle: mk("puzzle", "Match-3 / falling-block puzzle for PC Engine. A 6x12 well drawn with BG tiles, a 1x3 active piece you move/rotate/soft-drop/hard-drop, 3+-in-a-row clears (H+V) with gravity + cascade chains, score. d-pad moves, I rotates, II hard-drops."),
288
288
  sports: mk("sports", "Pong-style sports game for PC Engine. Two paddles + a bouncing ball on a netted court, score to 9, paddle-deflect physics; player 2 falls back to chase-AI when no input. d-pad moves P1."),
289
289
  racing: mk("racing", "Top-down lane racer for PC Engine. Player car at the bottom, obstacle cars spawn from the top and slide down, LEFT/RIGHT switches lanes, speed grows with score, crash freeze + auto-reset. Scrolling road BG."),
290
290
  };
@@ -305,7 +305,7 @@ const TEMPLATES = {
305
305
  catch_game: mk("catch_game", "A complete tiny game: a paddle catches falling fruit with the joystick; full game loop with vblank sync, two sprites, collision, scoring."),
306
306
  shmup: mk("shmup", "Vertical-shmup scaffold for MSX (screen 2). Player ship (sprite plane 0) + 4 bullet + 4 enemy object pools, a wave spawner, AABB collision, on-screen SCORE tiles, over a banded starfield filling the whole 32x24 name table. Joystick PORT 1 moves the ship (UP/DOWN/LEFT/RIGHT), trigger A (GTTRIG) fires; PSG blip on fire, noise-ish tone on a kill. Interrupt-free vsync via VDP status S#0. Extend with enemy fire, lives, scrolling stars."),
307
307
  platformer: mk("platformer", "Side-scrolling platformer for MSX (screen 2). Subpixel gravity/jump/land-on-top collision against a table of platforms across a 512-px (64-cell) world, drawn by COLUMN STREAMING into the wrapping screen-2 name table as the camera follows the player; the player sprite draws in screen space. Joystick LEFT/RIGHT walks, trigger A jumps (only when grounded); PSG jump blip. Interrupt-free vsync. Extend with enemies, pickups, goal."),
308
- puzzle: mk("puzzle", "Match-3 / falling-block puzzle for MSX (screen 2). A 6-wide x 12-tall well drawn with the BG tilemap (distinct R/G/B cell tiles + grey border + dim field interior so the playfield is always visible). A 1x3 active piece: joystick LEFT/RIGHT shifts, trigger A rotates the colour order, DOWN soft-drops, trigger B hard-drops; horizontal-triple clears score with a PSG chime. Interrupt-free vsync. Extend with vertical/diagonal matches, gravity-collapse, levels."),
308
+ puzzle: mk("puzzle", "Match-3 / falling-block puzzle for MSX (screen 2). A 6-wide x 12-tall well drawn with the BG tilemap (distinct R/G/B cell tiles + grey border + dim field interior so the playfield is always visible). A 1x3 active piece: joystick LEFT/RIGHT shifts, trigger A rotates the colour order, DOWN soft-drops, trigger B hard-drops; 3+-in-a-row clears in all 4 directions with gravity + cascade chains; PSG chime per clear. Interrupt-free vsync. Extend with levels/next-piece preview."),
309
309
  sports: mk("sports", "Pong-style 2-player sports for MSX (screen 2). Court (green field + white sidelines + dashed centre net) fills the 32x24 name table; two paddles (stacked sprites) + a ball. Player 1 = joystick PORT 1 UP/DOWN; Player 2 = joystick PORT 2 UP/DOWN, falling back to chase-the-ball AI when no second pad is present so it is playable solo. Wall/paddle bounces + scoring with PSG bonks. Interrupt-free vsync. Extend with serve angles, score display, win condition."),
310
310
  racing: mk("racing", "Top-down 3-lane racing for MSX (screen 2). Grey road + green-grass shoulders fill the name table; player car at the bottom, obstacle cars (object pool) spawn at the top and slide down. Joystick LEFT/RIGHT (edge-detected) switches lanes; obstacle speed grows with score; an AABB crash triggers a ~60-frame freeze then auto-reset, with a PSG crash tone. SCORE drawn as tiles. Interrupt-free vsync. Extend with pseudo-3D road, fuel, multiple cars."),
311
311
  };
@@ -351,9 +351,19 @@ TEMPLATES.gbc = {
351
351
  describe: "SIDE-SCROLLING platformer for GBC. Full CGB color palette (BG + sprite via BCPS/OCPS) over the GB side-scroller core: subpixel gravity + jump + land-on-top collision against platforms across a 256-px world (the wrapping BG map). The camera follows the player and scrolls the BG via SCX; the player sprite draws in screen space. A=jump, d-pad=move. One BG map wide (no streaming) — for a wider world, stream a new BG-map column on each 8px camera step (window for a fixed HUD). See the GBC MENTAL_MODEL.md 'Horizontal scrolling'. Extend with enemies, goals, pickups.",
352
352
  },
353
353
  puzzle: {
354
- main: "templates/puzzle.c", runtime: GBC_RUNTIME,
354
+ main: "templates/puzzle.c",
355
+ runtime: [
356
+ ...GBC_RUNTIME,
357
+ { src: "lib/c/font.h", dst: "font.h" }, /* digits+A-Z 2bpp glyphs for the HUD */
358
+ ],
355
359
  lang: GBC_LANG, ext: ".gbc",
356
- describe: "Match-3 puzzle for GBC. Three colored cells (BG palette via BCPS/BCPD), rotate + soft-drop + hard-drop + triple-clear chime.",
360
+ describe: "Falling-jewel matcher for GBC (the polished reference puzzle). 8x17 well, 6 jewel colors with " +
361
+ "real CGB palettes, matches in all 4 directions (H/V/both diagonals), gravity + cascade chains, magic " +
362
+ "jewel every 18th piece, level speedup, 6-digit score, title + game-over screens, SFX + toggleable " +
363
+ "music. Rendering: falling column + NEXT preview are OAM sprites; the locked well is BG tiles via a " +
364
+ "COLLECT/FLUSH vblank queue with an idle scrub (writes outside vblank silently drop on this core — " +
365
+ "never bypass the queue). Statics need dataLoc 0xC200 (above shadow_oam at $C100) — the project build " +
366
+ "recipe sets that automatically.",
357
367
  },
358
368
  sports: {
359
369
  main: "templates/sports.c", runtime: GBC_RUNTIME,
@@ -398,6 +408,11 @@ TEMPLATES.gbc = {
398
408
  // against. Factored to a constant so adding a new template is a one-line
399
409
  // change at the bottom.
400
410
  const SMS_RUNTIME = [
411
+ // The crt0 ships IN the project (like GG/MSX) so the dir is genuinely
412
+ // self-contained: build({output:'project'}) routes it via the crt0 channel
413
+ // (projectBuildRecipe), and an external stock-SDCC rebuild has the real
414
+ // boot stub on disk instead of silently linking SDCC's non-booting one.
415
+ { src: "lib/c/sms_crt0.s", dst: "sms_crt0.s" },
401
416
  { src: "lib/c/sms_hw.h", dst: "sms_hw.h" },
402
417
  { src: "lib/c/vdp_init.c", dst: "vdp_init.c" },
403
418
  { src: "lib/c/load_palette.c", dst: "load_palette.c" },
@@ -764,7 +779,7 @@ TEMPLATES.snes = {
764
779
  runtimeDirs: SNES_PVSNESLIB_VENDOR_DIRS,
765
780
  lang: "C (tcc-65816 + PVSnesLib)",
766
781
  ext: ".sfc",
767
- describe: "Match-3 falling-block puzzle for SNES. 6×12 grid (text mode), rotate/soft-drop/hard-drop, horizontal-triple clear. Rotate click + clear chime via bundled SPC700 sfx.",
782
+ describe: "Match-3 falling-block puzzle for SNES. 6×12 grid (text mode), rotate/soft-drop/hard-drop, 3+-in-a-row clears in all 4 directions with gravity + cascade chains. Rotate click + clear chime via bundled SPC700 sfx.",
768
783
  },
769
784
  sports: {
770
785
  main: "templates/sports.c",
@@ -939,7 +954,7 @@ TEMPLATES.genesis = {
939
954
  runtimeDirs: SGDK_RUNTIME_DIRS,
940
955
  lang: SGDK_LANG,
941
956
  ext: ".bin",
942
- describe: "Match-3 falling-block puzzle genre scaffold. 6×12 grid, 1×3 active piece (3 colours), rotate via A, soft-drop on DOWN, hard-drop on START, horizontal-triple clear. xorshift RNG so cell colours actually vary.",
957
+ describe: "Match-3 falling-block puzzle genre scaffold. 6×12 grid, 1×3 active piece (3 colours), rotate via A, soft-drop on DOWN, hard-drop on START, 3+-in-a-row clears in all 4 directions with gravity + cascade chains. xorshift RNG so cell colours actually vary.",
943
958
  },
944
959
  sports: {
945
960
  main: "templates/sports.c",
@@ -1757,7 +1772,18 @@ Compiles **C89**, not C99/C11. Stick to:
1757
1772
  let filesSection = `## Files\n\n- \`${mainFilename}\` — your game's entry point.\n`;
1758
1773
  if (tmpl?.runtime) {
1759
1774
  for (const { dst } of tmpl.runtime) {
1760
- filesSection += `- \`${dst}\` runtime helper. **You own this** — edit or replace at will.\n`;
1775
+ if (dst === "patch-header.js") {
1776
+ // NOT game code — calling it a "runtime helper" implied it compiles
1777
+ // into the ROM and confused readers. It's a standalone sidecar tool.
1778
+ filesSection += `- \`${dst}\` — sidecar TOOL, not game code (never compiled into the ROM). ` +
1779
+ `Stamps the Nintendo logo + header/global checksums a GB ROM needs to boot ` +
1780
+ `(\`node patch-header.js game.gb\`) — a zero-install stand-in for RGBDS's rgbfix when you ` +
1781
+ `rebuild OUTSIDE romdev with stock SDCC. romdev's own builds fix the header automatically.\n`;
1782
+ } else if (dst.endsWith("_crt0.s")) {
1783
+ filesSection += `- \`${dst}\` — startup assembly (reset/interrupt vectors, RAM init; routed as the crt0 by the project build). **You own this.**\n`;
1784
+ } else {
1785
+ filesSection += `- \`${dst}\` — runtime helper. **You own this** — edit or replace at will.\n`;
1786
+ }
1761
1787
  }
1762
1788
  }
1763
1789
  if (tmpl?.crt0) {
@@ -266,7 +266,7 @@ export function installToolchainCore({ id }) {
266
266
  }
267
267
 
268
268
  export function registerToolchainTools(server, z, sessionKey) {
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 }) {
269
+ async function buildSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, crt0, crt0Path, codeLoc, dataLoc, options, linkerConfig, linkerConfigPath, inesHeader, outputPath, inline = false, includeSymbols = false, lint = "advisory", runtime, maxmod, rebuildSdk }) {
270
270
  // Reject conflicting inline vs path args — fail loud, not silent.
271
271
  if (source != null && sourcePath != null) {
272
272
  throw new Error("build({output:'rom'}): pass either `source` OR `sourcePath`, not both.");
@@ -281,6 +281,12 @@ export function registerToolchainTools(server, z, sessionKey) {
281
281
  if (crt0Path) {
282
282
  crt0 = await readFile(crt0Path, "utf-8");
283
283
  }
284
+ // linkerConfigPath: read the .cfg from disk so a large multi-bank
285
+ // config (e.g. a disasm'd mapper-2 rebuild) isn't re-streamed through
286
+ // context on every build (0.27.0 feedback #2).
287
+ if (linkerConfigPath && linkerConfig == null) {
288
+ linkerConfig = await readFile(linkerConfigPath, "utf-8");
289
+ }
284
290
  // Auto-inject the bundled crt0 for SMS/GG when caller didn't pass
285
291
  // one. Stock SDCC crt0 doesn't boot these targets; without this,
286
292
  // user main() is never called → black screen. See AUTO_CRT0_PLATFORMS.
@@ -464,7 +470,7 @@ export function registerToolchainTools(server, z, sessionKey) {
464
470
  return jsonContent(payload);
465
471
  }
466
472
 
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 }) {
473
+ async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, linkerConfigPath, inesHeader, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
468
474
  const { buildForPlatform } = await import("../../toolchains/index.js");
469
475
  const resolved = resolveCore(platform);
470
476
  if (!resolved) throw new Error(`no core available for platform '${platform}'`);
@@ -480,6 +486,7 @@ export function registerToolchainTools(server, z, sessionKey) {
480
486
  binaryIncludes = { ...(binaryIncludes ?? {}), ...r.binaryIncludes };
481
487
  if (r.crt0 != null) crt0 = r.crt0;
482
488
  if (r.codeLoc != null) codeLoc = r.codeLoc;
489
+ if (r.dataLoc != null && dataLoc == null) dataLoc = r.dataLoc;
483
490
  if (r.linkerConfig != null && linkerConfig == null) linkerConfig = r.linkerConfig;
484
491
  if (r.runtime != null && runtime == null) runtime = r.runtime;
485
492
  if (r.maxmod != null && maxmod == null) maxmod = r.maxmod;
@@ -510,6 +517,9 @@ export function registerToolchainTools(server, z, sessionKey) {
510
517
  if (crt0Path) {
511
518
  crt0 = await readFile(crt0Path, "utf-8");
512
519
  }
520
+ if (linkerConfigPath && linkerConfig == null) {
521
+ linkerConfig = await readFile(linkerConfigPath, "utf-8");
522
+ }
513
523
  // Auto-inject bundled crt0 for SMS/GG when caller didn't pass one
514
524
  // (stock SDCC crt0 doesn't boot these targets — see buildSource).
515
525
  if (crt0 == null) {
@@ -714,6 +724,7 @@ export function registerToolchainTools(server, z, sessionKey) {
714
724
  dataLoc: z.coerce.number().int().optional().describe("SDCC — _DATA (WRAM) load address (default $C000 on Z80). NOT read by output:'romWithDebug'."),
715
725
  options: z.array(z.string()).optional().describe("output:'rom' — extra toolchain CLI options."),
716
726
  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.**"),
727
+ linkerConfigPath: z.string().optional().describe("Path-based `linkerConfig`: absolute path to a .cfg file on disk (the server reads it — the cfg never enters your context; e.g. the multi-bank cfg a banked-NES disasm project ships). Ignored when `linkerConfig` is passed inline."),
717
728
  inesHeader: z.object({
718
729
  prgBanks: z.coerce.number().int().min(1).max(255).describe("16KB PRG-ROM banks (1 = NROM-128, 2 = NROM-256)."),
719
730
  chrBanks: z.coerce.number().int().min(0).max(255).optional().describe("8KB CHR-ROM banks (0 = CHR-RAM, no CHARS segment). Default 0."),
@@ -785,8 +796,8 @@ export function registerToolchainTools(server, z, sessionKey) {
785
796
  */
786
797
  export function projectBuildRecipe(platform, names) {
787
798
  const has = (n) => names.includes(n);
788
- /** @type {{crt0File:string|null, codeLoc:number|undefined, linkerConfig:string|undefined, runtime:string|undefined, maxmod:boolean|undefined, skip:Set<string>, includeAsC:Set<string>}} */
789
- const r = { crt0File: null, codeLoc: undefined, linkerConfig: undefined, runtime: undefined, maxmod: undefined, skip: new Set(), includeAsC: new Set() };
799
+ /** @type {{crt0File:string|null, codeLoc:number|undefined, dataLoc:number|undefined, linkerConfig:string|undefined, runtime:string|undefined, maxmod:boolean|undefined, skip:Set<string>, includeAsC:Set<string>}} */
800
+ const r = { crt0File: null, codeLoc: undefined, dataLoc: undefined, linkerConfig: undefined, runtime: undefined, maxmod: undefined, skip: new Set(), includeAsC: new Set() };
790
801
 
791
802
  // Reference/upstream sources ship for grepping, not compiling (e.g. GB
792
803
  // music_demo's hUGEDriver.upstream.asm — the .c port is what builds). Skip
@@ -796,7 +807,11 @@ export function projectBuildRecipe(platform, names) {
796
807
  if (platform === "gb" || platform === "gbc") {
797
808
  // GB/GBC ship gb_crt0.s — it MUST go via crt0+codeLoc:0x150, never as a
798
809
  // source (SDCC emits its own gsinit → "Multiple definition of gsinit").
799
- if (has("gb_crt0.s")) { r.crt0File = "gb_crt0.s"; r.codeLoc = 0x150; }
810
+ // dataLoc 0xC200: statics start ABOVE shadow_oam ($C100-$C19F, fixed by
811
+ // the runtime). The sdld default of $C000 let any project with >256 bytes
812
+ // of statics silently overlap the OAM shadow — oam_clear() then zeroed
813
+ // game state (grid/RNG seed). 512 bytes of 8KB WRAM is cheap insurance.
814
+ if (has("gb_crt0.s")) { r.crt0File = "gb_crt0.s"; r.codeLoc = 0x150; r.dataLoc = 0xC200; }
800
815
  } else if (platform === "nes") {
801
816
  // A SCAFFOLDED NES project ships nes_runtime.c + a crt0 + a .cfg and needs
802
817
  // the chr-ram-runtime preset (it defines the OAM/CHARS segments + a NMI with
@@ -824,9 +839,17 @@ export function projectBuildRecipe(platform, names) {
824
839
  // msx_crt0.s through crt0 makes ITS header + init the cartridge entry.
825
840
  if (has("msx_crt0.s")) { r.crt0File = "msx_crt0.s"; r.codeLoc = 0x4010; }
826
841
  } else if (platform === "sms" || platform === "gg") {
827
- // SMS/GG auto-inject their bundled crt0 inside buildForPlatform so the
828
- // scaffold's own *_crt0.s would be a DUPLICATE. Skip it.
829
- for (const n of names) if (/_crt0\.s$/i.test(n)) r.skip.add(n);
842
+ // SMS/GG: route the project's *_crt0.s through the crt0 channel (like
843
+ // GB/MSX), NOT as a plain source TU. The OLD recipe skipped it on the
844
+ // belief that "buildForPlatform auto-injects the bundled crt0" — IT DOES
845
+ // NOT (only the output:'rom'/'run' MCP handlers auto-inject). So every
846
+ // output:'project' SMS/GG build linked SDCC's STOCK z80 crt0, whose boot
847
+ // is `ld a,#2 / rst $08 / halt` — main() never ran and every scaffold
848
+ // booted to a BLACK SCREEN (the RetroDECK "all broken" report; our
849
+ // output:'run' verifications were false-green via the other path).
850
+ // readProjectDir falls back to the bundled crt0 when the dir has none.
851
+ const crt0Name = names.find((n) => /_crt0\.s$/i.test(n));
852
+ if (crt0Name) r.crt0File = crt0Name;
830
853
  } else if (platform === "genesis" || platform === "megadrive" || platform === "md") {
831
854
  // SGDK supplies sega startup + rom header. The scaffold dir may contain
832
855
  // generated intermediates (sega.s, sega.preprocessed.s, rom_header.*, and an
@@ -919,6 +942,15 @@ export async function readProjectDir(projPath, platform) {
919
942
  }
920
943
  }
921
944
 
945
+ // SMS/GG with no crt0 file in the dir → fall back to the bundled crt0,
946
+ // exactly like the output:'rom'/'run' handlers do. Without this the link
947
+ // silently uses SDCC's stock z80 crt0, which never calls main() (black
948
+ // screen at boot). The SMS scaffold historically shipped without a crt0
949
+ // file, so this fallback is load-bearing for existing project dirs.
950
+ if (crt0 == null && (platform === "sms" || platform === "gg")) {
951
+ crt0 = await resolveAutoCrt0(platform);
952
+ }
953
+
922
954
  // GBA runtime refinement: libgba if the entry includes <gba.h>, else the
923
955
  // libtonc default the recipe set.
924
956
  let runtime = recipe.runtime;
@@ -926,11 +958,11 @@ export async function readProjectDir(projPath, platform) {
926
958
  runtime = "libgba";
927
959
  }
928
960
 
929
- return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
961
+ return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, dataLoc: recipe.dataLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
930
962
  }
931
963
 
932
964
  export async function buildProjectCore({ path: projPath, platform, outputPath }) {
933
- const { sources, includes, binaryIncludes, crt0, codeLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
965
+ const { sources, includes, binaryIncludes, crt0, codeLoc, dataLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
934
966
 
935
967
  // Linker preset: the recipe names it (e.g. NES 'chr-ram-runtime', which ships
936
968
  // the OAM/CHARS segments + its own crt0). resolveLinkerConfig also returns any
@@ -968,6 +1000,7 @@ export async function buildProjectCore({ path: projPath, platform, outputPath })
968
1000
  linkerConfig: resolvedLinkerConfig,
969
1001
  crt0: crt0Rel,
970
1002
  codeLoc,
1003
+ dataLoc,
971
1004
  });
972
1005
  if (outputPath && result.binary) {
973
1006
  await mkdir(path.dirname(outputPath), { recursive: true });
@@ -189,7 +189,7 @@ function noHitNote(sessionKey) {
189
189
  "(2) this region is rebuilt as a BLOCK rather than written field-by-field — sprite/OAM shadow tables, " +
190
190
  "display lists, and VRAM are typically bulk-copied (memcpy/loop) or DMA'd from a SOURCE struct elsewhere, " +
191
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 " +
192
+ "the struct the copy reads from (find it with memory({op:'search'}) on the live value), or for graphics trace the " +
193
193
  "DMA/copy source (Genesis VRAM DMA source is in VDP regs). 'Address is wrong' is usually case (2), not a bad address.";
194
194
  }
195
195
 
@@ -452,7 +452,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
452
452
  stoppedEarly,
453
453
  truncated,
454
454
  note: totalMatched === 0
455
- ? "No matching changes in the watched window. Try (a) onChange:'any' to confirm the byte moves at all, (b) longer `frames`, (c) `pressDuring` to drive the game past the event, (d) a different region/offset. If the byte never moves even with onChange:'any', this region may be REBUILT as a block (sprite/OAM shadow, display list, VRAM) rather than written in place — watch the SOURCE struct the copy/DMA reads from instead (find it with searchValue)."
455
+ ? "No matching changes in the watched window. Try (a) onChange:'any' to confirm the byte moves at all, (b) longer `frames`, (c) `pressDuring` to drive the game past the event, (d) a different region/offset. If the byte never moves even with onChange:'any', this region may be REBUILT as a block (sprite/OAM shadow, display list, VRAM) rather than written in place — watch the SOURCE struct the copy/DMA reads from instead (find it with memory({op:'search'}))."
456
456
  : (tryGetPC(host) == null ? "PC not available for this platform (getCPUState returned no pc field)." : undefined),
457
457
  };
458
458
 
@@ -569,17 +569,29 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
569
569
  const bankInfo = (prgOffset != null)
570
570
  ? { prgOffset: "0x" + prgOffset.toString(16).toUpperCase(), bank: Math.floor(prgOffset / 0x4000) }
571
571
  : null;
572
+ // The core snapshots the FULL register file inside the write hook (kind 3,
573
+ // all 14 platforms) — the break-instant truth; the live regs keep moving
574
+ // after the hit.
575
+ const wpSnap = host.getRegSnapshot ? host.getRegSnapshot(true) : null;
576
+ const wpRegs = (wpSnap && wpSnap.kind === 3) ? wpSnap.named : null;
572
577
  return attachObserverFrame(jsonContent({
573
578
  found: true,
574
579
  address: "$" + address.toString(16).toUpperCase(),
575
580
  pc: result.lastPC != null ? "$" + result.lastPC.toString(16).toUpperCase() : null,
576
581
  pcRaw: result.lastPC,
577
- value: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
582
+ // valueByte, not value: this is the ONE BYTE that landed on the watched
583
+ // address — a word/long store shows only its byte here, not the operand
584
+ // (a real session read 0x00 as "the move.l wrote zero").
585
+ valueByte: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
578
586
  hits: result.hits,
579
587
  framesStepped: result.framesStepped,
588
+ ...(wpRegs ? { registersAtHit: wpRegs } : {}),
580
589
  ...(bankInfo ? bankInfo : {}),
581
590
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
582
591
  note: "pc is the EXACT writing instruction (captured in the CPU write path), not a frame sample. " +
592
+ "valueByte is the single byte written to the watched address (a 16/32-bit store shows only its byte here). " +
593
+ "hits counts watched-byte writes during the hit frame — the same instruction looping twice in one frame is hits:2, one event. " +
594
+ (wpRegs ? "registersAtHit is the register file frozen AT the write (the live regs drift for the rest of the frame — don't cpu({op:'read'}) instead). " : "") +
583
595
  (bankInfo
584
596
  ? `pc is in PRG bank ${bankInfo.bank} (prg offset ${bankInfo.prgOffset}) — disassembleRom({ startAddress: ${result.lastPC != null ? "0x" + result.lastPC.toString(16) : "pc"}, bank: ${bankInfo.bank} }) targets the exact bank (no fixed-bank $FF padding).`
585
597
  : `disassembleRom({ startAddress: ${result.lastPC != null ? "0x" + result.lastPC.toString(16) : "pc"} }) to see it. On a banked mapper a $8000-$BFFF pc may be in a switchable bank — pass the right \`bank\`.`),
@@ -659,16 +671,36 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
659
671
  host.setPCBreak(0, false, false); // disarm
660
672
  }
661
673
  if (!hit) {
674
+ // Diagnostics on a miss (0.27.0 feedback #8): a bare "drive it with
675
+ // pressDuring" is useless when the caller DID supply input. Report
676
+ // where the CPU actually is, and tailor the advice.
677
+ const pcNow = tryGetPC(host);
678
+ const drove = presses.length > 0;
662
679
  return attachObserverFrame(jsonContent({
663
680
  hit: false, address: "$" + address.toString(16).toUpperCase(), framesRun,
664
681
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
665
- note: "PC never reached that address within maxFrames. Either the code path didn't execute (drive it with pressDuring " +
666
- "to reach the right game state), or the address isn't an instruction boundary (a mid-instruction address never matches REG_PC).",
682
+ ...(pcNow != null ? { pcNow: "$" + pcNow.toString(16).toUpperCase() } : {}),
683
+ note: (drove
684
+ ? "PC never reached that address within maxFrames EVEN WITH the scheduled input — so this is " +
685
+ "likely the WRONG ADDRESS for the path that actually ran (a different routine handles it), " +
686
+ "or the address isn't an instruction boundary (mid-instruction never matches REG_PC). "
687
+ : "PC never reached that address within maxFrames. Either the code path didn't execute (drive " +
688
+ "it with pressDuring to reach the right game state), or the address isn't an instruction " +
689
+ "boundary (mid-instruction never matches REG_PC). ") +
690
+ (pcNow != null ? "pcNow is the frame-boundary PC (usually the idle loop). " : "") +
691
+ "To find which code DID run, coverage-trace the suspect range: watch({on:'pc', start, end, frames}) " +
692
+ "returns every distinct PC executed there; or anchor on a RAM effect with breakpoint({on:'write'}).",
667
693
  }), host);
668
694
  }
669
695
  // Snapshot the registers AT the hit BEFORE clearing (last already holds the
670
- // hit state; read it without clearing so registersAtHit survives).
671
- const atHit = last.registersAtHit ?? host.getPCBreak(false).registersAtHit ?? null;
696
+ // hit state; read it without clearing so registersAtHit survives). Two
697
+ // snapshot transports: the fceumm-style inline pcbreak slots (A/X/Y/P/S)
698
+ // and the gpgx regsnap export (full m68k/z80 file — kind 1=pc-break,
699
+ // 2=watchdog).
700
+ const snapAtHit = host.getRegSnapshot ? host.getRegSnapshot(false) : null;
701
+ const atHit = last.registersAtHit
702
+ ?? host.getPCBreak(false).registersAtHit
703
+ ?? ((snapAtHit && (snapAtHit.kind === 1 || snapAtHit.kind === 2)) ? snapAtHit.named : null);
672
704
  // captureMemory: read the requested regions AT the hit (before we clear/step),
673
705
  // returned inline so break→read RAM collapses into ONE call. NOTE: registers
674
706
  // are the true break instant (core snapshot); these RAM reads are taken now —
@@ -700,8 +732,9 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
700
732
  // end-of-frame state. Prefer registersAtHit; only fall back to a live read on
701
733
  // cores that don't snapshot.
702
734
  const frozenNote = atHit
703
- ? "registersAtHit holds the register file CAPTURED AT this instruction (A/X/Y/P/S) — use THESE, not a follow-up cpu({op:'read'}), which on NES/fceumm returns end-of-frame state, not the break instant. For RAM at the hit, pass captureMemory:[{region,offset,length}] to get it inline (capturedMemory) in THIS call instead of a follow-up read. frame({op:'stepInstruction'}) to single-step from here."
735
+ ? "registersAtHit holds the register file CAPTURED AT the break instant — use THESE, not a follow-up cpu({op:'read'}), which returns end-of-frame state (the CPU/frame machinery keeps running after the hit). For RAM at the hit, pass captureMemory:[{region,offset,length}] to get it inline (capturedMemory) in THIS call instead of a follow-up read. frame({op:'stepInstruction'}) to single-step from here."
704
736
  : "This core does not snapshot registers at the hit. cpu({op:'read'}) reflects the CPU state now; on cores that run-to-frame-end (fceumm) that is NOT the break instant — prefer the RAM side effects (memory({op:'read'})) over the live register file.";
737
+ if (host.getRegSnapshot) host.getRegSnapshot(true); // consume the snapshot so a later bp can't read a stale one
705
738
  return attachObserverFrame(jsonContent({
706
739
  hit: true,
707
740
  address: "$" + address.toString(16).toUpperCase(),
@@ -711,7 +744,9 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
711
744
  ...(capturedMemory ? { capturedMemory } : {}),
712
745
  frame: host.status.frameCount,
713
746
  framesRun,
714
- hits: fin.hits,
747
+ // The core's hits counter doesn't tick on a watchdog stop — normalize so
748
+ // hit:true never reports hits:0 (a real session read that as contradictory).
749
+ hits: fin.hits || 1,
715
750
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
716
751
  note: frozenNote,
717
752
  }), host);
@@ -749,17 +784,21 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
749
784
  });
750
785
  }
751
786
  const fin = host.getReadWatch(true);
787
+ const rdSnap = host.getRegSnapshot ? host.getRegSnapshot(true) : null;
788
+ const rdRegs = (rdSnap && rdSnap.kind === 4) ? rdSnap.named : null;
752
789
  return attachObserverFrame(jsonContent({
753
790
  hit: true,
754
791
  address: "$" + address.toString(16).toUpperCase(),
755
792
  pc: last.lastPC != null ? "$" + last.lastPC.toString(16).toUpperCase() : null,
756
793
  pcRaw: last.lastPC,
757
- value: "0x" + (last.lastValue & 0xFF).toString(16).toUpperCase().padStart(2, "0"),
794
+ valueByte: "0x" + (last.lastValue & 0xFF).toString(16).toUpperCase().padStart(2, "0"),
758
795
  frame: host.status.frameCount,
759
796
  framesRun,
760
- hits: fin.hits,
797
+ hits: fin.hits || 1,
798
+ ...(rdRegs ? { registersAtHit: rdRegs } : {}),
761
799
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
762
- note: "pc is the EXACT instruction that read this address. disasm({ target:'rom', startAddress: pc }) to see it.",
800
+ note: "pc is the EXACT instruction that read this address. disasm({ target:'rom', startAddress: pc }) to see it." +
801
+ (rdRegs ? " registersAtHit is the register file frozen AT the read (the live regs drift for the rest of the frame)." : ""),
763
802
  }), host);
764
803
  }
765
804
 
@@ -782,10 +821,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
782
821
  "use it, NOT a follow-up cpu({op:'read'}). On some cores (notably NES/fceumm) the core drains the cycle budget on hit but the frame still finishes, " +
783
822
  "so a live cpu read afterward returns END-OF-FRAME registers, not the break instant. `registersAtHit` sidesteps that. The break PC is reported as `pc`/`pcRaw`; " +
784
823
  "the RAM side effects are also reliable via memory({op:'read'}). frame({op:'stepInstruction'}) to single-step from the break. (on:'read'/'write' finish the frame.)\n" +
785
- "All supported on every CPU core; `registersAtHit` is present on cores that snapshot regs (NES today); out-of-date core packages return notSupported.",
824
+ "All supported on every CPU core. **Every hit carries `registersAtHit` the FULL register file frozen by the core AT the hit instant, on ALL 14 platforms and all three `on` kinds.** Use it instead of a follow-up cpu({op:'read'}): the live registers keep moving after a hit (per-scanline CPU scheduling / frame completion), so a post-hit read drifts — chasing pointer registers read that way burned a real session for hours. The hit `pc` is the EXECUTING instruction's first byte (mid-instruction hooks no longer report the operand-advanced PC). Out-of-date core packages return notSupported.\n" +
825
+ "MENU-SCREEN INPUT TRICK: if a pressDuring schedule never registers (some menu screens poll input in a way scheduled taps miss), HOLD the button instead: input({op:'set', buttons:{...}}) BEFORE this call and OMIT pressDuring — the run inherits the held state, the menu sees the edge, and the breakpoint catches the event.",
786
826
  {
787
827
  on: z.enum(["write", "read", "pc"])
788
- .describe("write=break on a write to address (precision:exact=true writer PC / sampled=frame PC, a lie under IRQ); read=break on a read (exact PC, who consumes it); pc=break when PC reaches address — the hit returns `registersAtHit` (the break-instant A/X/Y/P/S on NES) + the break PC; use registersAtHit, not a follow-up cpu read (which is end-of-frame state on fceumm)."),
828
+ .describe("write=break on a write to address (precision:exact=true writer PC / sampled=frame PC, a lie under IRQ); read=break on a read (exact PC, who consumes it); pc=break when PC reaches address — the hit returns `registersAtHit` (the break-instant register file, all 14 platforms) + the break PC; use registersAtHit, not a follow-up cpu read (end-of-frame state)."),
789
829
  precision: z.enum(["exact", "sampled"]).default("exact")
790
830
  .describe("on:'write' ONLY. exact=core watchpoint, the real writing instruction PC even under interrupts (uses `address`). sampled=cheap frame-boundary PC (uses region/offset/length) — NOT the writer under IRQ. Ignored for on:read/pc (always exact)."),
791
831
  address: z.number().int().min(0).optional().describe("on:'write' exact / on:'read' / on:'pc' — CPU address to break on (write target, read target, or instruction boundary). Required for those."),
@@ -857,7 +897,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
857
897
  return jsonContent({ regId, value: "0x" + (now >>> 0).toString(16).toUpperCase(), valueRaw: now });
858
898
  }
859
899
 
860
- async function cpuCall({ pc, regs, sentinelPC = 0, stopAtPC, presetMemory, maxFrames = 600, maxInstructions, sandbox = false }) {
900
+ async function cpuCall({ pc, regs, sentinelPC = 0, stopAtPC, presetMemory, maxFrames = 600, maxInstructions, sandbox = false, pure = false }) {
861
901
  const host = getHost(sessionKey);
862
902
  if (!host.setRegSupported || !host.setRegSupported()) {
863
903
  return jsonContent({ returned: false, notSupported: true,
@@ -868,17 +908,28 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
868
908
  const r = host.callSubroutine({
869
909
  pc, regs: numRegs, sentinelPC, stopAtPC,
870
910
  presetMemory: (presetMemory ?? []).map((m) => ({ addr: m.addr, hex: m.hex })),
871
- maxFrames, ...(maxInstructions ? { maxInstructions } : {}), sandbox,
911
+ maxFrames, ...(maxInstructions ? { maxInstructions } : {}), sandbox, pure,
872
912
  });
873
- const note = r.returned
913
+ // The poisoned-call caveat (a real session lost hours to this): when the
914
+ // call spanned FRAMES of emulation, the game's own per-frame logic (VBlank
915
+ // handlers via RAM vectors, music drivers) ran CONCURRENTLY and may have
916
+ // written over the routine's output buffer. Loud, up front, with the fix.
917
+ const frameLogicCaveat = (!pure && r.framesRun > 0 && (host.pureCallSupported ? host.pureCallSupported() : false))
918
+ ? ` ⚠ framesRun:${r.framesRun} — the game's own frame logic (VBlank handler, music driver) ran DURING this call and may have modified RAM the routine wrote; treat the output buffer as suspect. Re-run with pure:true to step ONLY the CPU (no frame machinery).`
919
+ : (!pure && r.framesRun > 0)
920
+ ? ` ⚠ framesRun:${r.framesRun} — the game's own frame logic ran DURING this call and may have modified RAM the routine wrote; treat the output buffer as suspect (verify visually or against a known-good slice).`
921
+ : "";
922
+ const note = (r.returned
874
923
  ? "Routine RETURNED. readMemory the buffer it wrote (e.g. the decompressor's A1 dest) now — sandbox:false leaves it live. (regs by reg-id: m68k 8=A0,9=A1,0=D0.)"
875
924
  : r.watchdog
876
925
  ? "WATCHDOG tripped (ran the instruction budget without returning) — almost always a wrong entry setup, not a long routine. Check finalPC (where it's spinning) + finalRegs (is A0 where you set it, or did it walk off?). Common fixes: correct A0 to the real block start (with its length header), add a presetMemory the codec reads, or pass a WRAPPER entryPC that sets up dest. Raise maxInstructions only if you're sure it's legitimately huge."
877
926
  : r.stoppedAtPC
878
927
  ? `Stopped at ${r.stoppedAtPC} (your stopAtPC) with PARTIAL output — readMemory the dst to see what's been written so far.`
879
- : "Did not return within maxFrames AND the watchdog didn't trip — this usually means the entry FELL BACK INTO THE GAME (a wrapper PC with a wrong source, so it never reaches the sentinel) and the game is just free-running. finalPC is inside the main loop, not your routine. Re-check the entry PC (use the routine body, not a wrapper) and the source regs; or lower maxInstructions to fail fast while probing. Bump maxFrames/maxInstructions only if you're sure it's a legitimately huge decompress.";
928
+ : "Did not return within maxFrames AND the watchdog didn't trip — this usually means the entry FELL BACK INTO THE GAME (a wrapper PC with a wrong source, so it never reaches the sentinel) and the game is just free-running. finalPC is inside the main loop, not your routine. Re-check the entry PC (use the routine body, not a wrapper) and the source regs; or lower maxInstructions to fail fast while probing. Bump maxFrames/maxInstructions only if you're sure it's a legitimately huge decompress.")
929
+ + frameLogicCaveat;
880
930
  return jsonContent({
881
931
  returned: r.returned, framesRun: r.framesRun, sandbox,
932
+ ...(r.pure ? { pure: true, pureMode: r.pureMode } : {}),
882
933
  ...(r.watchdog ? { watchdog: true, reason: r.reason } : {}),
883
934
  ...(r.stoppedAtPC ? { stoppedAtPC: r.stoppedAtPC } : {}),
884
935
  ...(r.finalPC ? { finalPC: r.finalPC } : {}),
@@ -911,7 +962,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
911
962
  "OP CHEAT-SHEET (the params each op uses): " +
912
963
  "read → {cpu?, platform?}; " +
913
964
  "setReg → {regId, value}; " +
914
- "call → {pc, regs?, sandbox?, maxInstructions?, sentinelPC?, stopAtPC?, presetMemory?, maxFrames?}; " +
965
+ "call → {pc, regs?, pure?, sandbox?, maxInstructions?, sentinelPC?, stopAtPC?, presetMemory?, maxFrames?}; " +
915
966
  "decompress → {entryPC, sourceAddress, destAddress?, maxFrames?}.\n" +
916
967
  "• op:'read' — read a CPU's {pc, registers, flags, sp}. Main CPU wired for all 14 tier-1 systems (nes, snes, " +
917
968
  "genesis, sms, gg, gb, gbc, atari2600, atari7800, c64, lynx, gba (ARM7TDMI: 16 gprs + cpsr/spsr + execPc for " +
@@ -924,7 +975,13 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
924
975
  "buffer it wrote stays live for memory({op:'read'}).** Classic use: drive a decompressor (A0=source, A1=dest) then read " +
925
976
  "the dst. **NEVER HANGS: an instruction WATCHDOG (`maxInstructions`) force-stops a runaway and returns PROGRESS — " +
926
977
  "finalPC + finalRegs + watchdog:true — so you can tell 'wrong A0' from 'needs a preset' from 'legitimately long'.** " +
927
- "`stopAtPC` halts mid-routine for partial output; `presetMemory` for codecs that read a global from RAM first.\n" +
978
+ "`stopAtPC` halts mid-routine for partial output; `presetMemory` for codecs that read a global from RAM first. " +
979
+ "**`pure:true` (ALL 14 platforms): the game's own VBlank/IRQ logic CANNOT run during the call and stomp the routine's " +
980
+ "output buffer.** Mechanism per platform (reported as `pureMode`): Genesis/SMS/GG step ONLY the CPU ('cpu-only'); every " +
981
+ "other core suppresses interrupt DELIVERY for the duration ('irq-blocked' — video/timers advance harmlessly, no game " +
982
+ "handler executes); the 2600 has no interrupts at all ('no-interrupts'). Without pure, a call that spans frames runs " +
983
+ "the game's frame logic alongside your routine (the result carries a ⚠ caveat) — a real session spent " +
984
+ "hours diffing a CORRECT codec against that poisoned output. Prefer pure for every decompressor/codec call.\n" +
928
985
  "• op:'decompress' — convenience wrapper over op:'call' for the common decompressor shape: call `entryPC` with " +
929
986
  "A0=`sourceAddress` (and optionally A1=`destAddress`), run until it returns, then read `destAddress`. For the " +
930
987
  "NBA-Jam-style 'name + portrait are LZ-compressed' wall: point it at the game's own decompressor.",
@@ -949,6 +1006,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
949
1006
  maxFrames: z.number().int().min(1).max(100000).default(600).describe("op:call/decompress — frame cap (the outer bound)."),
950
1007
  maxInstructions: z.number().int().min(1000).optional().describe("op:call — instruction watchdog budget (the REAL cap; default ~maxFrames*500k). Raise for a huge decompress; lower to fail fast while probing the right A0."),
951
1008
  sandbox: z.boolean().default(false).describe("op:call — snapshot+restore core state around the call (default FALSE — you want the dst buffer left live to read). True leaves the live game untouched."),
1009
+ pure: z.boolean().default(false).describe("op:call — guarantee the game's own frame logic CANNOT run during the call and stomp the routine's output (ALL 14 platforms; `pureMode` in the result says how: 'cpu-only' on Genesis/SMS/GG, 'irq-blocked' elsewhere, 'no-interrupts' on 2600). Prefer this for any decompressor/codec call."),
952
1010
  // decompress
953
1011
  entryPC: z.number().int().min(0).optional().describe("op:decompress — decompressor entry PC."),
954
1012
  sourceAddress: z.number().int().min(0).optional().describe("op:decompress — compressed-source address → A0 (reg-id 8 on m68k)."),
@@ -1044,10 +1102,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1044
1102
  "**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" +
1045
1103
  "• 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" +
1046
1104
  "• 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" +
1047
- "• 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). `perFrame:true` switches to FEEL/PERF MODE: a per-frame timeline of VDP-DMA WORK ({frame,dmas,bytes,romBytes,ramBytes} + peakFrame + `spikes`) — the cheap 'why does horizontal movement feel choppy?' diagnostic (a per-frame byte spike = too much VDP work in the loop, e.g. a tilemap rewrite). On non-Genesis cores returns `notSupported`.",
1105
+ "• 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). `perFrame:true` switches to FEEL/PERF MODE: a per-frame timeline of VDP-DMA WORK ({frame,dmas,bytes,romBytes,ramBytes} + peakFrame + `spikes`) — the cheap 'why does horizontal movement feel choppy?' diagnostic (a per-frame byte spike = too much VDP work in the loop, e.g. a tilemap rewrite). On non-Genesis cores returns `notSupported`.\n" +
1106
+ "• on:'copy' — ALL 14 PLATFORMS: log every write landing in a VRAM/dest address window [start,end] with the EXECUTING instruction's PC — the generic answer to 'this tile/nametable/portrait on screen: which routine uploads it?'. Port-based video memory (NES $2007, SNES $2118/19 — incl. the DMA path, PCE VWR, MSX/SMS/GG VDP data port, Genesis data port) is hooked INSIDE the core, so `start`/`end` are VRAM addresses (NES PPU $0000-$3FFF; SNES VRAM byte addr; PCE VRAM word addr; MSX/SMS/GG VRAM addr). Direct-mapped platforms (GB/GBC $8000-$9FFF, GBA 0x06000000+, C64/Lynx/7800 RAM framebuffers) route through the CPU-address range log automatically — pass CPU addresses there. Follow up with breakpoint({on:'pc', address: pc}) to get registersAtHit at the uploader.",
1048
1107
  {
1049
- on: z.enum(["mem", "range", "pc", "dma"])
1050
- .describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]; dma=Genesis-only mem→VDP DMA source/dest trace."),
1108
+ on: z.enum(["mem", "range", "pc", "dma", "copy"])
1109
+ .describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]; dma=Genesis-only mem→VDP DMA source/dest trace; copy=log every write landing in a VRAM address window with the EXECUTING instruction's PC (all 14 platforms — the generic 'where does this graphic come from?')."),
1051
1110
  // on:'mem'
1052
1111
  region: z.enum(MEMORY_REGIONS).optional().describe("on:'mem' single-range — the region to watch (same canonical set memory uses, incl. nes_apu_regs, genesis_ym2612, c64_sid_regs). Omit when using `ranges`."),
1053
1112
  offset: z.number().int().min(0).default(0).describe("on:'mem' single-range — first byte of the watched range."),
@@ -1084,7 +1143,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1084
1143
  button: z.string(),
1085
1144
  port: z.number().int().min(0).max(3).default(0),
1086
1145
  holdFrames: z.number().int().min(1).default(2),
1087
- })).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). Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten."),
1146
+ })).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). Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten. MENU SCREENS: if a schedule never registers (some menus poll input in a way scheduled taps miss), hold the button via input({op:'set'}) and OMIT pressDuring — the run inherits the held state and the menu sees the edge."),
1088
1147
  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."),
1089
1148
  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."),
1090
1149
  },
@@ -1107,11 +1166,69 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1107
1166
  }
1108
1167
  return await dmaExact(a);
1109
1168
  }
1169
+ case "copy": {
1170
+ if (args.start == null || args.end == null) throw new Error("watch({on:'copy'}): `start` and `end` are required (the VRAM/dest address window).");
1171
+ return await wCopy({ ...args, frames: args.frames ?? 120, limit: args.limit ?? 200 });
1172
+ }
1110
1173
  default: throw new Error(`watch: unknown on '${args.on}'`);
1111
1174
  }
1112
1175
  }),
1113
1176
  );
1114
1177
 
1178
+ // ── watch({on:'copy'}) — the generic graphics source-trace ─────────────────
1179
+ async function wCopy({ start, end, frames = 120, limit = 200, pressDuring }) {
1180
+ const host = getHost(sessionKey);
1181
+ const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
1182
+ const pressDriver = makePressDriver(host, presses);
1183
+ if (host.vramWatchSupported && host.vramWatchSupported()) {
1184
+ // Port-based video memory: the core hook logs {vramAddr, pc, value}
1185
+ // with the EXECUTING instruction's PC (DMA-initiating instruction on
1186
+ // SNES). start/end are VRAM addresses.
1187
+ let i = 0;
1188
+ const r = host.watchVram(start, end, frames, () => { pressDriver.applyForFrame(i++); return false; });
1189
+ pressDriver.finish();
1190
+ const events = r.events.slice(0, limit).map((e) => ({
1191
+ vramAddr: "$" + e.vramAddr.toString(16).toUpperCase(),
1192
+ pc: "$" + e.pc.toString(16).toUpperCase(),
1193
+ pcRaw: e.pc,
1194
+ value: "0x" + e.value.toString(16).toUpperCase().padStart(2, "0"),
1195
+ }));
1196
+ const distinct = [...new Set(r.events.map((e) => e.pc))].slice(0, 32)
1197
+ .map((p) => "$" + p.toString(16).toUpperCase());
1198
+ return jsonContent({
1199
+ on: "copy", mode: "vram-port",
1200
+ window: { start: "$" + start.toString(16).toUpperCase(), end: "$" + end.toString(16).toUpperCase() },
1201
+ framesRun: frames,
1202
+ total: r.total, stored: r.stored, truncated: r.truncated,
1203
+ distinctPCs: distinct,
1204
+ events,
1205
+ note: "pc is the EXECUTING instruction that performed the upload (on SNES the instruction that " +
1206
+ "triggered the DMA). Addresses are VRAM-space. Next: breakpoint({on:'pc', address: <pc>}) to stop " +
1207
+ "there with registersAtHit (source pointer in the index/address regs), then disasm({target:'rom', " +
1208
+ "startAddress: <pc>}) to read the routine." +
1209
+ (r.truncated ? " Ring overflowed — narrow the window or lower frames." : ""),
1210
+ });
1211
+ }
1212
+ // Direct-mapped video memory (GB/GBC/GBA/C64/Lynx/7800): the same
1213
+ // question routes through the CPU-address range log — start/end are CPU
1214
+ // addresses (e.g. GB VRAM $8000-$9FFF).
1215
+ pressDriver.finish();
1216
+ const out = await wRange({ start, end, frames, limit, kind: "write", pressDuring });
1217
+ try {
1218
+ const parsed = JSON.parse(out.content.find((c) => c.type === "text").text);
1219
+ parsed.on = "copy";
1220
+ parsed.mode = "cpu-mapped";
1221
+ parsed.note = (parsed.note ? parsed.note + " " : "") +
1222
+ "This platform's video memory is CPU-mapped, so the copy trace IS the write-range log: " +
1223
+ "start/end are CPU addresses (GB/GBC VRAM $8000-$9FFF; GBA 0x06000000+; C64/Lynx/7800 use the " +
1224
+ "framebuffer/display-list RAM range). pc is the executing instruction; follow up with " +
1225
+ "breakpoint({on:'pc', address: pc}) for registersAtHit.";
1226
+ return jsonContent(parsed);
1227
+ } catch {
1228
+ return out;
1229
+ }
1230
+ }
1231
+
1115
1232
  // ── watch({on:'dma'}) helpers (Genesis only) ────────────────────────────────
1116
1233
  // precision:exact = dmaExact (watchDma, per-DMA core log), precision:sampled =
1117
1234
  // traceVramSourceCore (frame-sampled, dest-agnostic). Routed by the `watch`