romdevtools 0.27.0 → 0.29.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 (199) hide show
  1. package/AGENTS.md +56 -44
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +4 -4
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1227 -325
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +909 -257
  8. package/examples/atari2600/templates/shmup.asm +1035 -218
  9. package/examples/atari2600/templates/sports.asm +1143 -229
  10. package/examples/atari7800/templates/hello_sprite.c +8 -4
  11. package/examples/atari7800/templates/platformer.c +991 -152
  12. package/examples/atari7800/templates/puzzle.c +1091 -145
  13. package/examples/atari7800/templates/racing.c +949 -118
  14. package/examples/atari7800/templates/shmup.c +812 -130
  15. package/examples/atari7800/templates/sports.c +820 -181
  16. package/examples/c64/templates/platformer.c +876 -157
  17. package/examples/c64/templates/puzzle.c +881 -143
  18. package/examples/c64/templates/racing.c +873 -88
  19. package/examples/c64/templates/shmup.c +762 -154
  20. package/examples/c64/templates/sports.c +755 -95
  21. package/examples/gb/templates/platformer.c +841 -175
  22. package/examples/gb/templates/puzzle.c +1094 -176
  23. package/examples/gb/templates/racing.c +761 -169
  24. package/examples/gb/templates/shmup.c +679 -169
  25. package/examples/gb/templates/sports.c +790 -153
  26. package/examples/gba/templates/platformer.c +624 -169
  27. package/examples/gba/templates/puzzle.c +535 -207
  28. package/examples/gba/templates/racing.c +513 -196
  29. package/examples/gba/templates/shmup.c +565 -168
  30. package/examples/gba/templates/sports.c +454 -162
  31. package/examples/gbc/templates/platformer.c +944 -176
  32. package/examples/gbc/templates/puzzle.c +1131 -177
  33. package/examples/gbc/templates/racing.c +891 -175
  34. package/examples/gbc/templates/shmup.c +827 -179
  35. package/examples/gbc/templates/sports.c +870 -156
  36. package/examples/genesis/templates/platformer.c +747 -129
  37. package/examples/genesis/templates/puzzle.c +702 -208
  38. package/examples/genesis/templates/racing.c +728 -193
  39. package/examples/genesis/templates/shmup.c +535 -142
  40. package/examples/genesis/templates/shmup_2p.c +13 -1
  41. package/examples/genesis/templates/sports.c +495 -158
  42. package/examples/gg/templates/platformer.c +883 -214
  43. package/examples/gg/templates/puzzle.c +906 -181
  44. package/examples/gg/templates/racing.c +919 -160
  45. package/examples/gg/templates/shmup.c +716 -177
  46. package/examples/gg/templates/sports.c +735 -128
  47. package/examples/lynx/templates/platformer.c +604 -50
  48. package/examples/lynx/templates/puzzle.c +533 -130
  49. package/examples/lynx/templates/racing.c +538 -102
  50. package/examples/lynx/templates/shmup.c +461 -122
  51. package/examples/lynx/templates/sports.c +496 -69
  52. package/examples/msx/platformer/main.c +648 -159
  53. package/examples/msx/puzzle/main.c +750 -185
  54. package/examples/msx/racing/main.c +669 -177
  55. package/examples/msx/shmup/main.c +460 -177
  56. package/examples/msx/sports/main.c +591 -124
  57. package/examples/nes/templates/platformer.c +586 -160
  58. package/examples/nes/templates/puzzle.c +603 -222
  59. package/examples/nes/templates/racing.c +505 -197
  60. package/examples/nes/templates/shmup.c +339 -144
  61. package/examples/nes/templates/sports.c +341 -182
  62. package/examples/pce/platformer/main.c +875 -204
  63. package/examples/pce/puzzle/main.c +797 -216
  64. package/examples/pce/racing/main.c +782 -206
  65. package/examples/pce/shmup/main.c +638 -211
  66. package/examples/pce/sports/main.c +585 -167
  67. package/examples/porting-across-platforms/README.md +1 -1
  68. package/examples/sms/templates/platformer.c +765 -176
  69. package/examples/sms/templates/puzzle.c +783 -177
  70. package/examples/sms/templates/racing.c +812 -133
  71. package/examples/sms/templates/shmup.c +601 -148
  72. package/examples/sms/templates/shmup_2p.c +17 -1
  73. package/examples/sms/templates/sports.c +633 -121
  74. package/examples/snes/templates/music_demo.c +7 -0
  75. package/examples/snes/templates/platformer-data.asm +123 -24
  76. package/examples/snes/templates/platformer-hdr.asm +57 -0
  77. package/examples/snes/templates/platformer.c +587 -149
  78. package/examples/snes/templates/puzzle-data.asm +116 -21
  79. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  80. package/examples/snes/templates/puzzle.c +632 -185
  81. package/examples/snes/templates/racing-data.asm +390 -32
  82. package/examples/snes/templates/racing-hdr.asm +57 -0
  83. package/examples/snes/templates/racing.c +807 -177
  84. package/examples/snes/templates/shmup-data.asm +87 -29
  85. package/examples/snes/templates/shmup-hdr.asm +57 -0
  86. package/examples/snes/templates/shmup.c +459 -180
  87. package/examples/snes/templates/sports-data.asm +48 -2
  88. package/examples/snes/templates/sports-hdr.asm +57 -0
  89. package/examples/snes/templates/sports.c +414 -156
  90. package/package.json +12 -12
  91. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  92. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  93. package/src/cores/wasm/fceumm_libretro.js +1 -1
  94. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  95. package/src/cores/wasm/gambatte_libretro.js +1 -1
  96. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  97. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  98. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  99. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  100. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  101. package/src/cores/wasm/handy_libretro.js +1 -1
  102. package/src/cores/wasm/handy_libretro.wasm +0 -0
  103. package/src/cores/wasm/mgba_libretro.js +1 -1
  104. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  105. package/src/cores/wasm/prosystem_libretro.js +1 -1
  106. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  107. package/src/cores/wasm/snes9x_libretro.js +1 -1
  108. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  109. package/src/cores/wasm/stella2014_libretro.js +1 -1
  110. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  111. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  112. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  113. package/src/host/LibretroHost.js +304 -11
  114. package/src/http/tool-registry.js +11 -11
  115. package/src/mcp/server.js +6 -0
  116. package/src/mcp/tools/cheats.js +2 -1
  117. package/src/mcp/tools/disasm-rebuild.js +315 -65
  118. package/src/mcp/tools/disasm.js +149 -28
  119. package/src/mcp/tools/find-references.js +216 -51
  120. package/src/mcp/tools/frame.js +14 -6
  121. package/src/mcp/tools/index.js +18 -4
  122. package/src/mcp/tools/input.js +31 -7
  123. package/src/mcp/tools/lifecycle.js +6 -4
  124. package/src/mcp/tools/memory.js +208 -39
  125. package/src/mcp/tools/platform-docs.js +1 -1
  126. package/src/mcp/tools/playtest.js +56 -4
  127. package/src/mcp/tools/preview-tile.js +6 -2
  128. package/src/mcp/tools/project.js +1114 -120
  129. package/src/mcp/tools/rom-id.js +5 -1
  130. package/src/mcp/tools/run-until.js +4 -2
  131. package/src/mcp/tools/snippets.js +6 -6
  132. package/src/mcp/tools/sprite-pipeline.js +14 -2
  133. package/src/mcp/tools/state.js +2 -1
  134. package/src/mcp/tools/tile-inspect.js +8 -1
  135. package/src/mcp/tools/toolchain.js +55 -11
  136. package/src/mcp/tools/watch-memory.js +145 -27
  137. package/src/observer/bus.js +73 -0
  138. package/src/observer/livestream.html +4 -2
  139. package/src/observer/tool-wrap.js +17 -14
  140. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  141. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  142. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  143. package/src/platforms/atari7800/MENTAL_MODEL.md +32 -11
  144. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  145. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  146. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  147. package/src/platforms/gb/MENTAL_MODEL.md +19 -4
  148. package/src/platforms/gb/TROUBLESHOOTING.md +101 -6
  149. package/src/platforms/gb/lib/c/README.md +10 -11
  150. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  151. package/src/platforms/gb/lib/c/patch-header.js +19 -6
  152. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  153. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  154. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  155. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  156. package/src/platforms/gbc/MENTAL_MODEL.md +16 -4
  157. package/src/platforms/gbc/TROUBLESHOOTING.md +24 -3
  158. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  159. package/src/platforms/gbc/lib/c/README.md +10 -11
  160. package/src/platforms/gbc/lib/c/font.h +43 -0
  161. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  162. package/src/platforms/gbc/lib/c/patch-header.js +19 -6
  163. package/src/platforms/genesis/MENTAL_MODEL.md +43 -9
  164. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  165. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  166. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  167. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  168. package/src/platforms/gg/TROUBLESHOOTING.md +14 -18
  169. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  170. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  171. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  172. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  173. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  174. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  175. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  176. package/src/platforms/msx/MENTAL_MODEL.md +11 -5
  177. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  178. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  179. package/src/platforms/msx/lib/c/msx_hw.h +3 -0
  180. package/src/platforms/msx/lib/c/msx_vdp.c +70 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +12 -5
  182. package/src/platforms/nes/lib/c/nes_runtime.c +190 -34
  183. package/src/platforms/nes/lib/c/nes_runtime.h +35 -0
  184. package/src/platforms/pce/MENTAL_MODEL.md +14 -5
  185. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  186. package/src/platforms/pce/lib/c/pce_hw.h +13 -1
  187. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  188. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  189. package/src/platforms/sms/MENTAL_MODEL.md +11 -6
  190. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  191. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  192. package/src/platforms/snes/MENTAL_MODEL.md +7 -2
  193. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  194. package/src/playtest/playtest.js +73 -3
  195. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  197. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  198. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  199. package/src/toolchains/index.js +64 -19
