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
@@ -43,6 +43,21 @@ const PLATFORM_CORE_OPTIONS = {
43
43
  // `… - C-BIOS` machine tree ships in romdev-core-bluemsx/bios and is mirrored
44
44
  // into the wasm FS as the system dir (see loadMedia + resolveSystemDir).
45
45
  msx: { bluemsx_msxtype: "MSX2+ - C-BIOS" },
46
+ // VICE mounts a .d64/.tap/.crt but, with autostart off, just sits at the BASIC
47
+ // `READY.` prompt — the agent would see a blue boot screen, not the game. Force
48
+ // autostart so a disk/tape image runs the first program automatically (same as
49
+ // typing LOAD"*",8,1 : RUN). warp-during-autostart skips the slow 1541 load so
50
+ // the game is up in a fraction of the wall-clock. A bare .prg is injected and
51
+ // run directly by the core regardless of this; it matters for disk/tape/cart.
52
+ c64: {
53
+ vice_autostart: "enabled",
54
+ vice_autoloadwarp: "enabled",
55
+ vice_warp_boost: "enabled",
56
+ // Leave the mounted disk WRITABLE so a save-capable game can write its save
57
+ // files back into the .d64 (VICE updates the in-FS image in place). Without
58
+ // this a game's SAVE silently fails / errors — defeating disk-save support.
59
+ vice_floppy_write_protection: "disabled",
60
+ },
46
61
  };
47
62
 
48
63
  /**
@@ -188,7 +203,11 @@ export class LibretroHost {
188
203
  async loadMedia(args) {
189
204
  const mod = this._needMod();
190
205
  const { platform } = args;
191
- const mediaKind = args.mediaKind ?? defaultMediaKind(platform);
206
+ // Derive the kind from the file/virtual extension when the caller didn't say
207
+ // — so a C64 .d64 reports mediaKind:"disk" (writable save target) vs a .prg
208
+ // "program". For an in-memory load, the virtualName carries the ext.
209
+ const kindExt = path.extname(args.path || args.virtualName || "");
210
+ const mediaKind = args.mediaKind ?? defaultMediaKind(platform, kindExt);
192
211
 
193
212
  // Apply per-platform core option defaults BEFORE retro_load_game.
194
213
  // Most cores work with their option defaults; a few need explicit
@@ -282,7 +301,20 @@ export class LibretroHost {
282
301
  mod._free(infoPtr);
283
302
 
284
303
  if (!ok) {
285
- throw new Error(`retro_load_game failed for ${mediaPath}`);
304
+ // This is the failure path for EVERY bad/wrong-platform/corrupt/
305
+ // unsupported-mapper image — the most common loadMedia failure. The core
306
+ // returns a bare false, so name the likely causes + the exact checks
307
+ // rather than leaving the agent with "failed".
308
+ throw new Error(
309
+ `The '${platform}' core REFUSED this ${mediaKind || "media"} ` +
310
+ `(${data.length} bytes${ext ? `, ${ext}` : ""}, path ${mediaPath}). ` +
311
+ `retro_load_game returned false — the bytes reached the core but it would not accept them. ` +
312
+ `Common causes: (1) wrong platform for this file (a GB ROM loaded as 'nes', etc.) — ` +
313
+ `confirm the platform matches the file; (2) a corrupt or TRUNCATED image — re-check the byte length; ` +
314
+ `(3) an unsupported mapper/board or a missing/!bad header. ` +
315
+ `Inspect the file with cart({op:'identify'}) to see what platform/mapper it really is, ` +
316
+ `then load with the matching platform.`,
317
+ );
286
318
  }
287
319
 
288
320
  this.status.platform = platform;
@@ -417,7 +449,7 @@ export class LibretroHost {
417
449
  */
