romdevtools 0.16.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/AGENTS.md +75 -16
  2. package/CHANGELOG.md +316 -0
  3. package/examples/README.md +2 -0
  4. package/examples/atari2600/templates/platformer.asm +460 -0
  5. package/examples/atari2600/templates/racing.asm +463 -0
  6. package/examples/atari2600/templates/shmup.asm +386 -0
  7. package/examples/atari2600/templates/sports.asm +362 -0
  8. package/examples/atari7800/templates/default.c +49 -5
  9. package/examples/atari7800/templates/hello_sprite.c +48 -4
  10. package/examples/atari7800/templates/music_demo.c +47 -2
  11. package/examples/atari7800/templates/platformer.c +43 -4
  12. package/examples/atari7800/templates/puzzle.c +39 -4
  13. package/examples/atari7800/templates/racing.c +39 -4
  14. package/examples/atari7800/templates/shmup.c +40 -2
  15. package/examples/atari7800/templates/sports.c +36 -5
  16. package/examples/c64/templates/platformer.c +19 -5
  17. package/examples/c64/templates/puzzle.c +32 -2
  18. package/examples/c64/templates/shmup.c +28 -2
  19. package/examples/c64/templates/sports.c +30 -2
  20. package/examples/c64/templates/tile_engine.c +77 -27
  21. package/examples/gb/templates/default.c +110 -16
  22. package/examples/gb/templates/hello_sprite.c +15 -6
  23. package/examples/gb/templates/music_demo.c +36 -0
  24. package/examples/gb/templates/platformer.c +28 -6
  25. package/examples/gb/templates/puzzle.c +35 -4
  26. package/examples/gb/templates/racing.c +75 -10
  27. package/examples/gb/templates/shmup.c +41 -3
  28. package/examples/gb/templates/sports.c +51 -3
  29. package/examples/gb/templates/tile_engine.c +3 -2
  30. package/examples/gba/templates/gba_hello.c +29 -11
  31. package/examples/gba/templates/maxmod_demo.c +36 -2
  32. package/examples/gba/templates/platformer.c +3 -1
  33. package/examples/gba/templates/puzzle.c +15 -3
  34. package/examples/gba/templates/racing.c +65 -3
  35. package/examples/gba/templates/shmup.c +41 -4
  36. package/examples/gba/templates/sports.c +36 -2
  37. package/examples/gba/templates/tonc_hello.c +41 -5
  38. package/examples/gba/templates/tonc_hello_sprite.c +35 -1
  39. package/examples/gbc/templates/default.c +103 -26
  40. package/examples/gbc/templates/hello_sprite.c +12 -3
  41. package/examples/gbc/templates/music_demo.c +56 -12
  42. package/examples/gbc/templates/platformer.c +28 -6
  43. package/examples/gbc/templates/puzzle.c +35 -4
  44. package/examples/gbc/templates/racing.c +88 -21
  45. package/examples/gbc/templates/shmup.c +37 -3
  46. package/examples/gbc/templates/sports.c +48 -3
  47. package/examples/gbc/templates/tile_engine.c +3 -2
  48. package/examples/genesis/main.s +53 -1
  49. package/examples/genesis/templates/hello_sprite.c +25 -3
  50. package/examples/genesis/templates/puzzle.c +37 -3
  51. package/examples/genesis/templates/racing.c +44 -11
  52. package/examples/genesis/templates/sgdk_hello.c +34 -1
  53. package/examples/genesis/templates/shmup.c +31 -1
  54. package/examples/genesis/templates/shmup_2p.c +31 -0
  55. package/examples/genesis/templates/xgm2_demo.c +20 -0
  56. package/examples/gg/templates/default.c +56 -18
  57. package/examples/gg/templates/hello_sprite.c +25 -2
  58. package/examples/gg/templates/music_demo.c +24 -2
  59. package/examples/gg/templates/platformer.c +18 -12
  60. package/examples/gg/templates/puzzle.c +38 -7
  61. package/examples/gg/templates/racing.c +58 -9
  62. package/examples/gg/templates/shmup.c +47 -3
  63. package/examples/gg/templates/sports.c +57 -16
  64. package/examples/gg/templates/tile_engine.c +12 -6
  65. package/examples/lynx/templates/default.c +39 -8
  66. package/examples/lynx/templates/hello_sprite.c +15 -1
  67. package/examples/lynx/templates/music_demo.c +13 -1
  68. package/examples/lynx/templates/puzzle.c +28 -1
  69. package/examples/lynx/templates/racing.c +34 -7
  70. package/examples/lynx/templates/shmup.c +42 -3
  71. package/examples/lynx/templates/sports.c +29 -2
  72. package/examples/msx/platformer/main.c +213 -0
  73. package/examples/msx/puzzle/main.c +250 -0
  74. package/examples/msx/racing/main.c +249 -0
  75. package/examples/msx/shmup/main.c +288 -0
  76. package/examples/msx/sports/main.c +182 -0
  77. package/examples/nes/templates/default.c +67 -19
  78. package/examples/nes/templates/hello_sprite.c +35 -0
  79. package/examples/nes/templates/music_demo.c +40 -0
  80. package/examples/nes/templates/platformer.c +65 -6
  81. package/examples/nes/templates/puzzle.c +67 -6
  82. package/examples/nes/templates/racing.c +45 -13
  83. package/examples/nes/templates/shmup.c +51 -2
  84. package/examples/nes/templates/sports.c +51 -6
  85. package/examples/pce/catch_game/main.c +22 -3
  86. package/examples/pce/music_sfx/main.c +28 -1
  87. package/examples/pce/platformer/main.c +283 -0
  88. package/examples/pce/puzzle/main.c +304 -0
  89. package/examples/pce/racing/main.c +304 -0
  90. package/examples/pce/shmup/main.c +346 -0
  91. package/examples/pce/sports/main.c +254 -0
  92. package/examples/pce/sprite_move/main.c +7 -2
  93. package/examples/sms/main.c +35 -6
  94. package/examples/sms/templates/hello_sprite.c +29 -3
  95. package/examples/sms/templates/music_demo.c +18 -4
  96. package/examples/sms/templates/puzzle.c +34 -5
  97. package/examples/sms/templates/racing.c +39 -2
  98. package/examples/sms/templates/shmup.c +41 -2
  99. package/examples/sms/templates/shmup_2p.c +24 -1
  100. package/examples/sms/templates/sports.c +47 -4
  101. package/examples/snes/main.asm +108 -17
  102. package/examples/snes/templates/c-hello-data.asm +23 -0
  103. package/examples/snes/templates/c-hello.c +18 -1
  104. package/examples/snes/templates/default.c +50 -28
  105. package/examples/snes/templates/hello_sprite-data.asm +23 -0
  106. package/examples/snes/templates/hello_sprite.c +17 -1
  107. package/examples/snes/templates/music_demo-data.asm +23 -0
  108. package/examples/snes/templates/music_demo.c +22 -4
  109. package/examples/snes/templates/platformer-data.asm +22 -0
  110. package/examples/snes/templates/platformer.c +20 -2
  111. package/examples/snes/templates/puzzle-data.asm +22 -0
  112. package/examples/snes/templates/puzzle.c +21 -2
  113. package/examples/snes/templates/racing-data.asm +22 -0
  114. package/examples/snes/templates/racing.c +17 -1
  115. package/examples/snes/templates/shmup-data.asm +22 -0
  116. package/examples/snes/templates/shmup.c +20 -1
  117. package/examples/snes/templates/sports-data.asm +22 -0
  118. package/examples/snes/templates/sports.c +16 -1
  119. package/package.json +1 -1
  120. package/src/cheats/gamegenie.js +0 -1
  121. package/src/cli/smoke.js +1 -3
  122. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  123. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  124. package/src/host/LibretroHost.js +191 -16
  125. package/src/host/callbacks.js +9 -1
  126. package/src/host/chafa-render.js +2 -0
  127. package/src/host/dsp-state.js +2 -2
  128. package/src/host/gpgx-state.js +4 -0
  129. package/src/host/types.js +15 -8
  130. package/src/http/routes.js +1 -1
  131. package/src/http/tool-registry.js +26 -1
  132. package/src/mcp/server.js +1 -1
  133. package/src/mcp/state.js +36 -0
  134. package/src/mcp/tools/address-to-symbol.js +0 -1
  135. package/src/mcp/tools/art-loaders.js +1 -1
  136. package/src/mcp/tools/cart-parts.js +75 -4
  137. package/src/mcp/tools/classify-region.js +1 -1
  138. package/src/mcp/tools/diff-roms.js +1 -1
  139. package/src/mcp/tools/disasm-rebuild.js +507 -0
  140. package/src/mcp/tools/disasm.js +97 -9
  141. package/src/mcp/tools/find-references.js +1 -2
  142. package/src/mcp/tools/font-map.js +1 -1
  143. package/src/mcp/tools/frame.js +168 -3
  144. package/src/mcp/tools/index.js +0 -49
  145. package/src/mcp/tools/input-layout.js +0 -1
  146. package/src/mcp/tools/input.js +33 -3
  147. package/src/mcp/tools/lifecycle.js +18 -4
  148. package/src/mcp/tools/lospec.js +0 -19
  149. package/src/mcp/tools/platform-docs.js +1 -1
  150. package/src/mcp/tools/platform-tools.js +4 -4
  151. package/src/mcp/tools/project.js +54 -11
  152. package/src/mcp/tools/reinject.js +0 -1
  153. package/src/mcp/tools/rom-id.js +2 -2
  154. package/src/mcp/tools/snippets.js +2 -2
  155. package/src/mcp/tools/sprite-pipeline.js +1 -2
  156. package/src/mcp/tools/state.js +201 -14
  157. package/src/mcp/tools/tile-inspect.js +1 -1
  158. package/src/mcp/tools/toolchain.js +105 -12
  159. package/src/mcp/tools/watch-memory.js +137 -16
  160. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +34 -0
  161. package/src/platforms/atari2600/TROUBLESHOOTING.md +6 -0
  162. package/src/platforms/atari7800/TROUBLESHOOTING.md +6 -0
  163. package/src/platforms/c64/MENTAL_MODEL.md +45 -1
  164. package/src/platforms/c64/TROUBLESHOOTING.md +6 -0
  165. package/src/platforms/c64/d64.js +280 -0
  166. package/src/platforms/c64/sid.js +0 -2
  167. package/src/platforms/common/metasprite-adapters.js +1 -1
  168. package/src/platforms/common/metasprite-codegen.js +3 -3
  169. package/src/platforms/common/registers.js +5 -3
  170. package/src/platforms/gb/MENTAL_MODEL.md +10 -0
  171. package/src/platforms/gb/TROUBLESHOOTING.md +6 -0
  172. package/src/platforms/gb/lib/c/gb_runtime.c +4 -4
  173. package/src/platforms/gba/TROUBLESHOOTING.md +6 -0
  174. package/src/platforms/gbc/TROUBLESHOOTING.md +6 -0
  175. package/src/platforms/gbc/lib/c/gb_runtime.c +4 -4
  176. package/src/platforms/genesis/TROUBLESHOOTING.md +6 -0
  177. package/src/platforms/gg/TROUBLESHOOTING.md +6 -0
  178. package/src/platforms/lynx/TROUBLESHOOTING.md +6 -0
  179. package/src/platforms/msx/MENTAL_MODEL.md +10 -6
  180. package/src/platforms/msx/TROUBLESHOOTING.md +6 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +63 -2
  182. package/src/platforms/nes/TROUBLESHOOTING.md +6 -0
  183. package/src/platforms/nes/image-to-tilemap.js +3 -0
  184. package/src/platforms/nes/lib/asm/famitone2.s +5 -1
  185. package/src/platforms/pce/MENTAL_MODEL.md +9 -4
  186. package/src/platforms/pce/TROUBLESHOOTING.md +6 -0
  187. package/src/platforms/pce/lib/c/pce_video.c +1 -1
  188. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  189. package/src/platforms/snes/TROUBLESHOOTING.md +6 -0
  190. package/src/platforms/snes/brr.js +0 -2
  191. package/src/playtest/playtest.js +0 -7
  192. package/src/rom-id/identifier.js +15 -0
  193. package/src/toolchains/asar/asar.js +0 -9
  194. package/src/toolchains/assemble-snippet.js +30 -12
  195. package/src/toolchains/cc65/ines.js +145 -0
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +14 -1
  197. package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
  198. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
  199. package/src/toolchains/common/reassemble.js +10 -3
  200. package/src/toolchains/common/sdk-cache.js +1 -1
  201. package/src/toolchains/genesis-c/genesis-c.js +5 -3
  202. package/src/toolchains/index.js +27 -3
  203. package/src/toolchains/parse-errors.js +78 -1
  204. package/src/toolchains/sdcc/preflight-lint.js +5 -1
  205. package/src/toolchains/sdcc/sdcc.js +1 -1
  206. package/src/toolchains/sjasm/sjasm.js +1 -1
  207. package/src/toolchains/snes-c/snes-c.js +2 -2
  208. package/src/toolchains/vasm68k/vasm68k.js +2 -4
  209. package/src/toolchains/wladx/wladx.js +1 -1