@@ -43,6 +43,12 @@ 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
+ // geargrafx ships with the TurboTap disabled, which makes port-1 input
47
+ // unreachable in-game (every pad scan slot mirrors pad 0). Enabling it
48
+ // costs nothing for 1P games (slot 0 still reads pad 0) and routes the
49
+ // host's port-1 input to pad slot 2 — PCE 2P works (probed 2026-06-10
50
+ // during the ZENITH BARRAGE gold round).
51
+ pce: { geargrafx_turbotap: "Enabled" },
46
52
  // VICE mounts a .d64/.tap/.crt but, with autostart off, just sits at the BASIC
47
53
  // `READY.` prompt — the agent would see a blue boot screen, not the game. Force
48
54
  // autostart so a disk/tape image runs the first program automatically (same as
@@ -57,6 +63,19 @@ const PLATFORM_CORE_OPTIONS = {
57
63
  // files back into the .d64 (VICE updates the in-FS image in place). Without
58
64
  // this a game's SAVE silently fails / errors — defeating disk-save support.
59
65
  vice_floppy_write_protection: "disabled",
66
+ // TWO live C64 control ports so 2P games see player 2. The VICE core drives
67
+ // ONE control port per RetroPad by default (every retro port → cur_port);
68
+ // the per-port split only happens with the userport adapter, where the
69
+ // mapper does vice_port = cur_port + retro_port. With joyport=1 + a userport
70
+ // adapter that gives retro0→control-port-1, retro1→control-port-2 — BOTH
71
+ // standard ports live. Our games read P1 on control port 2 ($DC00) and P2
72
+ // on port 1 ($DC01); the host swaps the two retro ports below
73
+ // (portInputToMask C64 path) so host port 0 = P1 (control port 2) and host
74
+ // port 1 = P2 (control port 1), matching the universal "port 0 = player 1"
75
+ // convention. Verified: drives both paddles independently in 2P, 1P-vs-CPU
76
+ // still reachable. (Both options ship in the wasm — no core rebuild.)
77
+ vice_joyport: "1",
78
+ vice_userport_joytype: "HIT",
60
79
  },