418
450
  stepFrames(n) {
419
451
  const mod = this._needMod();
420
- if (!this.status.loaded) throw new Error("no media loaded");
452
+ this._needMedia();
421
453
  if (this.status.paused) return 0;
422
454
  for (let i = 0; i < n; i++) {
423
455
  mod._retro_run();
@@ -436,7 +468,7 @@ export class LibretroHost {
436
468
  * Returns the frame count after. */
437
469
  renderOneFrame() {
438
470
  const mod = this._needMod();
439
- if (!this.status.loaded) throw new Error("no media loaded");
471
+ this._needMedia();
440
472
  mod._retro_run();
441
473
  this.status.frameCount++;
442
474
  if (this.state.lastFrame) {
@@ -575,7 +607,7 @@ export class LibretroHost {
575
607
  /** @param {string} name */
576
608
  loadState(name) {
577
609
  const snapshot = this.namedStates.get(name);
578
- if (!snapshot) throw new Error(`no save state named '${name}'`);
610
+ if (!snapshot) throw new Error(this._noStateError(name));
579
611
  return this.unserializeState(snapshot); // returns # cheats cleared
580
612
  }
581
613
 
@@ -622,10 +654,21 @@ export class LibretroHost {
622
654
  * disturbing the live host). Throws if the slot doesn't exist. */
623
655
  getStateBlob(name) {
624
656
  const blob = this.namedStates.get(name);
625
- if (!blob) throw new Error(`no save state named '${name}'`);
657
+ if (!blob) throw new Error(this._noStateError(name));
626
658
  return blob;
627
659
  }
628
660
 
661
+ /** Build a "no save state named X" error that lists the slots that DO exist
662
+ * (or says there are none) and names the op to create one. */
663
+ _noStateError(name) {
664
+ const names = [...this.namedStates.keys()];
665
+ return names.length
666
+ ? `No save state named '${name}'. Existing in-memory slots: ${names.map((n) => `'${n}'`).join(", ")}. ` +
667
+ `(List them with state({op:'list'}); create one with state({op:'save', name}).)`
668
+ : `No save state named '${name}' — this session has NO in-memory save slots yet. ` +
669
+ `Create one with state({op:'save', name:'${name}'}) first (or load from disk with state({op:'load', path})).`;
670
+ }
671
+
629
672
  /**
630
673
  * @param {import("./types.js").MemoryRegion} region
631
674
  * @param {number} offset
@@ -648,7 +691,7 @@ export class LibretroHost {
648
691
  readMemory(region, offset, length) {
649
692
  const mod = this._needMod();
650
693
  const id = MemoryRegionToRetro[region];
651
- if (id === undefined) throw new Error(`unknown memory region '${region}'`);
694
+ if (id === undefined) throw new Error(this._unknownRegionError(region));
652
695
  const ptr = mod._retro_get_memory_data(id);
653
696
  const size = mod._retro_get_memory_size(id);
654
697
  if (!ptr || !size) throw new Error(this._emptyRegionError(region));
@@ -666,7 +709,7 @@ export class LibretroHost {
666
709
  writeMemory(region, offset, bytes) {
667
710
  const mod = this._needMod();
668
711
  const id = MemoryRegionToRetro[region];
669
- if (id === undefined) throw new Error(`unknown memory region '${region}'`);
712
+ if (id === undefined) throw new Error(this._unknownRegionError(region));
670
713
  const ptr = mod._retro_get_memory_data(id);
671
714
  const size = mod._retro_get_memory_size(id);
672
715
  if (!ptr || !size) throw new Error(this._emptyRegionError(region));
@@ -676,6 +719,89 @@ export class LibretroHost {
676
719
  mod.HEAPU8.set(bytes, ptr + offset);
677
720
  }
678
721
 
722
+ // ── C64 disk image read/write (VICE) ───────────────────────────────────────
723
+ // The VICE WASM core takes content in-memory and exposes no disk memory region,
724
+ // so these go through dedicated core exports that read/write the LIVE mounted
725
+ // 1541 disk_image_t directly (see scripts/patches/vice-romdev-memory-regions.patch).
726
+ // Only the standard 35-track 1541 .d64 (174848 bytes) is supported. unit 8.
727
+
728
+ /** True if the loaded core exposes the romdev disk read/write exports. */
729
+ diskImageSupported() {
730
+ const mod = this.mod;
731
+ return !!(mod && typeof mod._romdev_disk_export === "function"
732
+ && typeof mod._romdev_disk_import === "function");
733
+ }
734
+
735
+ /**
736
+ * Read the LIVE mounted disk image out as a flat .d64.
737
+ * @param {number} [unit] drive unit (default 8)
738
+ * @returns {Uint8Array} the .d64 bytes (copied out of WASM memory)
739
+ */
740
+ exportDiskImage(unit = 8) {
741
+ const mod = this._needMod();
742
+ if (typeof mod._romdev_disk_export !== "function") {
743
+ throw new Error("this core build does not expose disk export (C64/VICE only).");
744
+ }
745
+ const len = mod._romdev_disk_export(unit >>> 0, 0) >>> 0;
746
+ if (!len) {
747
+ throw new Error("no disk image mounted on this unit, or it is not a 35-track .d64. " +
748
+ "Load a .d64 with loadMedia({platform:'c64', path}) first.");
749
+ }
750
+ const ptr = mod._romdev_disk_ptr();
751
+ // copy out — the core buffer is reused on the next export
752
+ return mod.HEAPU8.slice(ptr, ptr + len);
753
+ }
754
+
755
+ /**
756
+ * Write a flat .d64 back into the LIVE mounted disk image (sector by sector).
757
+ * @param {Uint8Array} bytes a 174848-byte .d64
758
+ * @param {number} [unit] drive unit (default 8)
759
+ * @returns {number} bytes written
760
+ */
761
+ importDiskImage(bytes, unit = 8) {
762
+ const mod = this._needMod();
763
+ if (typeof mod._romdev_disk_import !== "function") {
764
+ throw new Error("this core build does not expose disk import (C64/VICE only).");
765
+ }
766
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
767
+ const ptr = mod._malloc(data.length);
768
+ try {
769
+ mod.HEAPU8.set(data, ptr);
770
+ const n = mod._romdev_disk_import(ptr, data.length >>> 0, unit >>> 0, 0) >>> 0;
771
+ if (!n) throw new Error("disk import failed — no writable .d64 mounted on this unit.");
772
+ return n;
773
+ } finally {
774
+ mod._free(ptr);
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Write ONE PRG file (name + bytes incl. its 2-byte load address) straight into
780
+ * the LIVE mounted disk via the vdrive — the "inject a save" primitive.
781
+ * @param {string} name file name (PETSCII, ≤16 chars)
782
+ * @param {Uint8Array} bytes the PRG file bytes (load address + body)
783
+ * @param {number} [unit] drive unit (default 8)
784
+ */
785
+ putDiskFile(name, bytes, unit = 8) {
786
+ const mod = this._needMod();
787
+ if (typeof mod._romdev_disk_putfile !== "function") {
788
+ throw new Error("this core build does not expose disk putfile (C64/VICE only).");
789
+ }
790
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
791
+ const nameBytes = Buffer.from(String(name) + "\0", "latin1");
792
+ const namePtr = mod._malloc(nameBytes.length);
793
+ const dataPtr = mod._malloc(data.length || 1);
794
+ try {
795
+ mod.HEAPU8.set(nameBytes, namePtr);
796
+ mod.HEAPU8.set(data, dataPtr);
797
+ const rc = mod._romdev_disk_putfile(unit >>> 0, namePtr, dataPtr, data.length >>> 0);
798
+ if (rc !== 0) throw new Error(`disk putfile failed (rc=${rc}) — no writable .d64 mounted, or the disk is full.`);
799
+ } finally {
800
+ mod._free(namePtr);
801
+ mod._free(dataPtr);
802
+ }
803
+ }
804
+
679
805
  reset() {
680
806
  const mod = this._needMod();
681
807
  mod._retro_reset();
@@ -956,7 +1082,7 @@ export class LibretroHost {
956
1082
 
957
1083
  runUntilPC(address, maxFrames = 600) {
958
1084
  this._needMod();
959
- if (!this.status.loaded) throw new Error("no media loaded");
1085
+ this._needMedia();
960
1086
  if (!this.pcBreakSupported()) {
961
1087
  throw new Error("PC breakpoint not supported by this core (Genesis today; other cores as patched).");
962
1088
  }
@@ -988,7 +1114,7 @@ export class LibretroHost {
988
1114
  */
989
1115
  runUntilRead(address, maxFrames = 600) {
990
1116
  this._needMod();
991
- if (!this.status.loaded) throw new Error("no media loaded");
1117
+ this._needMedia();
992
1118
  if (!this.readWatchSupported()) {
993
1119
  throw new Error("read watchpoint not supported by this core (Genesis today; other cores as patched).");
994
1120
  }
@@ -1020,7 +1146,7 @@ export class LibretroHost {
1020
1146
  */
1021
1147
  stepInstruction() {
1022
1148
  this._needMod();
1023
- if (!this.status.loaded) throw new Error("no media loaded");
1149
+ this._needMedia();
1024
1150
  if (!this.pcBreakSupported()) {
1025
1151
  throw new Error("single-step not supported by this core (Genesis today; other cores as patched).");
1026
1152
  }
@@ -1088,8 +1214,8 @@ export class LibretroHost {
1088
1214
  * @param {(host:LibretroHost)=>any} [a.capture] read result from core RAM BEFORE restore
1089
1215
  */
1090
1216
  callSubroutine(a) {
1091
- const mod = this._needMod();
1092
- if (!this.status.loaded) throw new Error("no media loaded");
1217
+ this._needMod();
1218
+ this._needMedia();
1093
1219
  if (!this.setRegSupported()) {
1094
1220
  throw new Error("cpu({op:'call'}) not supported by this core (rebuild with romdev_setreg/romdev_getreg).");
1095
1221
  }
@@ -1325,7 +1451,7 @@ export class LibretroHost {
1325
1451
  */
1326
1452
  watchRange(lo, hi, mode, frames) {
1327
1453
  const mod = this._needMod();
1328
- if (!this.status.loaded) throw new Error("no media loaded");
1454
+ this._needMedia();
1329
1455
  if (!this.rangeWatchSupported()) throw new Error("range watch not supported by this core.");
1330
1456
  const m = mode === "read" ? 1 : mode === "write" ? 2 : 3;
1331
1457
  mod._romdev_range_set(lo >>> 0, hi >>> 0, m, 1);
@@ -1358,7 +1484,7 @@ export class LibretroHost {
1358
1484
  */
1359
1485
  logPCRange(lo, hi, frames) {
1360
1486
  const mod = this._needMod();
1361
- if (!this.status.loaded) throw new Error("no media loaded");
1487
+ this._needMedia();
1362
1488
  if (!this.rangeWatchSupported()) throw new Error("coverage trace not supported by this core.");
1363
1489
  mod._romdev_cov_set(lo >>> 0, hi >>> 0, 1);
1364
1490
  this._runFramesExclusive(() => false, frames);
@@ -1398,7 +1524,7 @@ export class LibretroHost {
1398
1524
  */
1399
1525
  watchDma(frames) {
1400
1526
  const mod = this._needMod();
1401
- if (!this.status.loaded) throw new Error("no media loaded");
1527
+ this._needMedia();
1402
1528
  if (!this.dmaWatchSupported()) throw new Error("VDP-DMA watch not supported by this core (Genesis only).");
1403
1529
  mod._romdev_dmawatch_set(1);
1404
1530
  this._runFramesExclusive(() => false, frames);
@@ -1436,6 +1562,21 @@ export class LibretroHost {
1436
1562
  return this.mod;
1437
1563
  }
1438
1564
 
1565
+ // Guard for every op that needs a loaded game. The bare "no media loaded"
1566
+ // left the agent guessing; this names the fix (loadMedia) and the gotcha
1567
+ // (emulator state is in-memory, so a reconnect/restart drops it). The richer
1568
+ // session-aware recovery (echoing the exact prior loadMedia call) lives at the
1569
+ // tool layer in state.js getHost(); this is the host-level twin.
1570
+ _needMedia() {
1571
+ if (!this.status.loaded) {
1572
+ throw new Error(
1573
+ "No media loaded — call loadMedia({platform, path}) before this op. " +
1574
+ "(If you DID load and hit this after a reconnect/restart, the host's in-memory " +
1575
+ "state didn't survive — re-run loadMedia with your ROM to pick back up.)",
1576
+ );
1577
+ }
1578
+ }
1579
+
1439
1580
  /**
1440
1581
  * Build a friendly error message when a memory region is empty (the
1441
1582
  * core didn't expose it). Includes per-platform suggestions when we
@@ -1446,8 +1587,42 @@ export class LibretroHost {
1446
1587
  * "my VRAM writes are being optimized away" spiral — when in fact
1447
1588
  * gambatte exposes VRAM as `gb_vram`, not the generic id.
1448
1589
  */
1590
+ _unknownRegionError(region) {
1591
+ // A bad region name should never leave the agent guessing — list the valid
1592
+ // ones (the single source of truth, MemoryRegionToRetro) so it can pick the
1593
+ // right one. The cross-platform names (system_ram / video_ram / save_ram)
1594
+ // exist everywhere; the rest are platform-specific.
1595
+ const valid = Object.keys(MemoryRegionToRetro).sort();
1596
+ const common = valid.filter((r) => ["system_ram", "video_ram", "save_ram", "rom"].includes(r));
1597
+ return (
1598
+ `Unknown memory region '${region}'. ` +
1599
+ `Common (most platforms): ${common.join(", ")}. ` +
1600
+ `All registered region names: ${valid.join(", ")}. ` +
1601
+ `(Region availability is per platform — some names only resolve on the platform that has that hardware.)`
1602
+ );
1603
+ }
1604
+
1449
1605
  _emptyRegionError(region) {
1450
1606
  const plat = this.status && this.status.platform;
1607
+ // SRAM gets an honest, specific answer: empty save_ram almost always means
1608
+ // "this cart/system has no battery save," NOT "the core is broken."
1609
+ if (region === "save_ram") {
1610
+ if (["atari2600", "atari7800", "lynx"].includes(plat)) {
1611
+ return `'${plat}' has no cartridge battery saves (the hardware never supported them) — ` +
1612
+ `save_ram is always empty here, there's no save file to read/write.`;
1613
+ }
1614
+ if (plat === "c64") {
1615
+ return `C64 has no cartridge battery SRAM — the C64 save medium is the FLOPPY (.d64), ` +
1616
+ `not save_ram (so save_ram is empty, as expected). A game's own KERNAL SAVE writes ` +
1617
+ `into the live disk; capture it with state({op:'exportDisk', path}) (the .d64 then ` +
1618
+ `includes the saved file, re-loadable to resume). Inject an outside save with ` +
1619
+ `state({op:'importDisk', path}) or state({op:'putDiskFile', path}).`;
1620
+ }
1621
+ return `save_ram is empty on platform '${plat}': this CART has no battery save ` +
1622
+ `(check cart({op:'identify'}).saveRam.hasBattery — many ROMs use passwords or no save). ` +
1623
+ `If you expected a save, confirm the cart header marks it battery-backed. ` +
1624
+ `For a full-machine snapshot regardless of SRAM, use state({op:'save'/'load', path}).`;
1625
+ }
1451
1626
  const suggestions = {
1452
1627
  // platform → { generic-region-name: "use this instead" }
1453
1628
  gb: { video_ram: "gb_vram", save_ram: "save_ram (likely empty on cartless ROMs — try gb_oam / gb_io / gb_hram for non-VRAM state)" },
@@ -310,10 +310,18 @@ function handleEnv(mod, state, rawCmd, dataPtr, log) {
310
310
  const semi = desc.indexOf("; ");
311
311
  if (semi >= 0) {
312
312
  const options = desc.substring(semi + 2).split("|");
313
+ // PRESERVE a value the host already pre-seeded (e.g. via
314
+ // PLATFORM_CORE_OPTIONS, set before retro_load_game). The core
315
+ // registering its variables must NOT clobber that override back to
316
+ // its own default (options[0]) — that silently reset forced options
317
+ // like bluemsx's machine type / cart mapper. Keep the prior value if
318
+ // it's still a valid option; otherwise fall back to the default.
319
+ const prior = state.coreVariables.get(key);
320
+ const keep = prior && options.includes(prior.value) ? prior.value : options[0];
313
321
  state.coreVariables.set(key, {
314
322
  description: desc.substring(0, semi),
315
323
  options,
316
- value: options[0],
324
+ value: keep,
317
325
  });
318
326
  }
319
327
  ptr += 8;
@@ -24,6 +24,7 @@ let lastSettings = "";
24
24
  // I had BLOCK=1, ASCII=2; real BLOCK=0x8, ASCII=0x4000. The bad
25
25
  // values silently picked an unrelated tag, which is why "ascii"
26
26
  // mode was still rendering Unicode block glyphs.
27
+ /* eslint-disable no-unused-vars -- the full chafa tag enum is kept for reference; not all are used. */
27
28
  const TAG_SPACE = 0x1;
28
29
  const TAG_SOLID = 0x2;
29
30
  const TAG_STIPPLE = 0x4;
@@ -35,6 +36,7 @@ const TAG_BRAILLE = 0x800;
35
36
  const TAG_ASCII = 0x4000;
36
37
  const TAG_SEXTANT = 0x400000;
37
38
  const TAG_OCTANT = 0x4000000;
39
+ /* eslint-enable no-unused-vars */
38
40
 
39
41
  const SYMBOL_TAGS = {
40
42
  // Pure ASCII glyphs (space + printable 7-bit) — most text-shaped,
@@ -102,8 +102,8 @@ export function decodeSnes9xDSP(state) {
102
102
  bufLastSamples.push(u >= 0x8000 ? u - 0x10000 : u);
103
103
  }
104
104
  p += 24;
105
- const interpPos = state[p] | (state[p + 1] << 8); p += 2;
106
- const brrAddr = state[p] | (state[p + 1] << 8); p += 2;
105
+ p += 2; // interpPos (decoded field, not surfaced)
106
+ p += 2; // brrAddr (decoded field, not surfaced)
107
107
  const env = state[p] | (state[p + 1] << 8); p += 2;
108
108
  const hiddenEnvU = state[p] | (state[p + 1] << 8); p += 2;
109
109
  const hiddenEnv = hiddenEnvU >= 0x8000 ? hiddenEnvU - 0x10000 : hiddenEnvU;
@@ -17,7 +17,11 @@ const formatCpuState = (s) => s;
17
17
  // entry is 5 × 4 = 20 bytes, so memory_map takes 256 × 20 = 5120 bytes.
18
18
  // After that come the fields we want.
19
19
  const M68K_BASE = 5120; // start of cpu_idle_t poll
20
+ // M68K_POLL / M68K_CYCLES document the struct layout (consumed implicitly by the
21
+ // next offset) — keep them named even though nothing reads them directly.
22
+ // eslint-disable-next-line no-unused-vars
20
23
  const M68K_POLL = M68K_BASE + 0; // 12 bytes
24
+ // eslint-disable-next-line no-unused-vars
21
25
  const M68K_CYCLES = M68K_BASE + 12; // 12 bytes (cycles + refresh_cycles + cycle_end)
22
26
  const M68K_DAR = M68K_BASE + 24; // uint dar[16] — D0..D7 then A0..A7
23
27
  const M68K_PC = M68K_DAR + 64; // uint pc
package/src/host/types.js CHANGED
@@ -223,18 +223,25 @@ export const MemoryRegionToRetro = {
223
223
  */
224
224
 
225
225
  /**
226
- * Default mediaKind for a platform when caller doesn't specify.
227
- * Consoles default to cartridge; C64 defaults to program (`.prg`).
226
+ * Default mediaKind for a platform when caller doesn't specify. Consoles default
227
+ * to cartridge; C64 depends on the file kind — a `.d64`/`.g64`/`.d71`/`.d81` is a
228
+ * disk, a `.tap` is a tape, a `.crt` is a cartridge, and a bare `.prg`/`.p00` is
229
+ * a program injected directly. The extension is passed when known (loadMedia has
230
+ * it) so disk/tape images report honestly in status() and the agent knows a
231
+ * writable disk exists for saves.
228
232
  * @param {string} platform
233
+ * @param {string} [ext] lower- or mixed-case file extension incl. dot, e.g. ".d64"
229
234
  * @returns {MediaKind}
230
235
  */
231
- export function defaultMediaKind(platform) {
232
- switch (platform) {
233
- case "c64":
234
- return "program";
235
- default:
236
- return "cartridge";
236
+ export function defaultMediaKind(platform, ext) {
237
+ if (platform === "c64") {
238
+ const e = (ext || "").toLowerCase();
239
+ if (/\.(d64|g64|d71|d81|d80|d82|nib)$/.test(e)) return "disk";
240
+ if (/\.(tap|t64)$/.test(e)) return "tape";
241
+ if (/\.(crt|bin)$/.test(e)) return "cartridge";
242
+ return "program"; // .prg / .p00 / unknown → injected program
237
243
  }
244
+ return "cartridge";
238
245
  }
239
246
 
240
247
  /**
@@ -18,7 +18,7 @@
18
18
  // (the app already mounts localhostHostValidation()).
19
19
 
20
20
  import { buildToolRegistry, runTool, toolJsonSchema } from "./tool-registry.js";
21
- import { skillPreamble, skillToolReference, buildSkillDoc } from "./skill-doc.js";
21
+ import { buildSkillDoc } from "./skill-doc.js";
22
22
  import { swaggerHtml, swaggerAsset } from "./swagger.js";
23
23
  import { observer } from "../observer/bus.js";
24
24
  import { log } from "../mcp/log.js";
@@ -128,7 +128,32 @@ export async function runTool(tool, args, sessionKey) {
128
128
  emit({ ok: false, error: text });
129
129
  return { ok: false, error: text };
130
130
  }
131
- const images = extractImages(r);
131
+ // Observer sidebands — identical to the MCP observer middleware (tool-wrap.js),
132
+ // because the HTTP path runs handlers directly and would otherwise drop them:
133
+ // • _observerImages — a frame the tool wrote to DISK instead of returning
134
+ // inline (e.g. screenshot({path:...})); surface it to /livestream anyway.
135
+ // • _observerFrameProvider — a DEFERRED framebuffer thunk (frame({op:'verify'}),
136
+ // watch/breakpoint tools): the tool advanced/looked at the emulator but
137
+ // returns JSON-only to the caller. We encode the PNG ASYNC (setImmediate,
138
+ // after the HTTP response goes out) and push it as a `call_frame` event so
139
+ // the human's livestream sees the frame at zero cost to the caller.
140
+ // Strip both from the caller-visible result before it's serialized.
141
+ let sidebandImages = [];
142
+ let frameProvider = null;
143
+ if (r && typeof r === "object") {
144
+ if (Array.isArray(r._observerImages)) { sidebandImages = r._observerImages; delete r._observerImages; }
145
+ if (typeof r._observerFrameProvider === "function") { frameProvider = r._observerFrameProvider; delete r._observerFrameProvider; }
146
+ }
147
+ if (frameProvider) {
148
+ setImmediate(() => {
149
+ try {
150
+ const img = frameProvider();
151
+ if (img) observer.push({ type: "call_frame", sessionKey: sessionKey ?? "http", ts: startedAt, tool: tool.name, images: [img] });
152
+ } catch { /* livestream is best-effort; never affects the caller */ }
153
+ });
154
+ }
155
+ const inlineImages = extractImages(r);
156
+ const images = inlineImages.length > 0 ? inlineImages : sidebandImages;
132
157
  const text = r?.content?.[0]?.text;
133
158
  if (typeof text === "string") {
134
159
  // most tools return jsonContent(...) → text is JSON; parse it back so the
package/src/mcp/server.js CHANGED
@@ -140,7 +140,7 @@ async function main() {
140
140
  // in verbose mode (log.debug handles that split). This keeps the console
141
141
  // quiet in prod while /log stays rich enough to diagnose what an agent did.
142
142
  if (req.method === "POST" && req.body) {
143
- const { method, id, params } = req.body;
143
+ const { method, params } = req.body;
144
144
  if (method === "tools/call") {
145
145
  const argKeys = params?.arguments ? Object.keys(params.arguments) : [];
146
146
  const summary = argKeys.map((k) => {
package/src/mcp/state.js CHANGED
@@ -14,6 +14,24 @@ import { LibretroHost } from "../host/index.js";
14
14
  /** @type {Map<string, LibretroHost>} */
15
15
  const hosts = new Map();
16
16
 
17
+ // What this session last loaded, kept OUTSIDE the host map so it SURVIVES a
18
+ // host eviction (server restart / session reconnect / unload). The host itself
19
+ // is gone in those cases, so the "No ROM loaded" error has nothing to read —
20
+ // this is the breadcrumb that lets the error tell the agent exactly how to
21
+ // recover ("you last loaded <X>; re-run loadMedia to pick back up") instead of
22
+ // a generic wipe. Set by loadMedia on success; never cleared on eviction.
23
+ /** @type {Map<string, {platform?: string, path?: string, fromBase64?: boolean}>} */
24
+ const lastMedia = new Map();
25
+
26
+ /** Record the media a session last loaded (for recovery hints). @param {string} sessionKey */
27
+ export function rememberLastMedia(sessionKey, info) {
28
+ lastMedia.set(sessionKey, info);
29
+ }
30
+ /** @param {string} sessionKey */
31
+ export function getLastMedia(sessionKey) {
32
+ return lastMedia.get(sessionKey) ?? null;
33
+ }
34
+
17
35
  /**
18
36
  * @param {string} sessionKey
19
37
  * @returns {LibretroHost}
@@ -21,6 +39,24 @@ const hosts = new Map();
21
39
  export function getHost(sessionKey) {
22
40
  const host = hosts.get(sessionKey);
23
41
  if (!host) {
42
+ // If THIS session loaded media before, the host was evicted (restart /
43
+ // reconnect / unload) — lead with the exact recovery call instead of the
44
+ // generic "you're in the wrong session" guidance, which doesn't apply here.
45
+ const prev = lastMedia.get(sessionKey);
46
+ if (prev && (prev.path || prev.fromBase64)) {
47
+ const recall = prev.path
48
+ ? `loadMedia({ platform: "${prev.platform}", path: "${prev.path}" })`
49
+ : `loadMedia({ platform: "${prev.platform}", base64: ... }) (your ROM came from base64 — re-supply the bytes)`;
50
+ throw new Error(
51
+ "No ROM loaded in this session — the host was evicted (the server restarted, " +
52
+ "your session reconnected, or the media was unloaded). Emulator state lives in " +
53
+ "server memory only, so it did not survive. RECOVER by re-running your last load:\n " +
54
+ recall +
55
+ "\nThen replay any boot/navigate steps to get back to where you were. " +
56
+ "(If instead you expected a DIFFERENT session, you may be sending an inconsistent " +
57
+ "`x-romdev-session` header — reuse one stable id on every call.)",
58
+ );
59
+ }
24
60
  throw new Error(
25
61
  "No ROM loaded in this session — call loadMedia({path}) first. " +
26
62
  "If you DID loadMedia and still see this, your calls are landing in DIFFERENT " +
@@ -18,7 +18,6 @@
18
18
  // - Future: vasm listings, asar symbol tables
19
19
 
20
20
  import { readFile } from "node:fs/promises";
21
- import { jsonContent, safeTool } from "../util.js";
22
21
  import { parseGnuLdMap, isGnuLdMap } from "../../toolchains/gnu-ld-map.js";
23
22
 
24
23
  function parseSdldStyle(text) {
@@ -407,7 +407,7 @@ function aseCelToRgba(cel, colorDepth, palette) {
407
407
  return rgba;
408
408
  }
409
409
 
410
- async function loadAsepriteSheetImpl({ path: asePath, platform, tile_size = 8, outputDir, slice_strategy = "slices", emit = "raw", emitDefines = false }) {
410
+ async function loadAsepriteSheetImpl({ path: asePath, platform, _tile_size = 8, outputDir, slice_strategy = "slices", emit = "raw", emitDefines = false }) {
411
411
  const buf = await readFile(asePath);
412
412
  const ase = new Aseprite(buf, path.basename(asePath));
413
413
  ase.parse();