@@ -8,7 +8,6 @@
8
8
  // the project elsewhere and rebuild with cc65/sdcc directly, every byte
9
9
  // that compiles is in the directory.
10
10
 
11
- import { readFile, writeFile } from "node:fs/promises";
12
11
  import { jsonContent, safeTool } from "../util.js";
13
12
  import { starterSnippetsCore, copyStarterSnippetsCore } from "./snippets.js";
14
13
 
@@ -283,6 +282,11 @@ const TEMPLATES = {
283
282
  sprite_move: mk("sprite_move", "Joypad-controlled 16x16 sprite over a tiled background. d-pad moves the sprite; verified visible + responsive. Build up an action game from here."),
284
283
  music_sfx: mk("music_sfx", "HuC6280 PSG demo: a looping melody plus a button-fired SFX. Shows psg_tone/psg_off across the PSG's wavetable channels."),
285
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
+ 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
+ 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."),
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
+ 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."),
286
290
  };
287
291
  })(),
288
292
 
@@ -299,6 +303,11 @@ const TEMPLATES = {
299
303
  sprite_move: mk("sprite_move", "Joystick-controlled sprite on a screen-2 background. d-pad moves the sprite; verified visible + responsive. The base for any action game."),
300
304
  music_sfx: mk("music_sfx", "AY-3-8910 PSG demo: a looping melody on channel A plus a trigger-fired SFX on channel C, with an on-screen indicator."),
301
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
+ 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
+ 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."),
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
+ 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."),
302
311
  };