61
80
  };
62
81
 
@@ -415,6 +434,9 @@ export class LibretroHost {
415
434
 
416
435
  // Configure controller port 0 as joypad (some cores default to NONE).
417
436
  mod._retro_set_controller_port_device(0, RETRO_DEVICE_JOYPAD);
437
+ // Port 1 too — needed for 2P. The C64/VICE 2P path (two live control ports)
438
+ // only reads RetroPad port 1 when it's registered as a joypad device.
439
+ mod._retro_set_controller_port_device(1, RETRO_DEVICE_JOYPAD);
418
440
 
419
441
  // ---- Settle the framebuffer to the ROM's chosen geometry ----
420
442
  //
@@ -623,7 +645,14 @@ export class LibretroHost {
623
645
  this._applyC64ButtonKeys(input.ports[0] || {});
624
646
  }
625
647
  for (let port = 0; port < this.state.inputPorts.length; port++) {
626
- const portInput = this._c64StripKeyButtons(input.ports[port], platform);
648
+ // C64 2P port swap: with joyport=1 + userport the VICE mapper binds
649
+ // RetroPad 0 → C64 control port 1 and RetroPad 1 → control port 2. But
650
+ // our games read player 1 on control port 2 ($DC00) and player 2 on
651
+ // control port 1 ($DC01). So feed host port 0's input to RetroPad slot 1
652
+ // (→ control port 2 = P1) and host port 1's to slot 0 (→ port 1 = P2),
653
+ // restoring the universal "host port 0 = player 1" convention.
654
+ const srcPort = platform === "c64" ? (port ^ 1) : port;
655
+ const portInput = this._c64StripKeyButtons(input.ports[srcPort], platform);
627
656
  this.state.inputPorts[port][0] = portInputToMask(portInput, platform);
628
657
  }
629
658
  }
@@ -776,6 +805,18 @@ export class LibretroHost {
776
805
  return mod._retro_get_memory_size(id) || 0;
777
806
  }
778
807
 
