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
@@ -56,7 +56,7 @@ async function maybeRestoreState(host, fromState, fromStatePath) {
56
56
  // observer wrapper encodes it ASYNCHRONOUSLY, after the agent's response has
57
57
  // already gone out. The provider is stripped from the agent-visible result. The
58
58
  // frame is captured by reference now (correct frozen state) but rasterized later.
59
- export function attachObserverFrame(json, host) {
59
+ export function attachObserverFrame(json, host, caption) {
60
60
  json._observerFrameProvider = () => {
61
61
  try {
62
62
  const shot = host.screenshot(); // { pngBase64, width, height }
@@ -65,6 +65,7 @@ export function attachObserverFrame(json, host) {
65
65
  : null;
66
66
  } catch { return null; }
67
67
  };
68
+ if (caption) json._observerFrameCaption = String(caption);
68
69
  return json;
69
70
  }
70
71
 
@@ -189,7 +190,7 @@ function noHitNote(sessionKey) {
189
190
  "(2) this region is rebuilt as a BLOCK rather than written field-by-field — sprite/OAM shadow tables, " +
190
191
  "display lists, and VRAM are typically bulk-copied (memcpy/loop) or DMA'd from a SOURCE struct elsewhere, " +
191
192
  "so no single instruction writes this exact byte. In that case the address you want is the SOURCE: watch " +
192
- "the struct the copy reads from (find it with searchValue on the live value), or for graphics trace the " +
193
+ "the struct the copy reads from (find it with memory({op:'search'}) on the live value), or for graphics trace the " +
193
194
  "DMA/copy source (Genesis VRAM DMA source is in VDP regs). 'Address is wrong' is usually case (2), not a bad address.";
194
195
  }
195
196
 
@@ -452,7 +453,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
452
453
  stoppedEarly,
453
454
  truncated,
454
455
  note: totalMatched === 0
455
- ? "No matching changes in the watched window. Try (a) onChange:'any' to confirm the byte moves at all, (b) longer `frames`, (c) `pressDuring` to drive the game past the event, (d) a different region/offset. If the byte never moves even with onChange:'any', this region may be REBUILT as a block (sprite/OAM shadow, display list, VRAM) rather than written in place — watch the SOURCE struct the copy/DMA reads from instead (find it with searchValue)."
456
+ ? "No matching changes in the watched window. Try (a) onChange:'any' to confirm the byte moves at all, (b) longer `frames`, (c) `pressDuring` to drive the game past the event, (d) a different region/offset. If the byte never moves even with onChange:'any', this region may be REBUILT as a block (sprite/OAM shadow, display list, VRAM) rather than written in place — watch the SOURCE struct the copy/DMA reads from instead (find it with memory({op:'search'}))."
456
457
  : (tryGetPC(host) == null ? "PC not available for this platform (getCPUState returned no pc field)." : undefined),
457
458
  };
458
459
 
@@ -569,17 +570,29 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
569
570
  const bankInfo = (prgOffset != null)
570
571
  ? { prgOffset: "0x" + prgOffset.toString(16).toUpperCase(), bank: Math.floor(prgOffset / 0x4000) }
571
572
  : null;
573
+ // The core snapshots the FULL register file inside the write hook (kind 3,
574
+ // all 14 platforms) — the break-instant truth; the live regs keep moving
575
+ // after the hit.
576
+ const wpSnap = host.getRegSnapshot ? host.getRegSnapshot(true) : null;
577
+ const wpRegs = (wpSnap && wpSnap.kind === 3) ? wpSnap.named : null;
572
578
  return attachObserverFrame(jsonContent({
573
579
  found: true,
574
580
  address: "$" + address.toString(16).toUpperCase(),
575
581
  pc: result.lastPC != null ? "$" + result.lastPC.toString(16).toUpperCase() : null,
576
582
  pcRaw: result.lastPC,
577
- value: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
583
+ // valueByte, not value: this is the ONE BYTE that landed on the watched
584
+ // address — a word/long store shows only its byte here, not the operand
585
+ // (a real session read 0x00 as "the move.l wrote zero").
586
+ valueByte: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
578
587
  hits: result.hits,
579
588
  framesStepped: result.framesStepped,
589
+ ...(wpRegs ? { registersAtHit: wpRegs } : {}),
580
590
  ...(bankInfo ? bankInfo : {}),
581
591
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
582
592
  note: "pc is the EXACT writing instruction (captured in the CPU write path), not a frame sample. " +
593
+ "valueByte is the single byte written to the watched address (a 16/32-bit store shows only its byte here). " +
594
+ "hits counts watched-byte writes during the hit frame — the same instruction looping twice in one frame is hits:2, one event. " +
595
+ (wpRegs ? "registersAtHit is the register file frozen AT the write (the live regs drift for the rest of the frame — don't cpu({op:'read'}) instead). " : "") +
583
596
  (bankInfo
584
597
  ? `pc is in PRG bank ${bankInfo.bank} (prg offset ${bankInfo.prgOffset}) — disassembleRom({ startAddress: ${result.lastPC != null ? "0x" + result.lastPC.toString(16) : "pc"}, bank: ${bankInfo.bank} }) targets the exact bank (no fixed-bank $FF padding).`
585
598
  : `disassembleRom({ startAddress: ${result.lastPC != null ? "0x" + result.lastPC.toString(16) : "pc"} }) to see it. On a banked mapper a $8000-$BFFF pc may be in a switchable bank — pass the right \`bank\`.`),
@@ -659,16 +672,36 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
659
672
  host.setPCBreak(0, false, false); // disarm
660
673
  }
661
674
  if (!hit) {
675
+ // Diagnostics on a miss (0.27.0 feedback #8): a bare "drive it with
676
+ // pressDuring" is useless when the caller DID supply input. Report
677
+ // where the CPU actually is, and tailor the advice.
678
+ const pcNow = tryGetPC(host);
679
+ const drove = presses.length > 0;
662
680
  return attachObserverFrame(jsonContent({
663
681
  hit: false, address: "$" + address.toString(16).toUpperCase(), framesRun,
664
682
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
665
- note: "PC never reached that address within maxFrames. Either the code path didn't execute (drive it with pressDuring " +
666
- "to reach the right game state), or the address isn't an instruction boundary (a mid-instruction address never matches REG_PC).",
683
+ ...(pcNow != null ? { pcNow: "$" + pcNow.toString(16).toUpperCase() } : {}),
684
+ note: (drove
685
+ ? "PC never reached that address within maxFrames EVEN WITH the scheduled input — so this is " +
686
+ "likely the WRONG ADDRESS for the path that actually ran (a different routine handles it), " +
687
+ "or the address isn't an instruction boundary (mid-instruction never matches REG_PC). "
688
+ : "PC never reached that address within maxFrames. Either the code path didn't execute (drive " +
689
+ "it with pressDuring to reach the right game state), or the address isn't an instruction " +
690
+ "boundary (mid-instruction never matches REG_PC). ") +
691
+ (pcNow != null ? "pcNow is the frame-boundary PC (usually the idle loop). " : "") +
692
+ "To find which code DID run, coverage-trace the suspect range: watch({on:'pc', start, end, frames}) " +
693
+ "returns every distinct PC executed there; or anchor on a RAM effect with breakpoint({on:'write'}).",
667
694
  }), host);
668
695
  }
669
696
  // Snapshot the registers AT the hit BEFORE clearing (last already holds the
670
- // hit state; read it without clearing so registersAtHit survives).
671
- const atHit = last.registersAtHit ?? host.getPCBreak(false).registersAtHit ?? null;
697
+ // hit state; read it without clearing so registersAtHit survives). Two
698
+ // snapshot transports: the fceumm-style inline pcbreak slots (A/X/Y/P/S)
699
+ // and the gpgx regsnap export (full m68k/z80 file — kind 1=pc-break,
700
+ // 2=watchdog).
701
+ const snapAtHit = host.getRegSnapshot ? host.getRegSnapshot(false) : null;
702
+ const atHit = last.registersAtHit
703
+ ?? host.getPCBreak(false).registersAtHit
704
+ ?? ((snapAtHit && (snapAtHit.kind === 1 || snapAtHit.kind === 2)) ? snapAtHit.named : null);
672
705
  // captureMemory: read the requested regions AT the hit (before we clear/step),
673
706
  // returned inline so break→read RAM collapses into ONE call. NOTE: registers
674
707
  // are the true break instant (core snapshot); these RAM reads are taken now —
@@ -700,8 +733,9 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
700
733
  // end-of-frame state. Prefer registersAtHit; only fall back to a live read on
701
734
  // cores that don't snapshot.
702
735
  const frozenNote = atHit
703
- ? "registersAtHit holds the register file CAPTURED AT this instruction (A/X/Y/P/S) — use THESE, not a follow-up cpu({op:'read'}), which on NES/fceumm returns end-of-frame state, not the break instant. For RAM at the hit, pass captureMemory:[{region,offset,length}] to get it inline (capturedMemory) in THIS call instead of a follow-up read. frame({op:'stepInstruction'}) to single-step from here."
736
+ ? "registersAtHit holds the register file CAPTURED AT the break instant — use THESE, not a follow-up cpu({op:'read'}), which returns end-of-frame state (the CPU/frame machinery keeps running after the hit). For RAM at the hit, pass captureMemory:[{region,offset,length}] to get it inline (capturedMemory) in THIS call instead of a follow-up read. frame({op:'stepInstruction'}) to single-step from here."
704
737
  : "This core does not snapshot registers at the hit. cpu({op:'read'}) reflects the CPU state now; on cores that run-to-frame-end (fceumm) that is NOT the break instant — prefer the RAM side effects (memory({op:'read'})) over the live register file.";
738
+ if (host.getRegSnapshot) host.getRegSnapshot(true); // consume the snapshot so a later bp can't read a stale one
705
739
  return attachObserverFrame(jsonContent({
706
740
  hit: true,
707
741
  address: "$" + address.toString(16).toUpperCase(),
@@ -711,7 +745,9 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
711
745
  ...(capturedMemory ? { capturedMemory } : {}),
712
746
  frame: host.status.frameCount,
713
747
  framesRun,
714
- hits: fin.hits,
748
+ // The core's hits counter doesn't tick on a watchdog stop — normalize so
749
+ // hit:true never reports hits:0 (a real session read that as contradictory).
750
+ hits: fin.hits || 1,
715
751
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
716
752
  note: frozenNote,
717
753
  }), host);
@@ -749,17 +785,21 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
749
785
  });
750
786
  }
751
787
  const fin = host.getReadWatch(true);
788
+ const rdSnap = host.getRegSnapshot ? host.getRegSnapshot(true) : null;
789
+ const rdRegs = (rdSnap && rdSnap.kind === 4) ? rdSnap.named : null;
752
790
  return attachObserverFrame(jsonContent({
753
791
  hit: true,
754
792
  address: "$" + address.toString(16).toUpperCase(),
755
793
  pc: last.lastPC != null ? "$" + last.lastPC.toString(16).toUpperCase() : null,
756
794
  pcRaw: last.lastPC,
757
- value: "0x" + (last.lastValue & 0xFF).toString(16).toUpperCase().padStart(2, "0"),
795
+ valueByte: "0x" + (last.lastValue & 0xFF).toString(16).toUpperCase().padStart(2, "0"),
758
796
  frame: host.status.frameCount,
759
797
  framesRun,
760
- hits: fin.hits,
798
+ hits: fin.hits || 1,
799
+ ...(rdRegs ? { registersAtHit: rdRegs } : {}),
761
800
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
762
- note: "pc is the EXACT instruction that read this address. disasm({ target:'rom', startAddress: pc }) to see it.",
801
+ note: "pc is the EXACT instruction that read this address. disasm({ target:'rom', startAddress: pc }) to see it." +
802
+ (rdRegs ? " registersAtHit is the register file frozen AT the read (the live regs drift for the rest of the frame)." : ""),
763
803
  }), host);
764
804
  }