303
312
  })(),
304
313
  };
@@ -1223,6 +1232,43 @@ TEMPLATES.atari2600 = {
1223
1232
  ext: ".a26",
1224
1233
  describe: "Gallery-shooter (Space-Invaders-shaped) done with the RIGHT TIA objects, not playfield 'barcode' bars: P0 = double-width cannon, P1 + NUSIZ1=%011 = a row of THREE hardware-replicated invaders (one GRP1 write draws all three), M0 = the player shot. Aliens march left/right and drop a step at the edges; fire with the joystick button. The honest 2600-idiomatic way to do this genre — extend by reusing P1 lower for shields or adding M1 as an alien bomb. Verified: marches + renders cannon/aliens/shot.",
1225
1234
  },
1235
+ // ── Genre scaffolds ───────────────────────────────────────────────
1236
+ // The 2600 maps cleanly onto only SOME of the five canonical genres.
1237
+ // shmup + sports are the console's native idioms (Space Invaders /
1238
+ // Pong); racing (top-down) and platformer (single-screen) are honest,
1239
+ // period-correct fits. puzzle (match-3) is deliberately ABSENT — see
1240
+ // the note after this block: a 6x12 multi-colour grid is not
1241
+ // renderable on a tilemap-less, one-COLUPF-per-line, 2-player TIA, so
1242
+ // shipping a "puzzle" key would mean shipping something that isn't a
1243
+ // recognizable match-3. Genre id == template key (createGame maps 1:1).
1244
+ shmup: {
1245
+ main: "templates/shmup.asm",
1246
+ runtime: [],
1247
+ lang: "6507 assembly (dasm)",
1248
+ ext: ".a26",
1249
+ describe: "SHMUP — the 2600's flagship genre (Space Invaders / Galaxian / Demon Attack). Gallery shooter done with the RIGHT TIA objects: P0 = double-width cannon, P1 + NUSIZ1=%011 = a row of THREE hardware-replicated invaders (one GRP1 write draws all three), M0 = the player shot. Aliens march left/right and drop a step at the edges; fire with the joystick button. Same proven body as the `mini_invaders` template. Extend with M1 as an alien bomb or reuse P1 lower for shields.",
1250
+ },
1251
+ sports: {
1252
+ main: "templates/sports.asm",
1253
+ runtime: [],
1254
+ lang: "6507 assembly (dasm)",
1255
+ ext: ".a26",
1256
+ describe: "SPORTS — Pong, the 2600's archetypal sport (Combat / Video Olympics). Two 8-px paddles (P0 left, P1 right), one 2-px ball (BL), top+bottom walls via reflected playfield. Joystick UP/DOWN drives the left paddle; the right paddle is AI (chases the ball's Y). Blip on wall bounce, chime on score. Same proven body as the `paddle` template. Demonstrates multi-object positioning (RESP0/RESP1/RESBL) + the 2-line kernel. Add a real P2 on the second port (SWCHA low nibble) to make it head-to-head.",
1257
+ },
1258
+ racing: {
1259
+ main: "templates/racing.asm",
1260
+ runtime: [],
1261
+ lang: "6507 assembly (dasm)",
1262
+ ext: ".a26",
1263
+ describe: "RACING — top-down vertical-scroll lane racer, the honest 2600 racing idiom (Enduro-style; pseudo-3D road projection needs a per-line table the 4 KB/76-cycle starter budget can't spare). P0 = your car near the bottom (LEFT/RIGHT to weave), reflected playfield draws the two road rails + a dashed centre line that scrolls upward to convey speed, P1 + M0 = descending traffic/hazards you must dodge. Speed (and score) ramps the longer you survive; a TIA-collision crash flashes the screen red and resets your speed. Extend with M1 as a 3rd hazard or NUSIZ1 for two-abreast traffic.",
1264
+ },
1265
+ platformer: {
1266
+ main: "templates/platformer.asm",
1267
+ runtime: [],
1268
+ lang: "6507 assembly (dasm)",
1269
+ ext: ".a26",
1270
+ describe: "PLATFORMER — SINGLE-SCREEN (Pitfall! / Montezuma / Kangaroo idiom). The 2600 has NO hardware scroll, no tilemap, 128 B RAM — a smooth side-scroller is not the honest fit (real games flip whole screens). This ships the genre CORE: fixed-point gravity + a jump arc (FIRE button), and land-on-top collision tested in CODE (not TIA collision, since you must know WHICH surface to stand on) against a 4-entry platform table drawn as horizontal playfield bars (the only TIA object wide enough to be a platform). Joystick walks L/R. Extend with ladders (UP/DOWN over a ladder x-span), an enemy on P1, a thrown rock on M0, or Pitfall-style screen-flipping at the edges. NOT a scroller — single screen by design.",
1271
+ },
1226
1272
  };