808
+ /** gpgx stores 68k work RAM as 16-bit words in host-LE order — the CPU's
809
+ * byte at $FF0000+A physically lives at work_ram[A^1] (core/macros.h
810
+ * READ_BYTE/WRITE_BYTE on little-endian builds). Normalize the system_ram
811
+ * region to CPU byte order here so offset X IS the byte the 68k sees at
812
+ * $FF0000+X — otherwise every byte-granular tool (search, diff, write,
813
+ * classify) is off-by-XOR-1 vs disassembly addresses and cheat-DB maps
814
+ * (self-consistent within raw-only loops, which is why it hid; poisonous
815
+ * the moment an address crosses to/from the CPU view). */
816
+ _byteSwapRegion(region) {
817
+ return region === "system_ram" && this.status.platform === "genesis";
818
+ }
819
+
779
820
  readMemory(region, offset, length) {
780
821
  const mod = this._needMod();
781
822
  const id = MemoryRegionToRetro[region];
@@ -786,6 +827,12 @@ export class LibretroHost {
786
827
  if (offset < 0 || offset + length > size) {
787
828
  throw new RangeError(`read out of bounds: offset=${offset} len=${length} size=${size}`);
788
829
  }
830
+ if (this._byteSwapRegion(region)) {
831
+ const heap = mod.HEAPU8;
832
+ const out = new Uint8Array(length);
833
+ for (let i = 0; i < length; i++) out[i] = heap[ptr + ((offset + i) ^ 1)];
834
+ return out;
835
+ }
789
836
  return new Uint8Array(mod.HEAPU8.buffer, ptr + offset, length).slice();
790
837
  }
791
838
 
@@ -804,6 +851,11 @@ export class LibretroHost {
804
851
  if (offset < 0 || offset + bytes.length > size) {
805
852
  throw new RangeError(`write out of bounds: offset=${offset} len=${bytes.length} size=${size}`);
806
853
  }
854
+ if (this._byteSwapRegion(region)) {
855
+ const heap = mod.HEAPU8;
856
+ for (let i = 0; i < bytes.length; i++) heap[ptr + ((offset + i) ^ 1)] = bytes[i];
857
+ return;
858
+ }
807
859
  mod.HEAPU8.set(bytes, ptr + offset);
808
860
  }
809
861
 
@@ -1072,7 +1124,36 @@ export class LibretroHost {
1072
1124
  this.reset();
1073
1125
  return false;
1074
1126
  }
1127
+ // Battery semantics: SAVE_RAM survives a power-cycle on a battery cart.
1128
+ // Carry it across the reload (the reload itself zeroes it).
1129
+ let sram = null;
1130
+ try {
1131
+ const size = this.regionSize("save_ram");
1132
+ if (size > 0) sram = Uint8Array.from(this.readMemory("save_ram", 0, size));
1133
+ } catch { /* no save_ram region on this core/cart — nothing to carry */ }
1075
1134
  await this.loadMedia(this._loadArgs);
1135
+ if (sram && sram.some((b) => b !== 0)) {
1136
+ // Restore like a frontend restores the .srm: bytes in place BEFORE the
1137
+ // game's boot code reads them. Some cores size SAVE_RAM lazily (gpgx
1138
+ // scans for the last non-empty byte → size 0 on a fresh boot), so fall
1139
+ // back to the raw region pointer when the sized path refuses.
1140
+ let restored = false;
1141
+ try {
1142
+ if (this.regionSize("save_ram") >= sram.length) {
1143
+ this.writeMemory("save_ram", 0, sram);
1144
+ restored = true;
1145
+ }
1146
+ } catch { /* sized path unavailable */ }
1147
+ if (!restored) {
1148
+ try {
1149
+ const ptr = this.mod._retro_get_memory_data(0); // RETRO_MEMORY_SAVE_RAM
1150
+ if (ptr) { this.mod.HEAPU8.set(sram, ptr); restored = true; }
1151
+ } catch { /* no save buffer on this core/cart */ }
1152
+ }
1153
+ // loadMedia's settle frames may already have run the game's
1154
+ // hi-score load against empty SRAM — soft-reset so boot re-reads.
1155
+ if (restored) { try { this.reset(); } catch { /* keep the loaded state */ } }
1156
+ }
1076
1157
  return true;
1077
1158
  }
1078
1159
 
@@ -1263,6 +1344,161 @@ export class LibretroHost {
1263
1344
  return !!(this.mod && typeof this.mod._romdev_watchdog_set === "function");
1264
1345
  }
1265
1346
 