765
805
 
@@ -782,10 +822,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
782
822
  "use it, NOT a follow-up cpu({op:'read'}). On some cores (notably NES/fceumm) the core drains the cycle budget on hit but the frame still finishes, " +
783
823
  "so a live cpu read afterward returns END-OF-FRAME registers, not the break instant. `registersAtHit` sidesteps that. The break PC is reported as `pc`/`pcRaw`; " +
784
824
  "the RAM side effects are also reliable via memory({op:'read'}). frame({op:'stepInstruction'}) to single-step from the break. (on:'read'/'write' finish the frame.)\n" +
785
- "All supported on every CPU core; `registersAtHit` is present on cores that snapshot regs (NES today); out-of-date core packages return notSupported.",
825
+ "All supported on every CPU core. **Every hit carries `registersAtHit` the FULL register file frozen by the core AT the hit instant, on ALL 14 platforms and all three `on` kinds.** Use it instead of a follow-up cpu({op:'read'}): the live registers keep moving after a hit (per-scanline CPU scheduling / frame completion), so a post-hit read drifts — chasing pointer registers read that way burned a real session for hours. The hit `pc` is the EXECUTING instruction's first byte (mid-instruction hooks no longer report the operand-advanced PC). Out-of-date core packages return notSupported.\n" +
826
+ "MENU-SCREEN INPUT TRICK: if a pressDuring schedule never registers (some menu screens poll input in a way scheduled taps miss), HOLD the button instead: input({op:'set', buttons:{...}}) BEFORE this call and OMIT pressDuring — the run inherits the held state, the menu sees the edge, and the breakpoint catches the event.",
786
827
  {
787
828
  on: z.enum(["write", "read", "pc"])
788
- .describe("write=break on a write to address (precision:exact=true writer PC / sampled=frame PC, a lie under IRQ); read=break on a read (exact PC, who consumes it); pc=break when PC reaches address — the hit returns `registersAtHit` (the break-instant A/X/Y/P/S on NES) + the break PC; use registersAtHit, not a follow-up cpu read (which is end-of-frame state on fceumm)."),
829
+ .describe("write=break on a write to address (precision:exact=true writer PC / sampled=frame PC, a lie under IRQ); read=break on a read (exact PC, who consumes it); pc=break when PC reaches address — the hit returns `registersAtHit` (the break-instant register file, all 14 platforms) + the break PC; use registersAtHit, not a follow-up cpu read (end-of-frame state)."),
789
830
  precision: z.enum(["exact", "sampled"]).default("exact")
790
831
  .describe("on:'write' ONLY. exact=core watchpoint, the real writing instruction PC even under interrupts (uses `address`). sampled=cheap frame-boundary PC (uses region/offset/length) — NOT the writer under IRQ. Ignored for on:read/pc (always exact)."),
791
832
  address: z.number().int().min(0).optional().describe("on:'write' exact / on:'read' / on:'pc' — CPU address to break on (write target, read target, or instruction boundary). Required for those."),
@@ -857,7 +898,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
857
898
  return jsonContent({ regId, value: "0x" + (now >>> 0).toString(16).toUpperCase(), valueRaw: now });
858
899
  }
