romdevtools 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +309 -0
  3. package/README.md +1 -1
  4. package/examples/README.md +1 -1
  5. package/examples/atari2600/templates/platformer.asm +18 -9
  6. package/examples/atari2600/templates/racing.asm +25 -4
  7. package/examples/atari2600/templates/shmup.asm +30 -5
  8. package/examples/atari2600/templates/sports.asm +41 -9
  9. package/examples/atari7800/templates/hello_sprite.c +8 -4
  10. package/examples/atari7800/templates/platformer.c +12 -8
  11. package/examples/atari7800/templates/puzzle.c +7 -4
  12. package/examples/atari7800/templates/racing.c +5 -2
  13. package/examples/atari7800/templates/shmup.c +8 -4
  14. package/examples/atari7800/templates/sports.c +6 -3
  15. package/examples/c64/templates/platformer.c +28 -24
  16. package/examples/c64/templates/puzzle.c +77 -16
  17. package/examples/c64/templates/racing.c +9 -0
  18. package/examples/c64/templates/shmup.c +13 -1
  19. package/examples/c64/templates/sports.c +9 -4
  20. package/examples/gb/templates/platformer.c +6 -2
  21. package/examples/gb/templates/puzzle.c +279 -101
  22. package/examples/gb/templates/racing.c +13 -1
  23. package/examples/gb/templates/shmup.c +13 -1
  24. package/examples/gb/templates/sports.c +9 -3
  25. package/examples/gba/templates/platformer.c +7 -13
  26. package/examples/gba/templates/puzzle.c +93 -15
  27. package/examples/gba/templates/racing.c +13 -1
  28. package/examples/gba/templates/shmup.c +13 -1
  29. package/examples/gba/templates/sports.c +17 -5
  30. package/examples/gbc/templates/platformer.c +6 -2
  31. package/examples/gbc/templates/puzzle.c +878 -178
  32. package/examples/gbc/templates/racing.c +13 -1
  33. package/examples/gbc/templates/shmup.c +13 -1
  34. package/examples/gbc/templates/sports.c +9 -3
  35. package/examples/genesis/templates/puzzle.c +76 -15
  36. package/examples/genesis/templates/racing.c +13 -1
  37. package/examples/genesis/templates/shmup_2p.c +13 -1
  38. package/examples/gg/templates/platformer.c +4 -0
  39. package/examples/gg/templates/puzzle.c +80 -14
  40. package/examples/gg/templates/racing.c +17 -1
  41. package/examples/gg/templates/shmup.c +17 -1
  42. package/examples/gg/templates/sports.c +4 -0
  43. package/examples/lynx/templates/platformer.c +25 -6
  44. package/examples/lynx/templates/puzzle.c +77 -14
  45. package/examples/lynx/templates/shmup.c +13 -1
  46. package/examples/lynx/templates/sports.c +5 -2
  47. package/examples/msx/platformer/main.c +2 -0
  48. package/examples/msx/puzzle/main.c +78 -15
  49. package/examples/msx/racing/main.c +1 -0
  50. package/examples/msx/shmup/main.c +1 -0
  51. package/examples/msx/sports/main.c +3 -2
  52. package/examples/nes/templates/platformer.c +11 -3
  53. package/examples/nes/templates/puzzle.c +81 -21
  54. package/examples/nes/templates/racing.c +15 -1
  55. package/examples/nes/templates/shmup.c +1 -0
  56. package/examples/nes/templates/sports.c +1 -0
  57. package/examples/pce/platformer/main.c +3 -1
  58. package/examples/pce/puzzle/main.c +78 -12
  59. package/examples/pce/racing/main.c +1 -0
  60. package/examples/pce/shmup/main.c +5 -4
  61. package/examples/pce/sports/main.c +4 -3
  62. package/examples/sms/templates/platformer.c +4 -0
  63. package/examples/sms/templates/puzzle.c +80 -14
  64. package/examples/sms/templates/racing.c +17 -1
  65. package/examples/sms/templates/shmup.c +17 -1
  66. package/examples/sms/templates/shmup_2p.c +17 -1
  67. package/examples/sms/templates/sports.c +4 -0
  68. package/examples/snes/templates/platformer.c +32 -15
  69. package/examples/snes/templates/puzzle.c +84 -16
  70. package/examples/snes/templates/racing.c +20 -1
  71. package/examples/snes/templates/shmup.c +20 -2
  72. package/examples/snes/templates/sports.c +7 -0
  73. package/package.json +12 -12
  74. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  75. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  76. package/src/cores/wasm/fceumm_libretro.js +1 -1
  77. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  78. package/src/cores/wasm/gambatte_libretro.js +1 -1
  79. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  80. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  81. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  82. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  83. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  84. package/src/cores/wasm/handy_libretro.js +1 -1
  85. package/src/cores/wasm/handy_libretro.wasm +0 -0
  86. package/src/cores/wasm/mgba_libretro.js +1 -1
  87. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  88. package/src/cores/wasm/prosystem_libretro.js +1 -1
  89. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  90. package/src/cores/wasm/snes9x_libretro.js +1 -1
  91. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  92. package/src/cores/wasm/stella2014_libretro.js +1 -1
  93. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  94. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  95. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  96. package/src/host/LibretroHost.js +245 -10
  97. package/src/mcp/server.js +6 -0
  98. package/src/mcp/tools/disasm-rebuild.js +315 -65
  99. package/src/mcp/tools/disasm.js +149 -28
  100. package/src/mcp/tools/find-references.js +216 -51
  101. package/src/mcp/tools/frame.js +11 -4
  102. package/src/mcp/tools/index.js +15 -1
  103. package/src/mcp/tools/input.js +26 -3
  104. package/src/mcp/tools/memory.js +208 -39
  105. package/src/mcp/tools/playtest.js +56 -4
  106. package/src/mcp/tools/project.js +35 -9
  107. package/src/mcp/tools/toolchain.js +43 -10
  108. package/src/mcp/tools/watch-memory.js +141 -24
  109. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  110. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  111. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  112. package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
  113. package/src/platforms/gb/MENTAL_MODEL.md +16 -1
  114. package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
  115. package/src/platforms/gb/lib/c/patch-header.js +7 -4
  116. package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
  117. package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
  118. package/src/platforms/gbc/lib/c/font.h +43 -0
  119. package/src/platforms/gbc/lib/c/patch-header.js +7 -4
  120. package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
  121. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  122. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  123. package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
  124. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  125. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  126. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  127. package/src/platforms/msx/MENTAL_MODEL.md +6 -0
  128. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  129. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  130. package/src/platforms/msx/lib/c/msx_hw.h +2 -0
  131. package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
  132. package/src/platforms/nes/MENTAL_MODEL.md +10 -3
  133. package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
  134. package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
  135. package/src/platforms/pce/MENTAL_MODEL.md +9 -0
  136. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  137. package/src/platforms/pce/lib/c/pce_hw.h +2 -1
  138. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  139. package/src/platforms/sms/MENTAL_MODEL.md +5 -0
  140. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  141. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  142. package/src/platforms/snes/MENTAL_MODEL.md +5 -0
  143. package/src/playtest/playtest.js +73 -3
  144. package/src/toolchains/index.js +37 -8