1347
+ /** True when this core build exposes the at-hit register snapshot (gpgx). */
1348
+ regSnapSupported() {
1349
+ return !!(this.mod && typeof this.mod._romdev_regsnap_get === "function");
1350
+ }
1351
+
1352
+ /**
1353
+ * Read the at-hit register snapshot: the FULL register file frozen by the
1354
+ * core hook at the instant a pc-break / watchdog / write-watch / read-watch
1355
+ * fired. The live register file keeps running after a hit (per-scanline CPU
1356
+ * scheduling / next-frame re-entry), so post-hit register reads drift —
1357
+ * this snapshot is the truth. Shipped by ALL patched cores (all 14
1358
+ * platforms). Returns { kind, named } or null when no hit has been
1359
+ * snapshotted (or the core build predates the export). kind:
1360
+ * 1=pc-break/step, 2=watchdog, 3=write-watch, 4=read-watch. `named` keys
1361
+ * follow each CPU's own register file; `pc` is always the EXECUTING
1362
+ * instruction (ARM: its pipeline PC, the same convention breakpoint
1363
+ * addresses use). Pass clear to reset the kind.
1364
+ */
1365
+ getRegSnapshot(clear = false) {
1366
+ const mod = this.mod;
1367
+ if (!mod || typeof mod._romdev_regsnap_get !== "function") return null;
1368
+ const ptr = mod._malloc(21 * 4);
1369
+ try {
1370
+ mod._romdev_regsnap_get(ptr, clear ? 1 : 0);
1371
+ const u = new Uint32Array(mod.HEAPU8.buffer, ptr, 21);
1372
+ const kind = u[0];
1373
+ if (!kind) return null;
1374
+ const r = Array.from(u.subarray(2, 2 + Math.min(u[1] >>> 0, 19)));
1375
+ const platform = this.status.platform;
1376
+ const h2 = (v) => "$" + (v & 0xFF).toString(16).toUpperCase();
1377
+ const h4 = (v) => "$" + (v & 0xFFFF).toString(16).toUpperCase();
1378
+ const hx = (v) => "$" + (v >>> 0).toString(16).toUpperCase();
1379
+ let named;
1380
+ if (platform === "genesis") {
1381
+ // m68k regId order: D0-7, A0-7, PC(instr start), SR, SP.
1382
+ named = {};
1383
+ for (let i = 0; i < 8; i++) named["d" + i] = hx(r[i]);
1384
+ for (let i = 0; i < 8; i++) named["a" + i] = hx(r[8 + i]);
1385
+ named.pc = hx(r[16]);
1386
+ named.sr = h4(r[17]);
1387
+ named.sp = hx(r[18]);
1388
+ } else if (platform === "gba") {
1389
+ // ARM regId order: r0-r15 raw, CPSR at 16, instr pipeline PC at 17, SP at 18.
1390
+ named = {};
1391
+ for (let i = 0; i < 16; i++) named["r" + i] = hx(r[i]);
1392
+ named.cpsr = hx(r[16]);
1393
+ named.pc = hx(r[17]); // EXECUTING instruction's pipeline PC (pc-break convention)
1394
+ named.sp = hx(r[18]);
1395
+ } else if (platform === "snes") {
1396
+ // 65816 regId order: A, X, Y, P, S, DB, D, …, PBPC(instr start).
1397
+ named = {
1398
+ a: h4(r[0]), x: h4(r[1]), y: h4(r[2]), p: h4(r[3]), s: h4(r[4]),
1399
+ db: h2(r[5]), d: h4(r[6]), pc: hx(r[16]),
1400
+ };
1401
+ } else if (platform === "gb" || platform === "gbc") {
1402
+ named = {
1403
+ a: h2(r[0]), f: h2(r[1]), b: h2(r[2]), c: h2(r[3]),
1404
+ d: h2(r[4]), e: h2(r[5]), h: h2(r[6]), l: h2(r[7]),
1405
+ pc: h4(r[16]), sp: h4(r[18]),
1406
+ };
1407
+ } else if (platform === "sms" || platform === "gg" || platform === "msx") {
1408
+ named = {
1409
+ a: h2(r[0]), f: h2(r[1]), b: h2(r[2]), c: h2(r[3]),
1410
+ d: h2(r[4]), e: h2(r[5]), h: h2(r[6]), l: h2(r[7]),
1411
+ ix: h4(r[8]), iy: h4(r[9]), pc: h4(r[16]), sp: h4(r[18]),
1412
+ };
1413
+ } else {
1414
+ // 6502 family (nes, atari2600, atari7800, c64, lynx, pce/huc6280):
1415
+ // regId order A, X, Y, P, S, …, PC(instr start).
1416
+ named = {
1417
+ a: h2(r[0]), x: h2(r[1]), y: h2(r[2]), p: h2(r[3]), s: h2(r[4]),
1418
+ pc: h4(r[16]),
1419
+ };
1420
+ }
1421
+ return { kind, named };
1422
+ } finally {
1423
+ mod._free(ptr);
1424
+ }
1425
+ }
1426
+
1427
+ /** True when this core build exposes the pure-CPU run (gpgx). */
1428
+ runPureSupported() {
1429
+ return !!(this.mod && typeof this.mod._romdev_run_pure === "function");
1430
+ }
1431
+
1432
+ /** True when this core build exposes the interrupt block (the pure-call
1433
+ * primitive on cores without a separable CPU loop). */
1434
+ irqBlockSupported() {
1435
+ return !!(this.mod && typeof this.mod._romdev_irqblock_set === "function");
1436
+ }
1437
+
1438
+ /** Suppress (or restore) interrupt DELIVERY to the active CPU. While
1439
+ * blocked, pending IRQ/NMI lines stay pending and no game handler can run
1440
+ * — the mechanism behind pure calls on cores whose CPU/video loops are
1441
+ * interleaved (everything except gpgx, which steps the CPU alone). */
1442
+ setIrqBlock(on) {
1443
+ const mod = this._needMod();
1444
+ if (typeof mod._romdev_irqblock_set !== "function") {
1445
+ throw new Error("this core build does not expose the interrupt block (rebuild with romdev_irqblock_set).");
1446
+ }
1447
+ mod._romdev_irqblock_set(on ? 1 : 0);
1448
+ }
1449
+
1450
+ /** True when a pure call is possible on this platform by ANY mechanism:
1451
+ * a separable CPU run (gpgx), an interrupt block, or hardware with no
1452
+ * interrupts at all (the 2600's 6507 has no IRQ/NMI lines wired — every
1453
+ * call is inherently pure). */
1454
+ pureCallSupported() {
1455
+ return this.runPureSupported() || this.irqBlockSupported() || this.status.platform === "atari2600";
1456
+ }
1457
+
1458
+ /** True when this core build exposes the VRAM-port copy trace (port-based
1459
+ * video memory: NES/SNES/PCE/MSX/SMS/GG/Genesis). Direct-mapped platforms
1460
+ * answer the same question through watchRange on the CPU-visible VRAM. */
1461
+ vramWatchSupported() {
1462
+ const mod = this.mod;
1463
+ return !!(mod && typeof mod._romdev_vramwatch_set === "function" && typeof mod._romdev_vramwatch_get === "function");
1464
+ }
1465
+
1466
+ /**
1467
+ * Run `frames` frames logging every data-port write landing in the VRAM
1468
+ * address window [lo,hi] — {vramAddr, pc, value} per event, pc being the
1469
+ * EXECUTING instruction (during DMA on SNES, the instruction that triggered
1470
+ * it). The "where does this graphic come from?" primitive for port-based
1471
+ * video memory. Returns { events, total, stored, truncated }.
1472
+ */
1473
+ watchVram(lo, hi, frames, perFrame) {
1474
+ const mod = this._needMod();
1475
+ this._needMedia();
1476
+ if (!this.vramWatchSupported()) throw new Error("VRAM copy trace not supported by this core.");
1477
+ mod._romdev_vramwatch_set(lo >>> 0, hi >>> 0, 1);
1478
+ try {
1479
+ this._runFramesExclusive(perFrame ?? (() => false), frames);
1480
+ } finally {
1481
+ // drained below; disarm after
1482
+ }
1483
+ const CAP = 1024;
1484
+ const outPtr = mod._malloc(CAP * 3 * 4);
1485
+ const out2Ptr = mod._malloc(8);
1486
+ try {
1487
+ const n = mod._romdev_vramwatch_get(outPtr, CAP, out2Ptr);
1488
+ const u = new Uint32Array(mod.HEAPU8.buffer, outPtr, n * 3);
1489
+ const u2 = new Uint32Array(mod.HEAPU8.buffer, out2Ptr, 2);
1490
+ const events = [];
1491
+ for (let i = 0; i < n; i++) {
1492
+ events.push({ vramAddr: u[i * 3], pc: u[i * 3 + 1], value: u[i * 3 + 2] & 0xFF });
1493
+ }
1494
+ return { events, total: u2[0], stored: u2[1], truncated: u2[0] > u2[1] };
1495
+ } finally {
1496
+ mod._free(outPtr);
1497
+ mod._free(out2Ptr);
1498
+ mod._romdev_vramwatch_set(0, 0, 0);
1499
+ }
1500
+ }
1501
+
1266
1502
  /** Arm/disarm the read watchpoint on a CPU address. */