859
900
 
860
- async function cpuCall({ pc, regs, sentinelPC = 0, stopAtPC, presetMemory, maxFrames = 600, maxInstructions, sandbox = false }) {
901
+ async function cpuCall({ pc, regs, sentinelPC = 0, stopAtPC, presetMemory, maxFrames = 600, maxInstructions, sandbox = false, pure = false }) {
861
902
  const host = getHost(sessionKey);
862
903
  if (!host.setRegSupported || !host.setRegSupported()) {
863
904
  return jsonContent({ returned: false, notSupported: true,
@@ -868,23 +909,34 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
868
909
  const r = host.callSubroutine({
869
910
  pc, regs: numRegs, sentinelPC, stopAtPC,
870
911
  presetMemory: (presetMemory ?? []).map((m) => ({ addr: m.addr, hex: m.hex })),
871
- maxFrames, ...(maxInstructions ? { maxInstructions } : {}), sandbox,
912
+ maxFrames, ...(maxInstructions ? { maxInstructions } : {}), sandbox, pure,
872
913
  });
873
- const note = r.returned
914
+ // The poisoned-call caveat (a real session lost hours to this): when the
915
+ // call spanned FRAMES of emulation, the game's own per-frame logic (VBlank
916
+ // handlers via RAM vectors, music drivers) ran CONCURRENTLY and may have
917
+ // written over the routine's output buffer. Loud, up front, with the fix.
918
+ const frameLogicCaveat = (!pure && r.framesRun > 0 && (host.pureCallSupported ? host.pureCallSupported() : false))
919
+ ? ` ⚠ framesRun:${r.framesRun} — the game's own frame logic (VBlank handler, music driver) ran DURING this call and may have modified RAM the routine wrote; treat the output buffer as suspect. Re-run with pure:true to step ONLY the CPU (no frame machinery).`
920
+ : (!pure && r.framesRun > 0)
921
+ ? ` ⚠ framesRun:${r.framesRun} — the game's own frame logic ran DURING this call and may have modified RAM the routine wrote; treat the output buffer as suspect (verify visually or against a known-good slice).`
922
+ : "";
923
+ const note = (r.returned
874
924
  ? "Routine RETURNED. readMemory the buffer it wrote (e.g. the decompressor's A1 dest) now — sandbox:false leaves it live. (regs by reg-id: m68k 8=A0,9=A1,0=D0.)"
875
925
  : r.watchdog
876
926
  ? "WATCHDOG tripped (ran the instruction budget without returning) — almost always a wrong entry setup, not a long routine. Check finalPC (where it's spinning) + finalRegs (is A0 where you set it, or did it walk off?). Common fixes: correct A0 to the real block start (with its length header), add a presetMemory the codec reads, or pass a WRAPPER entryPC that sets up dest. Raise maxInstructions only if you're sure it's legitimately huge."
877
927
  : r.stoppedAtPC
878
928
  ? `Stopped at ${r.stoppedAtPC} (your stopAtPC) with PARTIAL output — readMemory the dst to see what's been written so far.`
879
- : "Did not return within maxFrames AND the watchdog didn't trip — this usually means the entry FELL BACK INTO THE GAME (a wrapper PC with a wrong source, so it never reaches the sentinel) and the game is just free-running. finalPC is inside the main loop, not your routine. Re-check the entry PC (use the routine body, not a wrapper) and the source regs; or lower maxInstructions to fail fast while probing. Bump maxFrames/maxInstructions only if you're sure it's a legitimately huge decompress.";
880
- return jsonContent({
929
+ : "Did not return within maxFrames AND the watchdog didn't trip — this usually means the entry FELL BACK INTO THE GAME (a wrapper PC with a wrong source, so it never reaches the sentinel) and the game is just free-running. finalPC is inside the main loop, not your routine. Re-check the entry PC (use the routine body, not a wrapper) and the source regs; or lower maxInstructions to fail fast while probing. Bump maxFrames/maxInstructions only if you're sure it's a legitimately huge decompress.")
930
+ + frameLogicCaveat;
931
+ return attachObserverFrame(jsonContent({
881
932
  returned: r.returned, framesRun: r.framesRun, sandbox,
933
+ ...(r.pure ? { pure: true, pureMode: r.pureMode } : {}),
882
934
  ...(r.watchdog ? { watchdog: true, reason: r.reason } : {}),
883
935
  ...(r.stoppedAtPC ? { stoppedAtPC: r.stoppedAtPC } : {}),
884
936
  ...(r.finalPC ? { finalPC: r.finalPC } : {}),
885
937
  ...(r.finalRegs ? { finalRegs: r.finalRegs } : {}),
886
938
  note,
887
- });
939
+ }), host, "cpu call");
888
940
  }
889
941
 
890
942
  async function cpuDecompress({ entryPC, sourceAddress, destAddress, maxFrames = 600 }) {
@@ -911,7 +963,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
911
963
  "OP CHEAT-SHEET (the params each op uses): " +
912
964
  "read → {cpu?, platform?}; " +
913
965
  "setReg → {regId, value}; " +
914
- "call → {pc, regs?, sandbox?, maxInstructions?, sentinelPC?, stopAtPC?, presetMemory?, maxFrames?}; " +
966
+ "call → {pc, regs?, pure?, sandbox?, maxInstructions?, sentinelPC?, stopAtPC?, presetMemory?, maxFrames?}; " +
915
967
  "decompress → {entryPC, sourceAddress, destAddress?, maxFrames?}.\n" +
916
968
  "• op:'read' — read a CPU's {pc, registers, flags, sp}. Main CPU wired for all 14 tier-1 systems (nes, snes, " +
917
969
  "genesis, sms, gg, gb, gbc, atari2600, atari7800, c64, lynx, gba (ARM7TDMI: 16 gprs + cpsr/spsr + execPc for " +
@@ -924,7 +976,13 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
924
976
  "buffer it wrote stays live for memory({op:'read'}).** Classic use: drive a decompressor (A0=source, A1=dest) then read " +
925
977
  "the dst. **NEVER HANGS: an instruction WATCHDOG (`maxInstructions`) force-stops a runaway and returns PROGRESS — " +
926
978
  "finalPC + finalRegs + watchdog:true — so you can tell 'wrong A0' from 'needs a preset' from 'legitimately long'.** " +
927
- "`stopAtPC` halts mid-routine for partial output; `presetMemory` for codecs that read a global from RAM first.\n" +
979
+ "`stopAtPC` halts mid-routine for partial output; `presetMemory` for codecs that read a global from RAM first. " +
980
+ "**`pure:true` (ALL 14 platforms): the game's own VBlank/IRQ logic CANNOT run during the call and stomp the routine's " +
981
+ "output buffer.** Mechanism per platform (reported as `pureMode`): Genesis/SMS/GG step ONLY the CPU ('cpu-only'); every " +
982
+ "other core suppresses interrupt DELIVERY for the duration ('irq-blocked' — video/timers advance harmlessly, no game " +
983
+ "handler executes); the 2600 has no interrupts at all ('no-interrupts'). Without pure, a call that spans frames runs " +
984
+ "the game's frame logic alongside your routine (the result carries a ⚠ caveat) — a real session spent " +
985
+ "hours diffing a CORRECT codec against that poisoned output. Prefer pure for every decompressor/codec call.\n" +
928
986
  "• op:'decompress' — convenience wrapper over op:'call' for the common decompressor shape: call `entryPC` with " +
929
987
  "A0=`sourceAddress` (and optionally A1=`destAddress`), run until it returns, then read `destAddress`. For the " +
930
988
  "NBA-Jam-style 'name + portrait are LZ-compressed' wall: point it at the game's own decompressor.",
@@ -949,6 +1007,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
949
1007
  maxFrames: z.number().int().min(1).max(100000).default(600).describe("op:call/decompress — frame cap (the outer bound)."),
950
1008
  maxInstructions: z.number().int().min(1000).optional().describe("op:call — instruction watchdog budget (the REAL cap; default ~maxFrames*500k). Raise for a huge decompress; lower to fail fast while probing the right A0."),
951
1009
  sandbox: z.boolean().default(false).describe("op:call — snapshot+restore core state around the call (default FALSE — you want the dst buffer left live to read). True leaves the live game untouched."),
1010
+ pure: z.boolean().default(false).describe("op:call — guarantee the game's own frame logic CANNOT run during the call and stomp the routine's output (ALL 14 platforms; `pureMode` in the result says how: 'cpu-only' on Genesis/SMS/GG, 'irq-blocked' elsewhere, 'no-interrupts' on 2600). Prefer this for any decompressor/codec call."),
952
1011
  // decompress
953
1012
  entryPC: z.number().int().min(0).optional().describe("op:decompress — decompressor entry PC."),
954
1013
  sourceAddress: z.number().int().min(0).optional().describe("op:decompress — compressed-source address → A0 (reg-id 8 on m68k)."),
@@ -1044,10 +1103,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1044
1103
  "**CAVEAT: frame-level, not instruction-level (last value per frame); the sampled `pc` is a frame-boundary sample — for ISR-driven writes use breakpoint({on:'write', precision:'exact'}) for the real writer.**\n" +
1045
1104
  "• on:'range' — DISCOVERY: log EVERY instruction that reads or writes ANYWHERE in [start,end]. The fix for 'I don't know which PC touches this'. Returns {pc,address,value}[] + the actionable distinctPCs. (Ring-buffered: `truncated:true` if it overflows.) `fromState`/`fromStatePath` restores a savestate FIRST so the trace runs from a known moment (jump to the boss, then see what writes HP) — deterministic + repeatable.\n" +
1046
1105
  "• on:'pc' — DISCOVERY (coverage trace): record every DISTINCT PC executed within [start,end] — 'what code runs here?'. Log execution in the bank where you suspect the renderer lives during the moment it draws, then disassemble the PCs. Also takes `fromState`/`fromStatePath` to trace from a restored moment.\n" +
1047
- "• on:'dma' — GENESIS ONLY: trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — WHERE in ROM?', which on:'write' can't catch). `precision:'exact'` (default) logs every mem→VDP DMA with its VRAM DESTINATION + ROM SOURCE + length (filter by `vramDest`±`destWindow`; `dedupe` collapses the per-frame refresh; `sourceFilter:'rom-only'` drops RAM→VRAM noise; catches a same-frame second DMA). `precision:'sampled'` is the cheap frame-sampled source-register read (may miss two DMAs in one frame, dest-agnostic). `perFrame:true` switches to FEEL/PERF MODE: a per-frame timeline of VDP-DMA WORK ({frame,dmas,bytes,romBytes,ramBytes} + peakFrame + `spikes`) — the cheap 'why does horizontal movement feel choppy?' diagnostic (a per-frame byte spike = too much VDP work in the loop, e.g. a tilemap rewrite). On non-Genesis cores returns `notSupported`.",
1106
+ "• on:'dma' — GENESIS ONLY: trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — WHERE in ROM?', which on:'write' can't catch). `precision:'exact'` (default) logs every mem→VDP DMA with its VRAM DESTINATION + ROM SOURCE + length (filter by `vramDest`±`destWindow`; `dedupe` collapses the per-frame refresh; `sourceFilter:'rom-only'` drops RAM→VRAM noise; catches a same-frame second DMA). `precision:'sampled'` is the cheap frame-sampled source-register read (may miss two DMAs in one frame, dest-agnostic). `perFrame:true` switches to FEEL/PERF MODE: a per-frame timeline of VDP-DMA WORK ({frame,dmas,bytes,romBytes,ramBytes} + peakFrame + `spikes`) — the cheap 'why does horizontal movement feel choppy?' diagnostic (a per-frame byte spike = too much VDP work in the loop, e.g. a tilemap rewrite). On non-Genesis cores returns `notSupported`.\n" +
1107
+ "• on:'copy' — ALL 14 PLATFORMS: log every write landing in a VRAM/dest address window [start,end] with the EXECUTING instruction's PC — the generic answer to 'this tile/nametable/portrait on screen: which routine uploads it?'. Port-based video memory (NES $2007, SNES $2118/19 — incl. the DMA path, PCE VWR, MSX/SMS/GG VDP data port, Genesis data port) is hooked INSIDE the core, so `start`/`end` are VRAM addresses (NES PPU $0000-$3FFF; SNES VRAM byte addr; PCE VRAM word addr; MSX/SMS/GG VRAM addr). Direct-mapped platforms (GB/GBC $8000-$9FFF, GBA 0x06000000+, C64/Lynx/7800 RAM framebuffers) route through the CPU-address range log automatically — pass CPU addresses there. Follow up with breakpoint({on:'pc', address: pc}) to get registersAtHit at the uploader.",
1048
1108
  {
1049
- on: z.enum(["mem", "range", "pc", "dma"])
1050
- .describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]; dma=Genesis-only mem→VDP DMA source/dest trace."),
1109
+ on: z.enum(["mem", "range", "pc", "dma", "copy"])
1110
+ .describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]; dma=Genesis-only mem→VDP DMA source/dest trace; copy=log every write landing in a VRAM address window with the EXECUTING instruction's PC (all 14 platforms — the generic 'where does this graphic come from?')."),
1051
1111
  // on:'mem'
1052
1112
  region: z.enum(MEMORY_REGIONS).optional().describe("on:'mem' single-range — the region to watch (same canonical set memory uses, incl. nes_apu_regs, genesis_ym2612, c64_sid_regs). Omit when using `ranges`."),
1053
1113
  offset: z.number().int().min(0).default(0).describe("on:'mem' single-range — first byte of the watched range."),
@@ -1084,7 +1144,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1084
1144
  button: z.string(),
1085
1145
  port: z.number().int().min(0).max(3).default(0),
1086
1146
  holdFrames: z.number().int().min(1).default(2),
1087
- })).optional().describe("Schedule input while watching (drive the game to the state that touches the watched bytes/range, or uploads the graphic for on:'dma'). If OMITTED, this run inherits whatever input({op:'set'}) last held — same as frame({op:'step'}). If GIVEN, the schedule OWNS the pad for the whole run (a prior input({op:'set'}) is ignored). Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten."),
1147
+ })).optional().describe("Schedule input while watching (drive the game to the state that touches the watched bytes/range, or uploads the graphic for on:'dma'). If OMITTED, this run inherits whatever input({op:'set'}) last held — same as frame({op:'step'}). If GIVEN, the schedule OWNS the pad for the whole run (a prior input({op:'set'}) is ignored). Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten. MENU SCREENS: if a schedule never registers (some menus poll input in a way scheduled taps miss), hold the button via input({op:'set'}) and OMIT pressDuring — the run inherits the held state and the menu sees the edge."),
1088
1148
  fromState: z.string().optional().describe("on:'range'/'pc' — restore an in-memory savestate SLOT (from state({op:'save', name})) BEFORE tracing, so the log runs from a known moment (jump to the boss fight, then see what writes HP). Deterministic + repeatable."),