@@ -776,6 +776,18 @@ export class LibretroHost {
776
776
  return mod._retro_get_memory_size(id) || 0;
777
777
  }
778
778
 
779
+ /** gpgx stores 68k work RAM as 16-bit words in host-LE order — the CPU's
780
+ * byte at $FF0000+A physically lives at work_ram[A^1] (core/macros.h
781
+ * READ_BYTE/WRITE_BYTE on little-endian builds). Normalize the system_ram
782
+ * region to CPU byte order here so offset X IS the byte the 68k sees at
783
+ * $FF0000+X — otherwise every byte-granular tool (search, diff, write,
784
+ * classify) is off-by-XOR-1 vs disassembly addresses and cheat-DB maps
785
+ * (self-consistent within raw-only loops, which is why it hid; poisonous
786
+ * the moment an address crosses to/from the CPU view). */
787
+ _byteSwapRegion(region) {
788
+ return region === "system_ram" && this.status.platform === "genesis";
789
+ }
790
+
779
791
  readMemory(region, offset, length) {
780
792
  const mod = this._needMod();
781
793
  const id = MemoryRegionToRetro[region];
@@ -786,6 +798,12 @@ export class LibretroHost {
786
798
  if (offset < 0 || offset + length > size) {
787
799
  throw new RangeError(`read out of bounds: offset=${offset} len=${length} size=${size}`);
788
800
  }
801
+ if (this._byteSwapRegion(region)) {
802
+ const heap = mod.HEAPU8;
803
+ const out = new Uint8Array(length);
804
+ for (let i = 0; i < length; i++) out[i] = heap[ptr + ((offset + i) ^ 1)];
805
+ return out;
806
+ }
789
807
  return new Uint8Array(mod.HEAPU8.buffer, ptr + offset, length).slice();
790
808
  }
791
809
 
@@ -804,6 +822,11 @@ export class LibretroHost {
804
822
  if (offset < 0 || offset + bytes.length > size) {
805
823
  throw new RangeError(`write out of bounds: offset=${offset} len=${bytes.length} size=${size}`);
806
824
  }
825
+ if (this._byteSwapRegion(region)) {
826
+ const heap = mod.HEAPU8;
827
+ for (let i = 0; i < bytes.length; i++) heap[ptr + ((offset + i) ^ 1)] = bytes[i];
828
+ return;
829
+ }
807
830
  mod.HEAPU8.set(bytes, ptr + offset);
808
831
  }
809
832
 
@@ -1263,6 +1286,161 @@ export class LibretroHost {
1263
1286
  return !!(this.mod && typeof this.mod._romdev_watchdog_set === "function");
1264
1287
  }
1265
1288
 
1289
+ /** True when this core build exposes the at-hit register snapshot (gpgx). */
1290
+ regSnapSupported() {
1291
+ return !!(this.mod && typeof this.mod._romdev_regsnap_get === "function");
1292
+ }
1293
+
1294
+ /**
1295
+ * Read the at-hit register snapshot: the FULL register file frozen by the
1296
+ * core hook at the instant a pc-break / watchdog / write-watch / read-watch
1297
+ * fired. The live register file keeps running after a hit (per-scanline CPU
1298
+ * scheduling / next-frame re-entry), so post-hit register reads drift —
1299
+ * this snapshot is the truth. Shipped by ALL patched cores (all 14
1300
+ * platforms). Returns { kind, named } or null when no hit has been
1301
+ * snapshotted (or the core build predates the export). kind:
1302
+ * 1=pc-break/step, 2=watchdog, 3=write-watch, 4=read-watch. `named` keys
1303
+ * follow each CPU's own register file; `pc` is always the EXECUTING
1304
+ * instruction (ARM: its pipeline PC, the same convention breakpoint
1305
+ * addresses use). Pass clear to reset the kind.
1306
+ */
1307
+ getRegSnapshot(clear = false) {
1308
+ const mod = this.mod;
1309
+ if (!mod || typeof mod._romdev_regsnap_get !== "function") return null;
1310
+ const ptr = mod._malloc(21 * 4);
1311
+ try {
1312
+ mod._romdev_regsnap_get(ptr, clear ? 1 : 0);
1313
+ const u = new Uint32Array(mod.HEAPU8.buffer, ptr, 21);
1314
+ const kind = u[0];
1315
+ if (!kind) return null;
1316
+ const r = Array.from(u.subarray(2, 2 + Math.min(u[1] >>> 0, 19)));
1317
+ const platform = this.status.platform;
1318
+ const h2 = (v) => "$" + (v & 0xFF).toString(16).toUpperCase();
1319
+ const h4 = (v) => "$" + (v & 0xFFFF).toString(16).toUpperCase();
1320
+ const hx = (v) => "$" + (v >>> 0).toString(16).toUpperCase();
1321
+ let named;
1322
+ if (platform === "genesis") {
1323
+ // m68k regId order: D0-7, A0-7, PC(instr start), SR, SP.
1324
+ named = {};
1325
+ for (let i = 0; i < 8; i++) named["d" + i] = hx(r[i]);
1326
+ for (let i = 0; i < 8; i++) named["a" + i] = hx(r[8 + i]);
1327
+ named.pc = hx(r[16]);
1328
+ named.sr = h4(r[17]);
1329
+ named.sp = hx(r[18]);
1330
+ } else if (platform === "gba") {
1331
+ // ARM regId order: r0-r15 raw, CPSR at 16, instr pipeline PC at 17, SP at 18.
1332
+ named = {};
1333
+ for (let i = 0; i < 16; i++) named["r" + i] = hx(r[i]);
1334
+ named.cpsr = hx(r[16]);
1335
+ named.pc = hx(r[17]); // EXECUTING instruction's pipeline PC (pc-break convention)
1336
+ named.sp = hx(r[18]);
1337
+ } else if (platform === "snes") {
1338
+ // 65816 regId order: A, X, Y, P, S, DB, D, …, PBPC(instr start).
1339
+ named = {
1340
+ a: h4(r[0]), x: h4(r[1]), y: h4(r[2]), p: h4(r[3]), s: h4(r[4]),
1341
+ db: h2(r[5]), d: h4(r[6]), pc: hx(r[16]),
1342
+ };
1343
+ } else if (platform === "gb" || platform === "gbc") {
1344
+ named = {
1345
+ a: h2(r[0]), f: h2(r[1]), b: h2(r[2]), c: h2(r[3]),
1346
+ d: h2(r[4]), e: h2(r[5]), h: h2(r[6]), l: h2(r[7]),
1347
+ pc: h4(r[16]), sp: h4(r[18]),
1348
+ };
1349
+ } else if (platform === "sms" || platform === "gg" || platform === "msx") {
1350
+ named = {
1351
+ a: h2(r[0]), f: h2(r[1]), b: h2(r[2]), c: h2(r[3]),
1352
+ d: h2(r[4]), e: h2(r[5]), h: h2(r[6]), l: h2(r[7]),
1353
+ ix: h4(r[8]), iy: h4(r[9]), pc: h4(r[16]), sp: h4(r[18]),
1354
+ };
1355
+ } else {
1356
+ // 6502 family (nes, atari2600, atari7800, c64, lynx, pce/huc6280):
1357
+ // regId order A, X, Y, P, S, …, PC(instr start).
1358
+ named = {
1359
+ a: h2(r[0]), x: h2(r[1]), y: h2(r[2]), p: h2(r[3]), s: h2(r[4]),
1360
+ pc: h4(r[16]),
1361
+ };
1362
+ }
1363
+ return { kind, named };
1364
+ } finally {
1365
+ mod._free(ptr);
1366
+ }
1367
+ }
1368
+
1369
+ /** True when this core build exposes the pure-CPU run (gpgx). */
1370
+ runPureSupported() {
1371
+ return !!(this.mod && typeof this.mod._romdev_run_pure === "function");
1372
+ }
1373
+
1374
+ /** True when this core build exposes the interrupt block (the pure-call
1375
+ * primitive on cores without a separable CPU loop). */
1376
+ irqBlockSupported() {
1377
+ return !!(this.mod && typeof this.mod._romdev_irqblock_set === "function");
1378
+ }
1379
+
1380
+ /** Suppress (or restore) interrupt DELIVERY to the active CPU. While
1381
+ * blocked, pending IRQ/NMI lines stay pending and no game handler can run
1382
+ * — the mechanism behind pure calls on cores whose CPU/video loops are
1383
+ * interleaved (everything except gpgx, which steps the CPU alone). */
1384
+ setIrqBlock(on) {
1385
+ const mod = this._needMod();
1386
+ if (typeof mod._romdev_irqblock_set !== "function") {
1387
+ throw new Error("this core build does not expose the interrupt block (rebuild with romdev_irqblock_set).");
1388
+ }
1389
+ mod._romdev_irqblock_set(on ? 1 : 0);
1390
+ }
1391
+
1392
+ /** True when a pure call is possible on this platform by ANY mechanism:
1393
+ * a separable CPU run (gpgx), an interrupt block, or hardware with no
1394
+ * interrupts at all (the 2600's 6507 has no IRQ/NMI lines wired — every
1395
+ * call is inherently pure). */
1396
+ pureCallSupported() {
1397
+ return this.runPureSupported() || this.irqBlockSupported() || this.status.platform === "atari2600";
1398
+ }
1399
+
1400
+ /** True when this core build exposes the VRAM-port copy trace (port-based
1401
+ * video memory: NES/SNES/PCE/MSX/SMS/GG/Genesis). Direct-mapped platforms
1402
+ * answer the same question through watchRange on the CPU-visible VRAM. */
1403
+ vramWatchSupported() {
1404
+ const mod = this.mod;
1405
+ return !!(mod && typeof mod._romdev_vramwatch_set === "function" && typeof mod._romdev_vramwatch_get === "function");
1406
+ }
1407
+
1408
+ /**
1409
+ * Run `frames` frames logging every data-port write landing in the VRAM
1410
+ * address window [lo,hi] — {vramAddr, pc, value} per event, pc being the
1411
+ * EXECUTING instruction (during DMA on SNES, the instruction that triggered
1412
+ * it). The "where does this graphic come from?" primitive for port-based
1413
+ * video memory. Returns { events, total, stored, truncated }.
1414
+ */
1415
+ watchVram(lo, hi, frames, perFrame) {
1416
+ const mod = this._needMod();
1417
+ this._needMedia();
1418
+ if (!this.vramWatchSupported()) throw new Error("VRAM copy trace not supported by this core.");
1419
+ mod._romdev_vramwatch_set(lo >>> 0, hi >>> 0, 1);
1420
+ try {
1421
+ this._runFramesExclusive(perFrame ?? (() => false), frames);
1422
+ } finally {
1423
+ // drained below; disarm after
1424
+ }
1425
+ const CAP = 1024;
1426
+ const outPtr = mod._malloc(CAP * 3 * 4);
1427
+ const out2Ptr = mod._malloc(8);
1428
+ try {
1429
+ const n = mod._romdev_vramwatch_get(outPtr, CAP, out2Ptr);
1430
+ const u = new Uint32Array(mod.HEAPU8.buffer, outPtr, n * 3);
1431
+ const u2 = new Uint32Array(mod.HEAPU8.buffer, out2Ptr, 2);
1432
+ const events = [];
1433
+ for (let i = 0; i < n; i++) {
1434
+ events.push({ vramAddr: u[i * 3], pc: u[i * 3 + 1], value: u[i * 3 + 2] & 0xFF });
1435
+ }
1436
+ return { events, total: u2[0], stored: u2[1], truncated: u2[0] > u2[1] };
1437
+ } finally {
1438
+ mod._free(outPtr);
1439
+ mod._free(out2Ptr);
1440
+ mod._romdev_vramwatch_set(0, 0, 0);
1441
+ }
1442
+ }
1443
+
1266
1444
  /** Arm/disarm the read watchpoint on a CPU address. */
1267
1445
  setReadWatch(address, enabled = true) {
1268
1446
  const mod = this._needMod();
@@ -1488,6 +1666,13 @@ export class LibretroHost {
1488
1666
  const {
1489
1667
  pc, regs = {}, spReg = prof.spReg, pcReg = prof.pcReg, sentinelPC = prof.defaultSentinel,
1490
1668
  sentinelBytes = prof.retBytes, maxFrames = 600, sandbox = true, capture,
1669
+ // pure: step ONLY the active CPU (no frame machinery — VDP lines, co-CPU,
1670
+ // interrupt raising). Without it, each "frame" of the call runs the
1671
+ // game's OWN per-frame logic concurrently (VBlank handlers via RAM
1672
+ // vectors etc.), which can stomp the buffer the driven routine is
1673
+ // writing — a real session diffed a CORRECT codec reimplementation
1674
+ // against that poisoned output for hours. gpgx (Genesis/SMS/GG) only.
1675
+ pure = false,
1491
1676
  // presetMemory: [{addr, bytes}] CPU-space writes applied before the call
1492
1677
  // (codecs that read a global from RAM — a dest stride, a mode flag, etc).
1493
1678
  presetMemory = [],
@@ -1523,6 +1708,7 @@ export class LibretroHost {
1523
1708
 
1524
1709
  const snapshot = sandbox ? this.serializeState() : null;
1525
1710
  let captured, returned = false, framesRun = 0, watchdogTripped = false, stoppedAtPC = false;
1711
+ let pureMode = null;
1526
1712
  try {
1527
1713
  // Apply pre-call memory writes (CPU-space).
1528
1714
  for (const m of presetMemory) {
@@ -1575,19 +1761,67 @@ export class LibretroHost {
1575
1761
  const target = (stopAtPC !== undefined ? stopAtPC : sentinelPC) >>> 0;
1576
1762
  this.setPCBreak(target, true, false);
1577
1763
  let finalState = null;
1764
+ let irqBlocked = false;
1578
1765
  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;
1766
+ if (pure && this.runPureSupported()) {
1767
+ // STRONGEST pure mode (gpgx): step ONLY the CPU — no frame machinery
1768
+ // at all. Mask m68k interrupts so a PENDING VINT raised before the
1769
+ // call can't redirect entry (no NEW interrupts are raised — the
1770
+ // system loop never runs). The sandbox restore (or the game's own
1771
+ // RTE discipline) makes the IPL change invisible afterward.
1772
+ pureMode = "cpu-only";
1773
+ if (this.status.platform === "genesis") {
1774
+ this.setReg(17, (this.getReg(17) | 0x0700) & 0xFFFF);
1587
1775
  }
1588
- return false;
1589
- }, maxFrames);
1776
+ // Drive the CPU in cycle chunks, checking the sentinel/watchdog
1777
+ // between chunks. The watchdog bounds total instructions; the chunk
1778
+ // cap is only a backstop against a watchdog-less older core.
1779
+ const CHUNK_CYCLES = 1_000_000;
1780
+ const maxChunks = 512;
1781
+ for (let i = 0; i < maxChunks; i++) {
1782
+ this.mod._romdev_run_pure(CHUNK_CYCLES);
1783
+ const st = this.getPCBreak(false);
1784
+ if (st.hit) {
1785
+ finalState = st;
1786
+ if (st.watchdog) watchdogTripped = true;
1787
+ else if (stopAtPC !== undefined) stoppedAtPC = true;
1788
+ else returned = true;
1789
+ break;
1790
+ }
1791
+ }
1792
+ } else {
1793
+ if (pure) {
1794
+ // INTERRUPT-BLOCKED pure mode (every other core): the frame
1795
+ // machinery still runs (video/timers advance — harmless, they
1796
+ // don't write game RAM), but interrupt DELIVERY is suppressed, so
1797
+ // no game handler can execute. The only running game code is the
1798
+ // routine we called — the same guarantee that matters for the
1799
+ // output buffer. The 2600's 6507 has no interrupt lines at all,
1800
+ // so every call there is pure by hardware.
1801
+ if (this.irqBlockSupported()) {
1802
+ this.setIrqBlock(true);
1803
+ irqBlocked = true;
1804
+ pureMode = "irq-blocked";
1805
+ } else if (this.status.platform === "atari2600") {
1806
+ pureMode = "no-interrupts";
1807
+ } else {
1808
+ throw new Error("cpu({op:'call', pure:true}) not supported by this core build (needs the romdev_irqblock_set export — update the core package).");
1809
+ }
1810
+ }
1811
+ framesRun = this._runFramesExclusive(() => {
1812
+ const st = this.getPCBreak(false);
1813
+ if (st.hit) {
1814
+ finalState = st;
1815
+ if (st.watchdog) watchdogTripped = true;
1816
+ else if (stopAtPC !== undefined) stoppedAtPC = true;
1817
+ else returned = true;
1818
+ return true;
1819
+ }
1820
+ return false;
1821
+ }, maxFrames);
1822
+ }
1590
1823
  } finally {
1824
+ if (irqBlocked) { try { this.setIrqBlock(false); } catch { /* core gone */ } }
1591
1825
  this.setPCBreak(0, false, false);
1592
1826
  this.setWatchdog(0);
1593
1827
  if (!finalState) finalState = this.getPCBreak(true); else this.getPCBreak(true);
@@ -1607,6 +1841,7 @@ export class LibretroHost {
1607
1841
  const fin = this._lastCallResult || {};
1608
1842
  return {
1609
1843
  returned, framesRun,
1844
+ ...(pure ? { pure: true, pureMode } : {}),
1610
1845
  ...(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
1846
  ...(stoppedAtPC ? { stoppedAtPC: "$" + (stopAtPC >>> 0).toString(16).toUpperCase() } : {}),
1612
1847
  ...(fin.finalPC != null ? { finalPC: "$" + fin.finalPC.toString(16).toUpperCase(), finalPCRaw: fin.finalPC } : {}),
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)) {