1267
1503
  setReadWatch(address, enabled = true) {
1268
1504
  const mod = this._needMod();
@@ -1488,6 +1724,13 @@ export class LibretroHost {
1488
1724
  const {
1489
1725
  pc, regs = {}, spReg = prof.spReg, pcReg = prof.pcReg, sentinelPC = prof.defaultSentinel,
1490
1726
  sentinelBytes = prof.retBytes, maxFrames = 600, sandbox = true, capture,
1727
+ // pure: step ONLY the active CPU (no frame machinery — VDP lines, co-CPU,
1728
+ // interrupt raising). Without it, each "frame" of the call runs the
1729
+ // game's OWN per-frame logic concurrently (VBlank handlers via RAM
1730
+ // vectors etc.), which can stomp the buffer the driven routine is
1731
+ // writing — a real session diffed a CORRECT codec reimplementation
1732
+ // against that poisoned output for hours. gpgx (Genesis/SMS/GG) only.
1733
+ pure = false,
1491
1734
  // presetMemory: [{addr, bytes}] CPU-space writes applied before the call
1492
1735
  // (codecs that read a global from RAM — a dest stride, a mode flag, etc).
1493
1736
  presetMemory = [],
@@ -1523,6 +1766,7 @@ export class LibretroHost {
1523
1766
 
1524
1767
  const snapshot = sandbox ? this.serializeState() : null;
1525
1768
  let captured, returned = false, framesRun = 0, watchdogTripped = false, stoppedAtPC = false;
1769
+ let pureMode = null;
1526
1770
  try {
1527
1771
  // Apply pre-call memory writes (CPU-space).
1528
1772
  for (const m of presetMemory) {
@@ -1575,19 +1819,67 @@ export class LibretroHost {
1575
1819
  const target = (stopAtPC !== undefined ? stopAtPC : sentinelPC) >>> 0;
1576
1820
  this.setPCBreak(target, true, false);
1577
1821
  let finalState = null;
1822
+ let irqBlocked = false;
1578
1823
  try {
1579
- framesRun = this._runFramesExclusive(() => {
1580
- const st = this.getPCBreak(false);
1581
- if (st.hit) {
1582
- finalState = st;
1583
- if (st.watchdog) watchdogTripped = true;
1584
- else if (stopAtPC !== undefined) stoppedAtPC = true;
1585
- else returned = true;
1586
- return true;
1824
+ if (pure && this.runPureSupported()) {
1825
+ // STRONGEST pure mode (gpgx): step ONLY the CPU — no frame machinery
1826
+ // at all. Mask m68k interrupts so a PENDING VINT raised before the
1827
+ // call can't redirect entry (no NEW interrupts are raised — the
1828
+ // system loop never runs). The sandbox restore (or the game's own
1829
+ // RTE discipline) makes the IPL change invisible afterward.
1830
+ pureMode = "cpu-only";
1831
+ if (this.status.platform === "genesis") {
1832
+ this.setReg(17, (this.getReg(17) | 0x0700) & 0xFFFF);
1587
1833
  }
1588
- return false;
1589
- }, maxFrames);
1834
+ // Drive the CPU in cycle chunks, checking the sentinel/watchdog
1835
+ // between chunks. The watchdog bounds total instructions; the chunk
1836
+ // cap is only a backstop against a watchdog-less older core.
1837
+ const CHUNK_CYCLES = 1_000_000;
1838
+ const maxChunks = 512;
1839
+ for (let i = 0; i < maxChunks; i++) {
1840
+ this.mod._romdev_run_pure(CHUNK_CYCLES);
1841
+ const st = this.getPCBreak(false);
1842
+ if (st.hit) {
1843
+ finalState = st;
1844
+ if (st.watchdog) watchdogTripped = true;
1845
+ else if (stopAtPC !== undefined) stoppedAtPC = true;
1846
+ else returned = true;
1847
+ break;
1848
+ }
1849
+ }
1850
+ } else {
1851
+ if (pure) {
1852
+ // INTERRUPT-BLOCKED pure mode (every other core): the frame
1853
+ // machinery still runs (video/timers advance — harmless, they
1854
+ // don't write game RAM), but interrupt DELIVERY is suppressed, so
1855
+ // no game handler can execute. The only running game code is the
1856
+ // routine we called — the same guarantee that matters for the
1857
+ // output buffer. The 2600's 6507 has no interrupt lines at all,
1858
+ // so every call there is pure by hardware.
1859
+ if (this.irqBlockSupported()) {
1860
+ this.setIrqBlock(true);
1861
+ irqBlocked = true;
1862
+ pureMode = "irq-blocked";
1863
+ } else if (this.status.platform === "atari2600") {
1864
+ pureMode = "no-interrupts";
1865
+ } else {
1866
+ throw new Error("cpu({op:'call', pure:true}) not supported by this core build (needs the romdev_irqblock_set export — update the core package).");
1867
+ }
1868
+ }
1869
+ framesRun = this._runFramesExclusive(() => {
1870
+ const st = this.getPCBreak(false);
1871
+ if (st.hit) {
1872
+ finalState = st;
1873
+ if (st.watchdog) watchdogTripped = true;
1874
+ else if (stopAtPC !== undefined) stoppedAtPC = true;
1875
+ else returned = true;
1876
+ return true;
1877
+ }
1878
+ return false;
1879
+ }, maxFrames);
1880
+ }
1590
1881
  } finally {
1882
+ if (irqBlocked) { try { this.setIrqBlock(false); } catch { /* core gone */ } }
1591
1883
  this.setPCBreak(0, false, false);
1592
1884
  this.setWatchdog(0);
1593
1885
  if (!finalState) finalState = this.getPCBreak(true); else this.getPCBreak(true);
@@ -1607,6 +1899,7 @@ export class LibretroHost {
1607
1899
  const fin = this._lastCallResult || {};
1608
1900
  return {
1609
1901
  returned, framesRun,
1902
+ ...(pure ? { pure: true, pureMode } : {}),
1610
1903
  ...(watchdogTripped ? { watchdog: true, reason: "watchdog: hit the instruction budget (likely a runaway loop — wrong A0/regs, a needed preset, or legitimately huge; raise maxInstructions or check the entry setup)" } : {}),
1611
1904
  ...(stoppedAtPC ? { stoppedAtPC: "$" + (stopAtPC >>> 0).toString(16).toUpperCase() } : {}),
1612
1905
  ...(fin.finalPC != null ? { finalPC: "$" + fin.finalPC.toString(16).toUpperCase(), finalPCRaw: fin.finalPC } : {}),
@@ -17,7 +17,7 @@
17
17
  import { z } from "zod";
18
18
  import { registerTools } from "../mcp/tools/index.js";
19
19
  import { withClearToolErrors } from "../mcp/util.js";
20
- import { observer, summarizeForLog, extractImages } from "../observer/bus.js";
20
+ import { observer, summarizeForLog, extractImages, pushObserverFrame } from "../observer/bus.js";
21
21
  import { getHostOrNull } from "../mcp/state.js";
22
22
 
23
23
  /**
@@ -151,21 +151,21 @@ export async function runTool(tool, args, sessionKey) {
151
151
  // Strip both from the caller-visible result before it's serialized.
152
152
  let sidebandImages = [];
153
153
  let frameProvider = null;
154
+ let frameCaption = null;
154
155
  if (r && typeof r === "object") {
155
156
  if (Array.isArray(r._observerImages)) { sidebandImages = r._observerImages; delete r._observerImages; }
156
157
  if (typeof r._observerFrameProvider === "function") { frameProvider = r._observerFrameProvider; delete r._observerFrameProvider; }
158
+ if (typeof r._observerFrameCaption === "string") { frameCaption = r._observerFrameCaption; delete r._observerFrameCaption; }
157
159
  }
158
160
  if (frameProvider) {
159
- setImmediate(() => {
160
- try {
161
- const img = frameProvider();
162
- // re-resolve platform: a call like loadMedia sets it DURING the call, so
163
- // the post-call value is the most accurate for the frame's system label.
164
- let framePlatform = platform;
165
- try { framePlatform = getHostOrNull(sessionKey)?.status?.platform ?? platform; } catch { /* keep */ }
166
- if (img) observer.push({ type: "call_frame", sessionKey: sessionKey ?? "http", platform: framePlatform, ts: startedAt, tool: tool.name, images: [img] });
167
- } catch { /* livestream is best-effort; never affects the caller */ }
168
- });
161
+ // Throttled to one per 2s per (session, tool), trailing-edge — same
162
+ // policy as the MCP path (bus.js pushObserverFrame). Platform is
163
+ // re-resolved at emit time (loadMedia sets it DURING the call).
164
+ pushObserverFrame({
165
+ sessionKey, tool: tool.name, ts: startedAt, platform,
166
+ resolvePlatform: () => { try { return getHostOrNull(sessionKey)?.status?.platform ?? platform; } catch { return platform; } },
167
+ ...(frameCaption ? { caption: frameCaption } : {}),
168
+ }, frameProvider);
169
169
  }
170
170
  const inlineImages = extractImages(r);
171
171
  const images = inlineImages.length > 0 ? inlineImages : sidebandImages;
package/src/mcp/server.js CHANGED
@@ -418,6 +418,12 @@ async function main() {
418
418
  log.info(`optional observer: http://${bannerHost}:${port}/livestream`);
419
419
  log.info("");
420
420
  log.info("connect your coding agent: https://github.com/monteslu/romdev#connect");
421
+ // One conditional line so an agent knows the constraint BEFORE promising a
422
+ // playtest window to a human (the op itself still errors with the full fix).
423
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
424
+ log.info("");
425
+ log.info("note: no display detected (headless) — playtest({op:'open'}) is unavailable; all other tools work.");
426
+ }
421
427
  });
422
428
  const extraServers = [];
423
429
  for (const h of bindHosts.slice(1)) {
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { getHost } from "../state.js";
4
4
  import { jsonContent, safeTool } from "../util.js";
5
+ import { attachObserverFrame } from "./watch-memory.js";
5
6
  import { lookupCheats, searchCheatGames } from "../../cheats/lookup.js";
6
7
  import { encodeForDevice, nativeDevicesFor, decodeCode } from "../../cheats/gamegenie.js";
7
8
 
@@ -334,7 +335,7 @@ export function registerCheatTools(server, z, sessionKey) {
334
335
  switch (args.op) {
335
336
  case "lookup": return jsonContent(await cheatsLookupCore(args));
336
337
  case "search": return jsonContent(await cheatsSearchCore(args));
337
- case "apply": return jsonContent(await cheatsApplyCore(args, sessionKey));
338
+ case "apply": return attachObserverFrame(jsonContent(await cheatsApplyCore(args, sessionKey)), getHost(sessionKey), "cheat applied");
338
339
  case "clear": return jsonContent(await cheatsClearCore(args, sessionKey));
339
340
  case "make": return jsonContent(await cheatsMakeCore(args));
340
341
  default: throw new Error(`cheats: unknown op '${args.op}'`);