1089
1149
  fromStatePath: z.string().optional().describe("on:'range'/'pc' — like fromState but restore from a savestate FILE on disk (state({op:'save', path})). Relative path resolves against the loaded ROM's dir."),
1090
1150
  },
@@ -1107,11 +1167,69 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1107
1167
  }
1108
1168
  return await dmaExact(a);
1109
1169
  }
1170
+ case "copy": {
1171
+ if (args.start == null || args.end == null) throw new Error("watch({on:'copy'}): `start` and `end` are required (the VRAM/dest address window).");
1172
+ return await wCopy({ ...args, frames: args.frames ?? 120, limit: args.limit ?? 200 });
1173
+ }
1110
1174
  default: throw new Error(`watch: unknown on '${args.on}'`);
1111
1175
  }
1112
1176
  }),
1113
1177
  );
1114
1178
 
1179
+ // ── watch({on:'copy'}) — the generic graphics source-trace ─────────────────
1180
+ async function wCopy({ start, end, frames = 120, limit = 200, pressDuring }) {
1181
+ const host = getHost(sessionKey);
1182
+ const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
1183
+ const pressDriver = makePressDriver(host, presses);
1184
+ if (host.vramWatchSupported && host.vramWatchSupported()) {
1185
+ // Port-based video memory: the core hook logs {vramAddr, pc, value}
1186
+ // with the EXECUTING instruction's PC (DMA-initiating instruction on
1187
+ // SNES). start/end are VRAM addresses.
1188
+ let i = 0;
1189
+ const r = host.watchVram(start, end, frames, () => { pressDriver.applyForFrame(i++); return false; });
1190
+ pressDriver.finish();
1191
+ const events = r.events.slice(0, limit).map((e) => ({
1192
+ vramAddr: "$" + e.vramAddr.toString(16).toUpperCase(),
1193
+ pc: "$" + e.pc.toString(16).toUpperCase(),
1194
+ pcRaw: e.pc,
1195
+ value: "0x" + e.value.toString(16).toUpperCase().padStart(2, "0"),
1196
+ }));
1197
+ const distinct = [...new Set(r.events.map((e) => e.pc))].slice(0, 32)
1198
+ .map((p) => "$" + p.toString(16).toUpperCase());
1199
+ return jsonContent({
1200
+ on: "copy", mode: "vram-port",
1201
+ window: { start: "$" + start.toString(16).toUpperCase(), end: "$" + end.toString(16).toUpperCase() },
1202
+ framesRun: frames,
1203
+ total: r.total, stored: r.stored, truncated: r.truncated,
1204
+ distinctPCs: distinct,
1205
+ events,
1206
+ note: "pc is the EXECUTING instruction that performed the upload (on SNES the instruction that " +
1207
+ "triggered the DMA). Addresses are VRAM-space. Next: breakpoint({on:'pc', address: <pc>}) to stop " +
1208
+ "there with registersAtHit (source pointer in the index/address regs), then disasm({target:'rom', " +
1209
+ "startAddress: <pc>}) to read the routine." +
1210
+ (r.truncated ? " Ring overflowed — narrow the window or lower frames." : ""),
1211
+ });
1212
+ }
1213
+ // Direct-mapped video memory (GB/GBC/GBA/C64/Lynx/7800): the same
1214
+ // question routes through the CPU-address range log — start/end are CPU
1215
+ // addresses (e.g. GB VRAM $8000-$9FFF).
1216
+ pressDriver.finish();
1217
+ const out = await wRange({ start, end, frames, limit, kind: "write", pressDuring });
1218
+ try {
1219
+ const parsed = JSON.parse(out.content.find((c) => c.type === "text").text);
1220
+ parsed.on = "copy";
1221
+ parsed.mode = "cpu-mapped";
1222
+ parsed.note = (parsed.note ? parsed.note + " " : "") +
1223
+ "This platform's video memory is CPU-mapped, so the copy trace IS the write-range log: " +
1224
+ "start/end are CPU addresses (GB/GBC VRAM $8000-$9FFF; GBA 0x06000000+; C64/Lynx/7800 use the " +
1225
+ "framebuffer/display-list RAM range). pc is the executing instruction; follow up with " +
1226
+ "breakpoint({on:'pc', address: pc}) for registersAtHit.";
1227
+ return jsonContent(parsed);
1228
+ } catch {
1229
+ return out;
1230
+ }
1231
+ }
1232
+
1115
1233
  // ── watch({on:'dma'}) helpers (Genesis only) ────────────────────────────────