1227
1273
 
1228
1274
  // R22: Atari 7800 promoted to multi-template platform. Each template is
@@ -1729,7 +1775,6 @@ Compiles **C89**, not C99/C11. Stick to:
1729
1775
  // runtime tmpl.runtime list typically includes them) are skipped.
1730
1776
  const snippetFiles = [];
1731
1777
  if (withSnippets) {
1732
- const { listSnippetsForPlatform } = await import("./snippets.js").catch(() => ({}));
1733
1778
  // Inline minimal duplicate of listSnippetsForPlatform since snippets.js
1734
1779
  // doesn't export it. Keep this in sync with snippets.js.
1735
1780
  const LIB_DIR = path.join(PLATFORM_LIB_DIR, "lib");
@@ -1810,15 +1855,13 @@ async function createGameCore({ platform, genre, name, path: projPath, title, ov
1810
1855
  ? CANONICAL_GENRES.filter((g) => platformTemplates[g])
1811
1856
  : [];
1812
1857
  if (availableGenres.length === 0) {
1813
- // Point at the real, working project templates each genre-less
1814
- // platform actually ships, so the agent has a concrete next step
1815
- // instead of a bare "default".
1816
- const PROJECT_TEMPLATE_HINTS = {
1817
- msx: "default, sprite_move, music_sfx, catch_game",
1818
- pce: "default, sprite_move, music_sfx, catch_game",
1819
- atari2600: "default, single_screen, paddle, mini_invaders, music_demo",
1820
- };
1821
- const hint = PROJECT_TEMPLATE_HINTS[platform];
1858
+ // Reached only by a platform that ships NO canonical genre yet (every
1859
+ // tier-1 platform now ships at least one atari2600 ships 4, the rest
1860
+ // ship all 5 so in practice this is the bring-up / non-genre tier).
1861
+ // List that platform's real project templates so the agent has a
1862
+ // concrete next step instead of a bare "default".
1863
+ const projTemplates = platformTemplates ? Object.keys(platformTemplates) : [];
1864
+ const hint = projTemplates.length ? projTemplates.join(", ") : null;
1822
1865
  throw new Error(
1823
1866
  `createGame: no genre scaffolds for platform '${platform}' yet. ` +
1824
1867
  `Supported platforms: ${genrePlatforms.join(", ") || "(none)"}. ` +
@@ -16,7 +16,6 @@
16
16
  // dev wiki (citations inline).
17
17
 
18
18
  import { readFile, writeFile } from "node:fs/promises";
19
- import { jsonContent, safeTool } from "../util.js";
20
19
 
21
20
  // ───────────────────────────────────────────────────────────────────────────
22
21
  // Pointer encoding per platform.
@@ -1,5 +1,5 @@
1
1
  import { imageContent, jsonContent, safeTool } from "../util.js";
2
- import { intentZod, resolveIntent } from "../../platforms/common/intent.js";
2
+ import { resolveIntent } from "../../platforms/common/intent.js";
3
3
  import { getDefaultPalette, DEFAULT_PALETTES } from "../../platforms/common/default-palette.js";
4
4
  import { spliceChrCore } from "./splice-chr.js";
5
5
  import { relocateBlockCore, makeStoredBlockCore, findPointerToCore, PLATFORM_REGISTRY } from "./reinject.js";
@@ -65,7 +65,7 @@ export async function gbHeaderCore({ path: inPath, outputPath, cgb, title, cartT
65
65
  };
66
66
  }
67
67
 
68
- export function registerRomIdTools(server, z, sessionKey) {
68
+ export function registerRomIdTools(server, z, _sessionKey) {
69
69
  // identifyRom folded into `cart`; patchFile/patchRom/spliceCHR/relocate/etc.
70
70
  // folded into the `romPatch` tool (router below).
71
71
  const PLATFORMS = Object.keys(PLATFORM_REGISTRY);
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  import path from "node:path";
14
14
  import { readdir, readFile, stat, mkdir, writeFile } from "node:fs/promises";
15
15
 
16
- import { jsonContent, safeTool, textContent, writeOutput } from "../util.js";
16
+ import { jsonContent, textContent, writeOutput } from "../util.js";
17
17
 
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = path.dirname(__filename);
@@ -87,7 +87,7 @@ async function listSnippetsForPlatform(platform) {
87
87
  return out;
88
88
  }
89
89
 
90
- export function registerSnippetTools(server, z) {
90
+ export function registerSnippetTools(_server, _z) {
91
91
  // ── Shared implementations for the three snippet modes ──────────
92
92
  async function snippetsList(platform, language) {
93
93
  const all = await listSnippetsForPlatform(platform);
@@ -15,7 +15,6 @@ import { readFile, writeFile } from "node:fs/promises";
15
15
  import { PNG } from "pngjs";
16
16
  import { jsonContent, safeTool } from "../util.js";
17
17
  import { intentZod, resolveIntent, intentError } from "../../platforms/common/intent.js";
18
- import { getDefaultPalette } from "../../platforms/common/default-palette.js";
19
18
  import { convertImageToTilesCore, imageToTilemapCore } from "./platform-tools.js";
20
19
  import { validateGenesisTilesCore } from "./metasprite-tools.js";
21
20
 
@@ -542,7 +541,7 @@ async function crossPlatformSpriteImportImpl(args) {
542
541
 
543
542
  // ── Register all three tools ─────────────────────────────────────────
544
543
 
545
- export function registerSpritePipelineTools(server, z, sessionKey) {
544
+ export function registerSpritePipelineTools(server, z, _sessionKey) {
546
545
  server.tool(
547
546
  "encodeArt",
548
547
  "Encode a PNG into a platform's native art format, one tool keyed by `stage` — the PNG→tiles pipeline. " +
@@ -3,6 +3,21 @@ import path from "node:path";
3
3
  import { getHost } from "../state.js";
4
4
  import { jsonContent, safeTool } from "../util.js";
5
5
 
6
+ // Resolve a state-file `path`. An ABSOLUTE path is used as-is. A RELATIVE path
7
+ // is resolved against the LOADED ROM's directory (the agent's mental model is
8
+ // "save states live next to my ROM") — NOT the server's CWD, which is opaque to
9
+ // the caller and was a silent ENOENT footgun (v0.15.0 feedback #1). Falls back
10
+ // to CWD only when no ROM path is known (e.g. ROM loaded from base64).
11
+ export function resolveStatePath(p, host) {
12
+ if (!p || path.isAbsolute(p)) return p;
13
+ const media = host?.status?.mediaPath;
14
+ // mediaPath is "<memory…>" for base64 loads — not a real dir; skip those.
15
+ if (media && !media.startsWith("<") && path.isAbsolute(media)) {
16
+ return path.resolve(path.dirname(media), p);
17
+ }
18
+ return path.resolve(p);
19
+ }
20
+
6
21
  // Per-session state-diff baselines (op:'diff'). Module-local; keyed by sessionKey.
7
22
  const _stateDiffSnaps = new Map();
8
23
  function stateDiffSnapshots(key) {
@@ -19,16 +34,17 @@ async function saveStateCore({ name, path: outPath }, sessionKey) {
19
34
  const host = getHost(sessionKey);
20
35
  const done = [];
21
36
  if (name) { host.saveState(name); done.push(`slot '${name}'`); }
22
- if (outPath) {
37
+ const resolvedOut = outPath ? resolveStatePath(outPath, host) : null;
38
+ if (resolvedOut) {
23
39
  const blob = host.serializeState();
24
- await mkdir(path.dirname(outPath), { recursive: true });
25
- await writeFile(outPath, blob);
26
- done.push(`${blob.length} bytes → ${outPath}`);
40
+ await mkdir(path.dirname(resolvedOut), { recursive: true });
41
+ await writeFile(resolvedOut, blob);
42
+ done.push(`${blob.length} bytes → ${resolvedOut}`);
27
43
  }
28
44
  return {
29
45
  saved: true,
30
46
  ...(name ? { name } : {}),
31
- ...(outPath ? { path: outPath } : {}),
47
+ ...(resolvedOut ? { path: resolvedOut, ...(resolvedOut !== outPath ? { resolvedPath: resolvedOut } : {}) } : {}),
32
48
  platform: host.status.platform,
33
49
  note: `Saved ${done.join(" + ")}.` + (outPath ? " Restore across sessions with state({op:'load', path}) after loading the same ROM." : ""),
34
50
  };
@@ -38,12 +54,14 @@ async function saveStateCore({ name, path: outPath }, sessionKey) {
38
54
  async function exportStateCore({ fromSlot, path: outPath }, sessionKey) {
39
55
  const host = getHost(sessionKey);
40
56
  const blob = host.getStateBlob(fromSlot); // throws if the slot is missing — no host disturbance
41
- await mkdir(path.dirname(outPath), { recursive: true });
42
- await writeFile(outPath, blob);
57
+ const resolvedOut = resolveStatePath(outPath, host);
58
+ await mkdir(path.dirname(resolvedOut), { recursive: true });
59
+ await writeFile(resolvedOut, blob);
43
60
  return {
44
61
  exported: true,
45
62
  fromSlot,
46
- path: outPath,
63
+ path: resolvedOut,
64
+ ...(resolvedOut !== outPath ? { resolvedPath: resolvedOut } : {}),
47
65
  bytes: blob.length,
48
66
  platform: host.status.platform,
49
67
  note: "Copied the slot to disk; the live host was not touched (no pause/resume needed).",
@@ -56,8 +74,9 @@ async function loadStateCore({ name, path: inPath, render = true }, sessionKey)
56
74
  if (name && inPath) throw new Error("state({op:'load'}): provide `name` OR `path`, not both.");
57
75
  const host = getHost(sessionKey);
58
76
  let cheatsCleared = 0;
59
- if (inPath) {
60
- const blob = new Uint8Array(await readFile(inPath));
77
+ const resolvedIn = inPath ? resolveStatePath(inPath, host) : null;
78
+ if (resolvedIn) {
79
+ const blob = new Uint8Array(await readFile(resolvedIn));
61
80
  cheatsCleared = host.unserializeState(blob) || 0;
62
81
  } else {
63
82
  cheatsCleared = host.loadState(name) || 0;
@@ -66,7 +85,7 @@ async function loadStateCore({ name, path: inPath, render = true }, sessionKey)
66
85
  if (render) { host.renderOneFrame(); rendered = true; }
67
86
  return {
68
87
  loaded: true,
69
- ...(inPath ? { path: inPath } : { name }),
88
+ ...(resolvedIn ? { path: resolvedIn, ...(resolvedIn !== inPath ? { resolvedPath: resolvedIn } : {}) } : { name }),
70
89
  platform: host.status.platform,
71
90
  rendered,
72
91
  ...(host.status.paused && rendered ? { renderedWhilePaused: true } : {}),
@@ -79,6 +98,153 @@ function listStatesCore(_args, sessionKey) {
79
98
  return { states: getHost(sessionKey).listStates() };
80
99
  }
81
100
 
101
+ // SRAM presence: the battery-backed cartridge save RAM size for the loaded ROM
102
+ // (0 = this cart/system has no battery save). Used by exportSram/importSram and
103
+ // surfaced so an agent knows whether a save file even exists.
104
+ function sramSize(host) {
105
+ try { return host.regionSize("save_ram"); } catch { return 0; }
106
+ }
107
+
108
+ /** op:'exportSram' — write the cartridge's battery SAVE RAM to a .sav file.
109
+ * This is the actual save-game file (distinct from a whole-machine savestate):
110
+ * the bytes a real cart keeps on its battery. Empty on a no-battery cart. */
111
+ async function exportSramCore({ path: outPath }, sessionKey) {
112
+ const host = getHost(sessionKey);
113
+ const size = sramSize(host);
114
+ if (!size) {
115
+ throw new Error(
116
+ `state({op:'exportSram'}): the loaded ROM has no battery save RAM ` +
117
+ `(platform '${host.status.platform}', size 0). Either this cart has no battery ` +
118
+ `save, or this system never had cartridge saves (Atari 2600/7800, Lynx; C64 saves ` +
119
+ `are disk-based). Use state({op:'save', path}) for a full-machine savestate instead.`);
120
+ }
121
+ const blob = host.readMemory("save_ram", 0, size);
122
+ const resolved = resolveStatePath(outPath, host);
123
+ await mkdir(path.dirname(resolved), { recursive: true });
124
+ await writeFile(resolved, Buffer.from(blob));
125
+ return {
126
+ exportedSram: true,
127
+ path: resolved,
128
+ ...(resolved !== outPath ? { resolvedPath: resolved } : {}),
129
+ bytes: size,
130
+ platform: host.status.platform,
131
+ note: "Wrote the cartridge's battery SAVE RAM (the .sav save-game file). Restore with " +
132
+ "state({op:'importSram', path}) after loading the same ROM. This is the SAVE FILE, " +
133
+ "not a savestate — edit it offline (it's raw SRAM) or inject one a player made elsewhere.",
134
+ };
135
+ }
136
+
137
+ /** op:'importSram' — load a .sav file back into the cartridge's battery SAVE RAM. */
138
+ async function importSramCore({ path: inPath }, sessionKey) {
139
+ const host = getHost(sessionKey);
140
+ const size = sramSize(host);
141
+ if (!size) {
142
+ throw new Error(
143
+ `state({op:'importSram'}): the loaded ROM has no battery save RAM ` +
144
+ `(platform '${host.status.platform}', size 0) — nowhere to load a .sav into.`);
145
+ }
146
+ const resolved = resolveStatePath(inPath, host);
147
+ const blob = new Uint8Array(await readFile(resolved));
148
+ if (blob.length !== size) {
149
+ // Size mismatch is the classic wrong-game/wrong-region footgun — surface it,
150
+ // but allow a smaller blob (zero-pad) since some dumps trim trailing zeros.
151
+ if (blob.length > size) {
152
+ throw new Error(
153
+ `state({op:'importSram'}): .sav is ${blob.length} bytes but this cart's SAVE RAM is ${size} ` +
154
+ `— too large (wrong game/region?). Refusing to truncate.`);
155
+ }
156
+ }
157
+ host.writeMemory("save_ram", 0, blob);
158
+ return {
159
+ importedSram: true,
160
+ path: resolved,
161
+ ...(resolved !== inPath ? { resolvedPath: resolved } : {}),
162
+ bytes: blob.length,
163
+ sramSize: size,
164
+ ...(blob.length < size ? { zeroPadded: size - blob.length } : {}),
165
+ platform: host.status.platform,
166
+ note: "Loaded the .sav into the cartridge's battery SAVE RAM. The running game sees it " +
167
+ "on its next save-RAM read (some games re-read only on a load/menu). " +
168
+ (blob.length < size ? `Blob was smaller than SRAM (${blob.length}<${size}); the tail kept its prior bytes.` : ""),
169
+ };
170
+ }
171
+
172
+ /** op:'exportDisk' — write the LIVE mounted C64 .d64 disk image to a file.
173
+ * The C64 analogue of exportSram: a game saves by writing files to its disk, and
174
+ * this snapshots the whole disk (incl. any saves the game wrote). C64/VICE only. */
175
+ async function exportDiskCore({ path: outPath, unit = 8 }, sessionKey) {
176
+ const host = getHost(sessionKey);
177
+ if (!host.diskImageSupported || !host.diskImageSupported()) {
178
+ throw new Error("state({op:'exportDisk'}): disk images are a C64 feature (VICE). " +
179
+ `The loaded platform is '${host.status.platform}'.`);
180
+ }
181
+ const blob = host.exportDiskImage(unit); // throws if no .d64 mounted
182
+ const resolved = resolveStatePath(outPath, host);
183
+ await mkdir(path.dirname(resolved), { recursive: true });
184
+ await writeFile(resolved, Buffer.from(blob));
185
+ return {
186
+ exportedDisk: true,
187
+ path: resolved,
188
+ ...(resolved !== outPath ? { resolvedPath: resolved } : {}),
189
+ bytes: blob.length,
190
+ unit,
191
+ note: "Wrote the LIVE 1541 disk image (.d64) — the C64 save medium. Re-load it later " +
192
+ "with loadMedia({platform:'c64', path}) (it autostarts), or push it back into a " +
193
+ "running session with state({op:'importDisk', path}). This captures any files the " +
194
+ "game wrote to disk.",
195
+ };
196
+ }
197
+
198
+ /** op:'importDisk' — write a .d64 file back into the LIVE mounted C64 disk image. */
199
+ async function importDiskCore({ path: inPath, unit = 8 }, sessionKey) {
200
+ const host = getHost(sessionKey);
201
+ if (!host.diskImageSupported || !host.diskImageSupported()) {
202
+ throw new Error("state({op:'importDisk'}): disk images are a C64 feature (VICE). " +
203
+ `The loaded platform is '${host.status.platform}'.`);
204
+ }
205
+ const resolved = resolveStatePath(inPath, host);
206
+ const blob = new Uint8Array(await readFile(resolved));
207
+ if (blob.length !== 174848) {
208
+ throw new Error(`state({op:'importDisk'}): '${resolved}' is ${blob.length} bytes — not a ` +
209
+ `standard 174848-byte 35-track .d64. Only that format round-trips through the live drive.`);
210
+ }
211
+ const n = host.importDiskImage(blob, unit);
212
+ return {
213
+ importedDisk: true,
214
+ path: resolved,
215
+ ...(resolved !== inPath ? { resolvedPath: resolved } : {}),
216
+ bytes: n,
217
+ unit,
218
+ note: "Wrote the .d64 into the running C64's mounted disk. The game sees it on its next " +
219
+ "disk access (a load/menu). Use this to inject a save disk a player made elsewhere.",
220
+ };
221
+ }
222
+
223
+ /** op:'putDiskFile' — write ONE PRG file into the LIVE mounted C64 disk (inject a save). */
224
+ async function putDiskFileCore({ path: inPath, name, unit = 8 }, sessionKey) {
225
+ const host = getHost(sessionKey);
226
+ if (!host.diskImageSupported || !host.diskImageSupported()) {
227
+ throw new Error("state({op:'putDiskFile'}): disk files are a C64 feature (VICE). " +
228
+ `The loaded platform is '${host.status.platform}'.`);
229
+ }
230
+ if (!inPath) throw new Error("state({op:'putDiskFile'}): `path` (the file to write) is required.");
231
+ const resolved = resolveStatePath(inPath, host);
232
+ const blob = new Uint8Array(await readFile(resolved));
233
+ // file name on disk: explicit `name`, else the source basename (sans extension), uppercased
234
+ const fname = (name || path.basename(resolved).replace(/\.[^.]+$/, ""))
235
+ .toUpperCase().replace(/[^A-Z0-9 ]/g, "").slice(0, 16) || "FILE";
236
+ host.putDiskFile(fname, blob, unit);
237
+ return {
238
+ wroteDiskFile: true,
239
+ name: fname,
240
+ path: resolved,
241
+ bytes: blob.length,
242
+ unit,
243
+ note: "Wrote one PRG file into the running C64's mounted disk via the drive. Read the " +
244
+ "whole disk back with state({op:'exportDisk', path}) or cart({op:'extract'}).",
245
+ };
246
+ }
247
+
82
248
  /** op:'dump' — raw libretro blob to disk for forensic inspection (+ optional findHex). */
83
249
  async function dumpStateCore({ path: outPath, findHex, maxMatches = 32 }, sessionKey) {
84
250
  const host = getHost(sessionKey);
@@ -159,9 +325,10 @@ export function registerStateTools(server, z, sessionKey) {
159
325
  "'list': named in-memory slots. 'diff': whole-machine 'did ANYTHING change?' (coarser than memory diff) — " +
160
326
  "snapOrDiff:'snapshot' captures, 'diff' compares.",
161
327
  {
162
- op: z.enum(["save", "load", "list", "export", "dump", "diff"]).describe("save/load a state; list slots; export a slot to disk; dump the raw blob; diff the whole machine."),
163
- name: z.string().min(1).optional().describe("op=save/load: in-memory slot name. op=diff: snapshot label (default 'default')."),
164
- path: z.string().optional().describe("op=save: also write the blob here (survives restarts). op=load: restore from this disk blob. op=export/dump: write the blob here (required)."),
328
+ op: z.enum(["save", "load", "list", "export", "dump", "diff", "exportSram", "importSram", "exportDisk", "importDisk", "putDiskFile"]).describe("save/load a savestate (whole machine); list slots; export a slot to disk; dump the raw blob; diff the whole machine. SRAM (the cartridge BATTERY SAVE FILE, distinct from a savestate): exportSram writes the .sav, importSram loads one back. C64 DISK (VICE; the C64 save medium is a floppy, not battery SRAM): exportDisk writes the live .d64, importDisk pushes a .d64 back into the running drive, putDiskFile injects one PRG file into the live disk."),
329
+ name: z.string().min(1).optional().describe("op=save/load: in-memory slot name. op=diff: snapshot label (default 'default'). op=putDiskFile: file name on the disk (≤16 chars; default = source basename)."),
330
+ unit: z.number().int().min(8).max(11).default(8).describe("op=exportDisk/importDisk/putDiskFile (C64): drive unit (default 8)."),
331
+ path: z.string().optional().describe("op=save: also write the blob here (survives restarts). op=load: restore from this disk blob. op=export/dump: write the blob here (required). A RELATIVE path resolves against the loaded ROM's directory (NOT the server CWD); an absolute path is used as-is. The result echoes `resolvedPath` when they differ."),
165
332
  // load
166
333
  render: z.boolean().default(true).describe("op=load: step one frame after restoring so the framebuffer reflects it (fixes the stale-screenshot footgun). false = stay at the exact restored instant."),
167
334
  // export
@@ -190,6 +357,26 @@ export function registerStateTools(server, z, sessionKey) {
190
357
  if (!args.snapOrDiff) throw new Error("state({op:'diff'}): `snapOrDiff` ('snapshot' or 'diff') is required.");
191
358
  return jsonContent(diffStateCore(args, sessionKey));
192
359
  }
360
+ case "exportSram": {
361
+ if (!args.path) throw new Error("state({op:'exportSram'}): `path` (where to write the .sav) is required.");
362
+ return jsonContent(await exportSramCore(args, sessionKey));
363
+ }
364
+ case "importSram": {
365
+ if (!args.path) throw new Error("state({op:'importSram'}): `path` (the .sav to load) is required.");
366
+ return jsonContent(await importSramCore(args, sessionKey));
367
+ }
368
+ case "exportDisk": {
369
+ if (!args.path) throw new Error("state({op:'exportDisk'}): `path` (where to write the .d64) is required.");
370
+ return jsonContent(await exportDiskCore(args, sessionKey));
371
+ }
372
+ case "importDisk": {
373
+ if (!args.path) throw new Error("state({op:'importDisk'}): `path` (the .d64 to load) is required.");
374
+ return jsonContent(await importDiskCore(args, sessionKey));
375
+ }
376
+ case "putDiskFile": {
377
+ if (!args.path) throw new Error("state({op:'putDiskFile'}): `path` (the PRG file to inject) is required.");
378
+ return jsonContent(await putDiskFileCore(args, sessionKey));
379
+ }
193
380
  default: throw new Error(`state: unknown op '${args.op}'`);
194
381
  }
195
382
  }),
@@ -8,7 +8,7 @@
8
8
  // always reports `source: "file" | "emulator"` so the caller knows which.
9
9
 
10
10
  import { readFile } from "node:fs/promises";
11
- import { getHostOrNull, getHost } from "../state.js";
11
+ import { getHost } from "../state.js";
12
12
  import { jsonContent, imageContent, textContent, safeTool } from "../util.js";
13
13
  import { inspectPatternTilesCore } from "./platform-tools.js";
14
14
  import { extractSpriteSheetCore } from "./rom-id.js";