1116
1234
  // precision:exact = dmaExact (watchDma, per-DMA core log), precision:sampled =
1117
1235
  // traceVramSourceCore (frame-sampled, dest-agnostic). Routed by the `watch`
@@ -80,6 +80,79 @@ class ObserverBus extends EventEmitter {
80
80
 
81
81
  export const observer = new ObserverBus();
82
82
 
83
+ // ── Throttled deferred-frame emission ───────────────────────────────────────
84
+ // `call_frame` events carry a freshly-rasterized framebuffer PNG for the
85
+ // human's livestream. Tools attach a PROVIDER thunk (attachObserverFrame) and
86
+ // both transports route it here. Two guarantees:
87
+ // 1. The PNG encode NEVER runs on the agent's critical path (deferred via
88
+ // setImmediate / the trailing timer).
89
+ // 2. Rate-limited to one frame per FRAME_MIN_INTERVAL_MS **per
90
+ // (session, tool)** — frame({op:'step'}) called 120× in a narrowing loop
91
+ // emits at most every 2s, but a step followed immediately by a DIFFERENT
92
+ // tool's frame (input, state load, …) still shows: distinct tools don't
93
+ // throttle each other. Trailing-edge: the LAST suppressed frame in a
94
+ // burst always lands when the window reopens (rendered at fire time =
95
+ // the current screen, which is exactly what the human wants to converge
96
+ // on).
97
+ let FRAME_MIN_INTERVAL_MS = 2000;
98
+ export function _setFrameThrottleForTest(ms) { FRAME_MIN_INTERVAL_MS = ms; }
99
+
100
+ /** @type {Map<string, {lastTs: number, timer: any, pending: null | {provider: Function, meta: object}}>} */
101
+ const _frameThrottle = new Map();
102
+
103
+ function _emitFrame(provider, meta) {
104
+ try {
105
+ const img = provider();
106
+ if (img) {
107
+ observer.push({
108
+ type: "call_frame",
109
+ sessionKey: meta.sessionKey ?? "http",
110
+ platform: typeof meta.resolvePlatform === "function" ? (meta.resolvePlatform() ?? meta.platform ?? null) : (meta.platform ?? null),
111
+ ts: meta.ts ?? Date.now(),
112
+ tool: meta.tool,
113
+ ...(meta.caption ? { caption: meta.caption } : {}),
114
+ images: [img],
115
+ });
116
+ }
117
+ } catch { /* livestream is best-effort; never affects the agent */ }
118
+ }
119
+
120
+ /**
121
+ * Queue a deferred framebuffer for the livestream, throttled per
122
+ * (session, tool). `meta`: { sessionKey, tool, ts?, platform?,
123
+ * resolvePlatform?, caption? } — resolvePlatform (a thunk) is preferred so
124
+ * the platform label reflects post-call state (loadMedia sets it DURING the
125
+ * call). `provider` returns {kind:'image', mimeType, base64} or null; it is
126
+ * invoked OFF the agent's critical path.
127
+ */
128
+ export function pushObserverFrame(meta, provider) {
129
+ const key = `${meta.sessionKey ?? "http"}|${meta.tool ?? "?"}`;
130
+ let st = _frameThrottle.get(key);
131
+ if (!st) { st = { lastTs: 0, timer: null, pending: null }; _frameThrottle.set(key, st); }
132
+ const now = Date.now();
133
+ if (!st.timer && now - st.lastTs >= FRAME_MIN_INTERVAL_MS) {
134
+ st.lastTs = now;
135
+ setImmediate(() => _emitFrame(provider, meta));
136
+ return;
137
+ }
138
+ // Inside the window: stash as the pending trailing frame (latest wins) and
139
+ // arm the trailing timer once.
140
+ st.pending = { provider, meta };
141
+ if (!st.timer) {
142
+ const delay = Math.max(1, st.lastTs + FRAME_MIN_INTERVAL_MS - now);
143
+ st.timer = setTimeout(() => {
144
+ st.timer = null;
145
+ const p = st.pending;
146
+ st.pending = null;
147
+ if (p) {
148
+ st.lastTs = Date.now();
149
+ _emitFrame(p.provider, p.meta);
150
+ }
151
+ }, delay);
152
+ if (st.timer.unref) st.timer.unref(); // never hold the process open
153
+ }
154
+ }
155
+
83
156
  /**
84
157
  * Extract image payloads from an MCP tool result. MCP tool results have
85
158
  * `content: [{type:'text'|'image', ...}]`. We pull out images so the UI
@@ -305,7 +305,8 @@
305
305
  // One latest image per "tool" (kind = tool name); ev.tool
306
306
  // identifies which inspect call produced it.
307
307
  s.latestByKind[ev.tool] = { ts: ev.ts, base64: img.base64,
308
- mimeType: img.mimeType, tool: ev.tool, platform: ev.platform ?? null };
308
+ mimeType: img.mimeType, tool: ev.tool, platform: ev.platform ?? null,
309
+ caption: ev.caption ?? null };
309
310
  }
310
311
  }
311
312
  // screenshotAscii pushes both the PNG (above) AND the raw ANSI
@@ -416,7 +417,8 @@
416
417
  card.className = "image-card";
417
418
  const dt = new Date(img.ts).toLocaleTimeString();
418
419
  const platBadge = img.platform ? `<span class="plat">${escapeHtml(img.platform)}</span> ` : "";
419
- card.innerHTML = `<div class="meta"><span>${platBadge}${escapeHtml(img.tool)}</span><span>${dt}</span></div>`;
420
+ const label = img.caption ? `${img.tool}${img.caption}` : img.tool;
421
+ card.innerHTML = `<div class="meta"><span>${platBadge}${escapeHtml(label)}</span><span>${dt}</span></div>`;
420
422
  const el = document.createElement("img");
421
423
  el.src = `data:${img.mimeType};base64,${img.base64}`;
422
424
  el.alt = img.tool;
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Idempotent per server instance — installs once, repeats are no-ops.
7
7
 
8
- import { observer, extractImages, summarizeForLog } from "./bus.js";
8
+ import { observer, extractImages, summarizeForLog, pushObserverFrame } from "./bus.js";
9
9
  import { getHostOrNull } from "../mcp/state.js";
10
10
 
11
11
  const INSTALLED = Symbol.for("romdev.observer-installed");
@@ -54,6 +54,7 @@ export function installObserverMiddleware(server, sessionKey) {
54
54
  const platform = sessionPlatform(sessionKey); // which console this call drives
55
55
  let event;
56
56
  let frameProvider = null; // deferred framebuffer thunk (encoded async below)
57
+ let frameCaption = null; // optional human label for the call_frame event
57
58
  if (thrown) {
58
59
  event = {
59
60
  type: "call",
@@ -98,6 +99,10 @@ export function installObserverMiddleware(server, sessionKey) {
98
99
  frameProvider = result._observerFrameProvider;
99
100
  delete result._observerFrameProvider;
100
101
  }
102
+ if (result && typeof result === "object" && typeof result._observerFrameCaption === "string") {
103
+ frameCaption = result._observerFrameCaption;
104
+ delete result._observerFrameCaption;
105
+ }
101
106
  const inlineImages = extractImages(result);
102
107
  const images = inlineImages.length > 0 ? inlineImages : sidebandImages;
103
108
  const resultSummary = summarizeForLog(result);
@@ -121,20 +126,18 @@ export function installObserverMiddleware(server, sessionKey) {
121
126
  // tool response on observer delivery.
122
127
  try { observer.push(event); } catch { /* never let observer kill the tool */ }
123
128
 
124
- // Deferred frame: encode + push the PNG AFTER the agent's response goes
125
- // out, so the (expensive) rasterize never delays the tool. setImmediate
126
- // yields the response first; a separate `call_frame` event carries the
127
- // image for the human's livestream. Best-effort never throws into the
128
- // tool path. (Only breakpoint/watch tools set a provider.)
129
+ // Deferred frame: encoded + pushed AFTER the agent's response goes out,
130
+ // throttled to one per 2s PER (session, tool) with a trailing-edge
131
+ // emit (bus.js pushObserverFrame) frame-step loops can't flood the
132
+ // stream, distinct tools never throttle each other, and the last frame
133
+ // of a burst always lands. Best-effort never throws into the tool
134
+ // path.
129
135
  if (frameProvider) {
130
- setImmediate(() => {
131
- try {
132
- const img = frameProvider();
133
- if (img) {
134
- observer.push({ type: "call_frame", sessionKey, platform, ts: startedAt, tool: name, images: [img] });
135
- }
136
- } catch { /* livestream is best-effort; never affects the agent */ }
137
- });
136
+ pushObserverFrame({
137
+ sessionKey, tool: name, ts: startedAt, platform,
138
+ resolvePlatform: () => sessionPlatform(sessionKey),
139
+ ...(frameCaption ? { caption: frameCaption } : {}),
140
+ }, frameProvider);
138
141
  }
139
142
 
140
143
  if (thrown) throw thrown;