romdevtools 0.26.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 +322 -3
  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 +172 -25
  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
@@ -189,7 +189,7 @@ function noHitNote(sessionKey) {
189
189
  "(2) this region is rebuilt as a BLOCK rather than written field-by-field — sprite/OAM shadow tables, " +
190
190
  "display lists, and VRAM are typically bulk-copied (memcpy/loop) or DMA'd from a SOURCE struct elsewhere, " +
191
191
  "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 " +
192
+ "the struct the copy reads from (find it with memory({op:'search'}) on the live value), or for graphics trace the " +
193
193
  "DMA/copy source (Genesis VRAM DMA source is in VDP regs). 'Address is wrong' is usually case (2), not a bad address.";
194
194
  }
195
195
 
@@ -452,7 +452,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
452
452
  stoppedEarly,
453
453
  truncated,
454
454
  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)."
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 memory({op:'search'}))."
456
456
  : (tryGetPC(host) == null ? "PC not available for this platform (getCPUState returned no pc field)." : undefined),
457
457
  };
458
458
 
@@ -569,17 +569,29 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
569
569
  const bankInfo = (prgOffset != null)
570
570
  ? { prgOffset: "0x" + prgOffset.toString(16).toUpperCase(), bank: Math.floor(prgOffset / 0x4000) }
571
571
  : null;
572
+ // The core snapshots the FULL register file inside the write hook (kind 3,
573
+ // all 14 platforms) — the break-instant truth; the live regs keep moving
574
+ // after the hit.
575
+ const wpSnap = host.getRegSnapshot ? host.getRegSnapshot(true) : null;
576
+ const wpRegs = (wpSnap && wpSnap.kind === 3) ? wpSnap.named : null;
572
577
  return attachObserverFrame(jsonContent({
573
578
  found: true,
574
579
  address: "$" + address.toString(16).toUpperCase(),
575
580
  pc: result.lastPC != null ? "$" + result.lastPC.toString(16).toUpperCase() : null,
576
581
  pcRaw: result.lastPC,
577
- value: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
582
+ // valueByte, not value: this is the ONE BYTE that landed on the watched
583
+ // address — a word/long store shows only its byte here, not the operand
584
+ // (a real session read 0x00 as "the move.l wrote zero").
585
+ valueByte: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
578
586
  hits: result.hits,
579
587
  framesStepped: result.framesStepped,
588
+ ...(wpRegs ? { registersAtHit: wpRegs } : {}),
580
589
  ...(bankInfo ? bankInfo : {}),
581
590
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
582
591
  note: "pc is the EXACT writing instruction (captured in the CPU write path), not a frame sample. " +
592
+ "valueByte is the single byte written to the watched address (a 16/32-bit store shows only its byte here). " +
593
+ "hits counts watched-byte writes during the hit frame — the same instruction looping twice in one frame is hits:2, one event. " +
594
+ (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
595
  (bankInfo
584
596
  ? `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
597
  : `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\`.`),
@@ -633,7 +645,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
633
645
  });
634
646
  }
635
647
 
636
- async function bpRunUntilPC({ address, maxFrames = 600, pressDuring }) {
648
+ async function bpRunUntilPC({ address, maxFrames = 600, pressDuring, captureMemory }) {
637
649
  const host = getHost(sessionKey);
638
650
  if (!host.pcBreakSupported || !host.pcBreakSupported()) {
639
651
  return jsonContent({
@@ -659,16 +671,59 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
659
671
  host.setPCBreak(0, false, false); // disarm
660
672
  }
661
673
  if (!hit) {
674
+ // Diagnostics on a miss (0.27.0 feedback #8): a bare "drive it with
675
+ // pressDuring" is useless when the caller DID supply input. Report
676
+ // where the CPU actually is, and tailor the advice.
677
+ const pcNow = tryGetPC(host);
678
+ const drove = presses.length > 0;
662
679
  return attachObserverFrame(jsonContent({
663
680
  hit: false, address: "$" + address.toString(16).toUpperCase(), framesRun,
664
681
  ...(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).",
682
+ ...(pcNow != null ? { pcNow: "$" + pcNow.toString(16).toUpperCase() } : {}),
683
+ note: (drove
684
+ ? "PC never reached that address within maxFrames EVEN WITH the scheduled input — so this is " +
685
+ "likely the WRONG ADDRESS for the path that actually ran (a different routine handles it), " +
686
+ "or the address isn't an instruction boundary (mid-instruction never matches REG_PC). "
687
+ : "PC never reached that address within maxFrames. Either the code path didn't execute (drive " +
688
+ "it with pressDuring to reach the right game state), or the address isn't an instruction " +
689
+ "boundary (mid-instruction never matches REG_PC). ") +
690
+ (pcNow != null ? "pcNow is the frame-boundary PC (usually the idle loop). " : "") +
691
+ "To find which code DID run, coverage-trace the suspect range: watch({on:'pc', start, end, frames}) " +
692
+ "returns every distinct PC executed there; or anchor on a RAM effect with breakpoint({on:'write'}).",
667
693
  }), host);
668
694
  }
669
695
  // 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;
696
+ // hit state; read it without clearing so registersAtHit survives). Two
697
+ // snapshot transports: the fceumm-style inline pcbreak slots (A/X/Y/P/S)
698
+ // and the gpgx regsnap export (full m68k/z80 file — kind 1=pc-break,
699
+ // 2=watchdog).
700
+ const snapAtHit = host.getRegSnapshot ? host.getRegSnapshot(false) : null;
701
+ const atHit = last.registersAtHit
702
+ ?? host.getPCBreak(false).registersAtHit
703
+ ?? ((snapAtHit && (snapAtHit.kind === 1 || snapAtHit.kind === 2)) ? snapAtHit.named : null);
704
+ // captureMemory: read the requested regions AT the hit (before we clear/step),
705
+ // returned inline so break→read RAM collapses into ONE call. NOTE: registers
706
+ // are the true break instant (core snapshot); these RAM reads are taken now —
707
+ // i.e. after the hit frame finished — so on run-to-frame-end cores (fceumm)
708
+ // they reflect the routine's RAM SIDE EFFECTS for that frame (which is what
709
+ // RE wants: "what did this routine touch"), not necessarily the exact byte
710
+ // mid-instruction. Stable + reliable; that's the property the report leaned on.
711
+ let capturedMemory = null;
712
+ if (Array.isArray(captureMemory) && captureMemory.length) {
713
+ capturedMemory = {};
714
+ for (const m of captureMemory) {
715
+ const label = m.label ?? `${m.region}+${m.offset}`;
716
+ try {
717
+ const bytes = host.readMemory(m.region, m.offset, m.length ?? 1);
718
+ capturedMemory[label] = {
719
+ region: m.region, offset: m.offset, length: m.length ?? 1,
720
+ hex: Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""),
721
+ };
722
+ } catch (e) {
723
+ capturedMemory[label] = { region: m.region, offset: m.offset, error: String(e?.message ?? e) };
724
+ }
725
+ }
726
+ }
672
727
  const fin = host.getPCBreak(true); // clear hit
673
728
  // registersAtHit (NES/fceumm and any core that snapshots regs on hit) is the
674
729
  // RELIABLE break-instant register file. The LIVE register file (a follow-up
@@ -677,17 +732,21 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
677
732
  // end-of-frame state. Prefer registersAtHit; only fall back to a live read on
678
733
  // cores that don't snapshot.
679
734
  const frozenNote = atHit
680
- ? "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 a source pointer in a 16-bit reg pair, read the two ZP bytes via memory({op:'read'}). frame({op:'stepInstruction'}) to single-step from here."
735
+ ? "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."
681
736
  : "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.";
737
+ if (host.getRegSnapshot) host.getRegSnapshot(true); // consume the snapshot so a later bp can't read a stale one
682
738
  return attachObserverFrame(jsonContent({
683
739
  hit: true,
684
740
  address: "$" + address.toString(16).toUpperCase(),
685
741
  pc: last.lastPC != null ? "$" + last.lastPC.toString(16).toUpperCase() : null,
686
742
  pcRaw: last.lastPC,
687
743
  ...(atHit ? { registersAtHit: atHit } : {}),
744
+ ...(capturedMemory ? { capturedMemory } : {}),
688
745
  frame: host.status.frameCount,
689
746
  framesRun,
690
- hits: fin.hits,
747
+ // The core's hits counter doesn't tick on a watchdog stop — normalize so
748
+ // hit:true never reports hits:0 (a real session read that as contradictory).
749
+ hits: fin.hits || 1,
691
750
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
692
751
  note: frozenNote,
693
752
  }), host);
@@ -725,17 +784,21 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
725
784
  });
726
785
  }
727
786
  const fin = host.getReadWatch(true);
787
+ const rdSnap = host.getRegSnapshot ? host.getRegSnapshot(true) : null;
788
+ const rdRegs = (rdSnap && rdSnap.kind === 4) ? rdSnap.named : null;
728
789
  return attachObserverFrame(jsonContent({
729
790
  hit: true,
730
791
  address: "$" + address.toString(16).toUpperCase(),
731
792
  pc: last.lastPC != null ? "$" + last.lastPC.toString(16).toUpperCase() : null,
732
793
  pcRaw: last.lastPC,
733
- value: "0x" + (last.lastValue & 0xFF).toString(16).toUpperCase().padStart(2, "0"),
794
+ valueByte: "0x" + (last.lastValue & 0xFF).toString(16).toUpperCase().padStart(2, "0"),
734
795
  frame: host.status.frameCount,
735
796
  framesRun,
736
- hits: fin.hits,
797
+ hits: fin.hits || 1,
798
+ ...(rdRegs ? { registersAtHit: rdRegs } : {}),
737
799
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
738
- note: "pc is the EXACT instruction that read this address. disasm({ target:'rom', startAddress: pc }) to see it.",
800
+ note: "pc is the EXACT instruction that read this address. disasm({ target:'rom', startAddress: pc }) to see it." +
801
+ (rdRegs ? " registersAtHit is the register file frozen AT the read (the live regs drift for the rest of the frame)." : ""),
739
802
  }), host);
740
803
  }
741
804
 
@@ -758,10 +821,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
758
821
  "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, " +
759
822
  "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`; " +
760
823
  "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" +
761
- "All supported on every CPU core; `registersAtHit` is present on cores that snapshot regs (NES today); out-of-date core packages return notSupported.",
824
+ "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" +
825
+ "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.",
762
826
  {
763
827
  on: z.enum(["write", "read", "pc"])
764
- .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)."),
828
+ .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)."),
765
829
  precision: z.enum(["exact", "sampled"]).default("exact")
766
830
  .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)."),
767
831
  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."),
@@ -780,6 +844,12 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
780
844
  offset: z.number().int().min(0).describe("byte offset within the region"),
781
845
  label: z.string().optional().describe("human name for this guard byte"),
782
846
  })).optional().describe("on:'write' exact — ABORT GUARD for a pressDuring run: caller-named 'is this scenario still valid?' bytes (e.g. the area/scene id, the player object-active flag). If ANY changes mid-run the watchpoint stops IMMEDIATELY and returns {aborted:true, abortedBy, before, after} — so a driven scenario that derailed (player died → title screen) doesn't burn all maxFrames and return a meaningless found:false. Each is sampled once per frame (cheap)."),
847
+ captureMemory: z.array(z.object({
848
+ region: z.enum(MEMORY_REGIONS).describe("memory region to read"),
849
+ offset: z.number().int().min(0).describe("byte offset within the region"),
850
+ length: z.number().int().min(1).max(256).default(1).describe("bytes to read"),
851
+ label: z.string().optional().describe("human name for this read (else 'region+offset')"),
852
+ })).optional().describe("on:'pc' — read these memory regions AT the hit and return them inline as `capturedMemory` (collapses break→read-RAM into ONE call, the token win). Pair with `registersAtHit` to get the routine's register + RAM state in a single round trip (e.g. capture the ZP pointer bytes a decoder just wrote). NOTE: registersAtHit is the true break instant (core snapshot); these RAM reads are taken after the hit frame finishes, so on run-to-frame-end cores (fceumm) they're the routine's RAM side effects for that frame — stable + reliable, which is exactly what RE needs."),
783
853
  },
784
854
  safeTool(async (args) => {
785
855
  switch (args.on) {
@@ -827,7 +897,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
827
897
  return jsonContent({ regId, value: "0x" + (now >>> 0).toString(16).toUpperCase(), valueRaw: now });
828
898
  }
829
899
 
830
- async function cpuCall({ pc, regs, sentinelPC = 0, stopAtPC, presetMemory, maxFrames = 600, maxInstructions, sandbox = false }) {
900
+ async function cpuCall({ pc, regs, sentinelPC = 0, stopAtPC, presetMemory, maxFrames = 600, maxInstructions, sandbox = false, pure = false }) {
831
901
  const host = getHost(sessionKey);
832
902
  if (!host.setRegSupported || !host.setRegSupported()) {
833
903
  return jsonContent({ returned: false, notSupported: true,
@@ -838,17 +908,28 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
838
908
  const r = host.callSubroutine({
839
909
  pc, regs: numRegs, sentinelPC, stopAtPC,
840
910
  presetMemory: (presetMemory ?? []).map((m) => ({ addr: m.addr, hex: m.hex })),
841
- maxFrames, ...(maxInstructions ? { maxInstructions } : {}), sandbox,
911
+ maxFrames, ...(maxInstructions ? { maxInstructions } : {}), sandbox, pure,
842
912
  });
843
- const note = r.returned
913
+ // The poisoned-call caveat (a real session lost hours to this): when the
914
+ // call spanned FRAMES of emulation, the game's own per-frame logic (VBlank
915
+ // handlers via RAM vectors, music drivers) ran CONCURRENTLY and may have
916
+ // written over the routine's output buffer. Loud, up front, with the fix.
917
+ const frameLogicCaveat = (!pure && r.framesRun > 0 && (host.pureCallSupported ? host.pureCallSupported() : false))
918
+ ? ` ⚠ 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).`
919
+ : (!pure && r.framesRun > 0)
920
+ ? ` ⚠ 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).`
921
+ : "";
922
+ const note = (r.returned
844
923
  ? "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.)"
845
924
  : r.watchdog
846
925
  ? "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."
847
926
  : r.stoppedAtPC
848
927
  ? `Stopped at ${r.stoppedAtPC} (your stopAtPC) with PARTIAL output — readMemory the dst to see what's been written so far.`
849
- : "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.";
928
+ : "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.")
929
+ + frameLogicCaveat;
850
930
  return jsonContent({
851
931
  returned: r.returned, framesRun: r.framesRun, sandbox,
932
+ ...(r.pure ? { pure: true, pureMode: r.pureMode } : {}),
852
933
  ...(r.watchdog ? { watchdog: true, reason: r.reason } : {}),
853
934
  ...(r.stoppedAtPC ? { stoppedAtPC: r.stoppedAtPC } : {}),
854
935
  ...(r.finalPC ? { finalPC: r.finalPC } : {}),
@@ -881,7 +962,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
881
962
  "OP CHEAT-SHEET (the params each op uses): " +
882
963
  "read → {cpu?, platform?}; " +
883
964
  "setReg → {regId, value}; " +
884
- "call → {pc, regs?, sandbox?, maxInstructions?, sentinelPC?, stopAtPC?, presetMemory?, maxFrames?}; " +
965
+ "call → {pc, regs?, pure?, sandbox?, maxInstructions?, sentinelPC?, stopAtPC?, presetMemory?, maxFrames?}; " +
885
966
  "decompress → {entryPC, sourceAddress, destAddress?, maxFrames?}.\n" +
886
967
  "• op:'read' — read a CPU's {pc, registers, flags, sp}. Main CPU wired for all 14 tier-1 systems (nes, snes, " +
887
968
  "genesis, sms, gg, gb, gbc, atari2600, atari7800, c64, lynx, gba (ARM7TDMI: 16 gprs + cpsr/spsr + execPc for " +
@@ -894,7 +975,13 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
894
975
  "buffer it wrote stays live for memory({op:'read'}).** Classic use: drive a decompressor (A0=source, A1=dest) then read " +
895
976
  "the dst. **NEVER HANGS: an instruction WATCHDOG (`maxInstructions`) force-stops a runaway and returns PROGRESS — " +
896
977
  "finalPC + finalRegs + watchdog:true — so you can tell 'wrong A0' from 'needs a preset' from 'legitimately long'.** " +
897
- "`stopAtPC` halts mid-routine for partial output; `presetMemory` for codecs that read a global from RAM first.\n" +
978
+ "`stopAtPC` halts mid-routine for partial output; `presetMemory` for codecs that read a global from RAM first. " +
979
+ "**`pure:true` (ALL 14 platforms): the game's own VBlank/IRQ logic CANNOT run during the call and stomp the routine's " +
980
+ "output buffer.** Mechanism per platform (reported as `pureMode`): Genesis/SMS/GG step ONLY the CPU ('cpu-only'); every " +
981
+ "other core suppresses interrupt DELIVERY for the duration ('irq-blocked' — video/timers advance harmlessly, no game " +
982
+ "handler executes); the 2600 has no interrupts at all ('no-interrupts'). Without pure, a call that spans frames runs " +
983
+ "the game's frame logic alongside your routine (the result carries a ⚠ caveat) — a real session spent " +
984
+ "hours diffing a CORRECT codec against that poisoned output. Prefer pure for every decompressor/codec call.\n" +
898
985
  "• op:'decompress' — convenience wrapper over op:'call' for the common decompressor shape: call `entryPC` with " +
899
986
  "A0=`sourceAddress` (and optionally A1=`destAddress`), run until it returns, then read `destAddress`. For the " +
900
987
  "NBA-Jam-style 'name + portrait are LZ-compressed' wall: point it at the game's own decompressor.",
@@ -919,6 +1006,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
919
1006
  maxFrames: z.number().int().min(1).max(100000).default(600).describe("op:call/decompress — frame cap (the outer bound)."),
920
1007
  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."),
921
1008
  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."),
1009
+ 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."),
922
1010
  // decompress
923
1011
  entryPC: z.number().int().min(0).optional().describe("op:decompress — decompressor entry PC."),
924
1012
  sourceAddress: z.number().int().min(0).optional().describe("op:decompress — compressed-source address → A0 (reg-id 8 on m68k)."),
@@ -1014,10 +1102,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1014
1102
  "**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" +
1015
1103
  "• 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" +
1016
1104
  "• 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" +
1017
- "• 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`.",
1105
+ "• 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" +
1106
+ "• 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.",
1018
1107
  {
1019
- on: z.enum(["mem", "range", "pc", "dma"])
1020
- .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."),
1108
+ on: z.enum(["mem", "range", "pc", "dma", "copy"])
1109
+ .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?')."),
1021
1110
  // on:'mem'
1022
1111
  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`."),
1023
1112
  offset: z.number().int().min(0).default(0).describe("on:'mem' single-range — first byte of the watched range."),
@@ -1054,7 +1143,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1054
1143
  button: z.string(),
1055
1144
  port: z.number().int().min(0).max(3).default(0),
1056
1145
  holdFrames: z.number().int().min(1).default(2),
1057
- })).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."),
1146
+ })).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."),
1058
1147
  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."),
1059
1148
  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."),
1060
1149
  },
@@ -1077,11 +1166,69 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1077
1166
  }
1078
1167
  return await dmaExact(a);
1079
1168
  }
1169
+ case "copy": {
1170
+ if (args.start == null || args.end == null) throw new Error("watch({on:'copy'}): `start` and `end` are required (the VRAM/dest address window).");
1171
+ return await wCopy({ ...args, frames: args.frames ?? 120, limit: args.limit ?? 200 });
1172
+ }
1080
1173
  default: throw new Error(`watch: unknown on '${args.on}'`);
1081
1174
  }
1082
1175
  }),
1083
1176
  );
1084
1177
 
1178
+ // ── watch({on:'copy'}) — the generic graphics source-trace ─────────────────
1179
+ async function wCopy({ start, end, frames = 120, limit = 200, pressDuring }) {
1180
+ const host = getHost(sessionKey);
1181
+ const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
1182
+ const pressDriver = makePressDriver(host, presses);
1183
+ if (host.vramWatchSupported && host.vramWatchSupported()) {
1184
+ // Port-based video memory: the core hook logs {vramAddr, pc, value}
1185
+ // with the EXECUTING instruction's PC (DMA-initiating instruction on
1186
+ // SNES). start/end are VRAM addresses.
1187
+ let i = 0;
1188
+ const r = host.watchVram(start, end, frames, () => { pressDriver.applyForFrame(i++); return false; });
1189
+ pressDriver.finish();
1190
+ const events = r.events.slice(0, limit).map((e) => ({
1191
+ vramAddr: "$" + e.vramAddr.toString(16).toUpperCase(),
1192
+ pc: "$" + e.pc.toString(16).toUpperCase(),
1193
+ pcRaw: e.pc,
1194
+ value: "0x" + e.value.toString(16).toUpperCase().padStart(2, "0"),
1195
+ }));
1196
+ const distinct = [...new Set(r.events.map((e) => e.pc))].slice(0, 32)
1197
+ .map((p) => "$" + p.toString(16).toUpperCase());
1198
+ return jsonContent({
1199
+ on: "copy", mode: "vram-port",
1200
+ window: { start: "$" + start.toString(16).toUpperCase(), end: "$" + end.toString(16).toUpperCase() },
1201
+ framesRun: frames,
1202
+ total: r.total, stored: r.stored, truncated: r.truncated,
1203
+ distinctPCs: distinct,
1204
+ events,
1205
+ note: "pc is the EXECUTING instruction that performed the upload (on SNES the instruction that " +
1206
+ "triggered the DMA). Addresses are VRAM-space. Next: breakpoint({on:'pc', address: <pc>}) to stop " +
1207
+ "there with registersAtHit (source pointer in the index/address regs), then disasm({target:'rom', " +
1208
+ "startAddress: <pc>}) to read the routine." +
1209
+ (r.truncated ? " Ring overflowed — narrow the window or lower frames." : ""),
1210
+ });
1211
+ }
1212
+ // Direct-mapped video memory (GB/GBC/GBA/C64/Lynx/7800): the same
1213
+ // question routes through the CPU-address range log — start/end are CPU
1214
+ // addresses (e.g. GB VRAM $8000-$9FFF).
1215
+ pressDriver.finish();
1216
+ const out = await wRange({ start, end, frames, limit, kind: "write", pressDuring });
1217
+ try {
1218
+ const parsed = JSON.parse(out.content.find((c) => c.type === "text").text);
1219
+ parsed.on = "copy";
1220
+ parsed.mode = "cpu-mapped";
1221
+ parsed.note = (parsed.note ? parsed.note + " " : "") +
1222
+ "This platform's video memory is CPU-mapped, so the copy trace IS the write-range log: " +
1223
+ "start/end are CPU addresses (GB/GBC VRAM $8000-$9FFF; GBA 0x06000000+; C64/Lynx/7800 use the " +
1224
+ "framebuffer/display-list RAM range). pc is the executing instruction; follow up with " +
1225
+ "breakpoint({on:'pc', address: pc}) for registersAtHit.";
1226
+ return jsonContent(parsed);
1227
+ } catch {
1228
+ return out;
1229
+ }
1230
+ }
1231
+
1085
1232
  // ── watch({on:'dma'}) helpers (Genesis only) ────────────────────────────────
1086
1233
  // precision:exact = dmaExact (watchDma, per-DMA core log), precision:sampled =
1087
1234
  // traceVramSourceCore (frame-sampled, dest-agnostic). Routed by the `watch`
@@ -59,15 +59,34 @@ thousands of bytes and you'll drown).
59
59
  on-screen value. `region` defaults to `system_ram`.
60
60
  2. Change the value in-game (take damage, score a point), then
61
61
  `memory({op:'searchNext', compare:'eq', value})` — or `compare:'gt'|'lt'|'changed'|'unchanged'|
62
- 'inc'|'dec'` when you don't know the new value. Repeat until a handful remain.
62
+ 'inc'|'dec'` when you don't know the new value. The relative compares work as the
63
+ FIRST narrow too (baselines are recorded at seed). Repeat until a handful remain.
63
64
  3. Confirm: `memory({op:'write'})` the candidate and watch the screen react.
64
65
 
65
66
  This is the Cheat-Engine/RetroArch loop. It is THE bread-and-butter primitive.
66
67
 
68
+ **Stored ≠ displayed.** When a correct-looking seed returns 0, the byte usually isn't the
69
+ raw number: seed `as:'bcd'` for packed-BCD scores (2 decimal digits per byte — very common
70
+ on NES), or `as:'digits'` for one byte per ON-SCREEN digit at any constant tile base (HUD
71
+ tile-index buffers; the matched base is reported per candidate, and `searchNext` keeps
72
+ comparing in the same representation). For displayed−1 lives or ÷10 scores, just seed the
73
+ transformed number. If an INPUT drives the value (position, velocity, charge), skip the
74
+ loop entirely: `memory({op:'diffRuns', portsA:[{right:true}]})` isolates it in one call.
75
+
67
76
  `memory({op:'snapshot'})` + `memory({op:'diff'})` is for "which bytes did THIS one event touch?",
68
77
  not for value hunting. `memory({op:'diff'})` defaults to a **clustered summary** (ranges +
69
78
  stride) so it won't flood you — a reported stride (e.g. "islands at 0x80") is
70
- usually a struct/entity array, each island one record.
79
+ usually a struct/entity array, each island one record. Small clusters (≤8 bytes) carry
80
+ `before`/`after` hex inline, and `minDelta:N` drops |after−before| < N so RNG/counter
81
+ wiggle disappears from the report.
82
+
83
+ **"Which byte does this INPUT drive?" → `memory({op:'diffRuns'})`** — runs the same start
84
+ state twice (savestate restore in between) under two different held inputs (`portsA` vs
85
+ `portsB`, default released) for `frames` each, and returns only the bytes that DIVERGE
86
+ between the runs, with run-A/run-B values on small clusters. One call replaces the whole
87
+ save → hold → step → dump → restore → hold-other → dump → diff loop; the frame counter and
88
+ all input-independent churn cancel out automatically. (The emulator is left at the end of
89
+ run B.)
71
90
 
72
91
  ---
73
92
 
@@ -140,7 +159,12 @@ the copy reads from, then `breakpoint({on:'write'})` on THAT.
140
159
  **Precision — exact vs sampled.** The default `breakpoint({on:'write'})` is a core-level write
141
160
  watchpoint: it returns the EXACT writing instruction's PC, captured inside the CPU write
142
161
  path — correct even for NMI/IRQ-driven writes (the common case where a frame-sampled PC
143
- is just the idle loop). On a banked mapper it reports the `bank` (NES/GB/SMS-GG) so you
162
+ is just the idle loop). On ALL 14 platforms, every hit (write/read/pc) also carries
163
+ **`registersAtHit`** — the full register file frozen AT the hit instant — and the CPU
164
+ stays FROZEN until the hit is cleared. Use registersAtHit instead of a follow-up
165
+ `cpu({op:'read'})`: pre-0.28.0 the live registers kept running after a hit (on gpgx they
166
+ drifted hundreds of instructions — address registers read that way were someone else's
167
+ values). On a banked mapper it reports the `bank` (NES/GB/SMS-GG) so you
144
168
  can pass `{startAddress, bank}` to `disasm({target:'rom'})`. The lighter
145
169
  `breakpoint({on:'write', precision:'sampled'})` (a.k.a. `watch({on:'mem'})`) steps until the byte changes
146
170
  and returns a frame-boundary PC — a lead, not a guarantee under interrupts; use it for the
@@ -197,6 +221,16 @@ pushes a sentinel return, and runs until it returns. Most of these formats have
197
221
  you can usually craft a replacement by hand. (sandbox:false leaves the dest buffer
198
222
  live for `memory({op:'read'})`; sandbox:true restores the game untouched.)
199
223
 
224
+ **Pass `pure:true` — on every platform.** A non-pure call that spans frames runs the
225
+ game's OWN frame logic concurrently (VBlank handlers via RAM vectors, music
226
+ drivers) — which can overwrite the dest buffer mid-call and hand you poisoned
227
+ "ground truth" (a real session spent hours diffing a CORRECT reimplementation
228
+ against it). With `pure:true` the game's handlers CANNOT run: Genesis/SMS/GG step
229
+ only the CPU (`pureMode:'cpu-only'`); everywhere else interrupt DELIVERY is
230
+ suppressed for the duration (`'irq-blocked'` — pending lines stay pending, video
231
+ advances harmlessly); the 2600 has no interrupts (`'no-interrupts'`). Non-pure
232
+ results carry a ⚠ caveat whenever frame logic ran.
233
+
200
234
  ## 5e. Re-inject an edited asset — the round-trip (don't reimplement the compressor)
201
235
 
202
236
  Once you can SEE the decompressed bytes (5c) and you've edited them, put them BACK
@@ -315,9 +349,14 @@ Once you know WHAT to change, the write loop is a handful of calls — no custom
315
349
  confirm a patch landed where you meant.
316
350
  - **`disasm({target:'references', path, platform, address})`** — find every instruction that
317
351
  references a target address, classified `call/jump/branch/read/write/use/ref` (walks the
318
- vector table too). The fast "who touches this?" for a STATIC image. Limitation: direct
319
- addressing only indirect/computed jumps aren't detected (use the runtime `watch`/
320
- `breakpoint` tools in §5/§5d for those).
352
+ vector table too). The fast "who touches this?" for a STATIC image. EVERY banked format
353
+ is scanned PER BANK NES mappers (refs carry `prgBank`), and SNES multi-bank LoROM,
354
+ GB/GBC MBC, SMS/GG Sega-mapper, MSX megaROM, Atari 2600 F8/F6/F4, Atari 7800 SuperGame,
355
+ and >32KB HuCards (refs carry `romBank`) — so a hit in bank 12 of a 128KB cart shows up,
356
+ not just the first bank. Zero-page direct + indexed operands match, and `#$nn` immediates
357
+ are excluded (values, not addresses). Limitation: direct addressing only —
358
+ indirect/computed jumps aren't detected (use the runtime `watch`/`breakpoint` tools in
359
+ §5/§5d for those).
321
360
  - **`cart({op:'extract', path, outputDir})`** — split a ROM into standard parts (NES header/
322
361
  prg/chr; SNES copier_header+rom+internal header; Genesis vectors/header/body; GB boot/
323
362
  header/body) + a `manifest.json` (mapper, mirroring…). **`cart({op:'wrap'})`** is the inverse:
@@ -330,8 +369,9 @@ watch the screen react — cheaper than shipping a wrong ROM patch.
330
369
 
331
370
  For a STRUCTURAL hack (new logic, not a byte poke), turn the whole ROM into a
332
371
  re-buildable project in one call: `disasm({target:'project', path, outputDir})`. It splits
333
- the ROM into regions (per-16KB bank for banked NES, per-32KB for SNES LoROM, slot0+slotX
334
- for GB, one flat region for SMS/Genesis/C64/Atari), disassembles each through the CPU's
372
+ the ROM into regions (per-bank on EVERY banked format: 16KB banks for NES/GB/SMS-GG/MSX/
373
+ 7800-SuperGame, 32KB for SNES LoROM, 4KB for banked 2600, 8KB pages for >32KB HuCards;
374
+ one flat region for Genesis/C64/Lynx/GBA and small carts), disassembles each through the CPU's
335
375
  native objdump, then **reassembles + verifies byte-exact** against the original; any line
336
376
  that won't reproduce faithfully heals to a `.byte`/`db` of its real bytes, so the emitted
337
377
  `.asm` ALWAYS rebuilds (`roundTrip.allByteExact`). `readablePercent` per region tells you
@@ -343,22 +383,27 @@ rebuild exists — a `rebuild.json` of the precise `build({...})` args. So the l
343
383
 
344
384
  **Two rebuild tiers** (the disasm emits each CPU's native-reassembler syntax — ca65 for
345
385
  6502/65816, GNU `as` for m68k/arm/z80/gbz80 — which only some `build()` toolchains consume):
346
- - **One-call `build()` rebuild, byte-identical** — **NES, C64, Atari 7800, Lynx**. Feed
347
- `rebuild.json` straight to `build`. (Lynx: `build()` yields the headerless image; prepend
348
- the shipped `lnx_header.bin` for the full `.lnx`.)
386
+ - **One-call `build()` rebuild, byte-identical** — **NES (NROM *and* banked mappers), C64,
387
+ Atari 7800 (flat *and* SuperGame banked), Lynx, PC Engine (flat *and* banked HuCards)**.
388
+ Feed `rebuild.json` straight to `build`. Banked projects ship a HEADER segment with the
389
+ original header bytes (16 iNES / 128 .a78 / 512 copier), per-bank segment wrappers, and a
390
+ generated multi-bank `.cfg` referenced via `linkerConfigPath` (so the cfg never streams
391
+ through context). (Lynx: `build()` yields the headerless image; prepend the shipped
392
+ `lnx_header.bin` for the full `.lnx`.)
349
393
  - **Native-recipe rebuild (`buildCall:null`), byte-identical, steps in `BUILD.md`** — **SMS,
350
394
  GG, MSX, GB, GBC, Genesis, GBA, Atari 2600**. Their `build()` toolchains (SDCC/RGBDS/asar/
351
395
  dasm/vasm) can't reassemble ca65/GNU-as syntax, so `BUILD.md` gives the proven native
352
- `as`/`ld`/`objcopy` chain.
353
- - **PC Engine** is the one not-yet-byte-exact case (the region trims real padding / doesn't
354
- strip a copier header) — `BUILD.md` flags it.
396
+ `as`/`ld`/`objcopy` chain — per-bank on banked carts (Sega-mapper SMS/GG, MSX megaROMs,
397
+ banked 2600 get per-bank wrappers + cfg blobs and a bank-by-bank recipe).
355
398
 
356
399
  **Rebuilding a commercial NES (NROM CHR-ROM) game — `build({inesHeader})`:** the most common
357
400
  NES RE rebuild. `build({output:'rom', platform:'nes', inesHeader:{prgBanks, chrBanks, mapper,
358
401
  mirroring}, sourcesPaths:{…the PRG…}, binaryIncludePaths:{"chr.bin":…}})` auto-emits the
359
402
  16-byte iNES header + CHARS-segment wiring + flat NROM `.cfg` — no hand-derived header bytes.
360
- `disasm({target:'project'})` puts exactly this call in `rebuild.json`. (For homebrew C that
361
- ships fixed tile art, `linkerConfig:"chr-rom"` is the segment-split equivalent.)
403
+ `disasm({target:'project'})` puts exactly this call in `rebuild.json` for NROM; banked
404
+ mappers get the per-bank segment + multi-bank `.cfg` form instead (see the one-call tier
405
+ above). (For homebrew C that ships fixed tile art, `linkerConfig:"chr-rom"` is the
406
+ segment-split equivalent.)
362
407
 
363
408
  **Readability caveats** (the bytes are ALWAYS correct; only instruction-vs-`.byte` coverage
364
409
  varies): SNES and large Genesis ROMs come back byte-exact but DATA-ONLY (flat whole-ROM
@@ -398,6 +443,7 @@ For sprite/tile edits (not text), don't hand-roll the tile-format math:
398
443
  |---|---|
399
444
  | Find a value's address | `memory({op:'search'})` → `memory({op:'searchNext'})` (NOT full-RAM diff) |
400
445
  | Which bytes did one event touch | `memory({op:'snapshot'})` → `memory({op:'diff'})` (summary) |
446
+ | Which byte does an INPUT drive | `memory({op:'diffRuns', portsA, portsB?})` (A/B divergence, one call) |
401
447
  | Is on-screen text a string or a bitmap | `text({op:'learn'})` (reports pre-rendered graphic) |
402
448
  | Is a "table" really ASCII/code | `memory({op:'classify'})` |
403
449
  | Confirm a patch is in the running ROM | `memory({op:'readCart'})` |
@@ -406,7 +452,8 @@ For sprite/tile edits (not text), don't hand-roll the tile-format math:
406
452
  | Which instruction READ a byte | `breakpoint({on:'read', address})` (read-side `breakpoint({on:'write'})`) |
407
453
  | Single-step the CPU | `frame({op:'stepInstruction'})` (+ `cpu({op:'read'})` to watch regs) |
408
454
  | Set a CPU register | `cpu({op:'setReg', regId, value})` |
409
- | Decompress a compressed asset | `cpu({op:'decompress'})` / `cpu({op:'call'})` (run the ROM's own codec) |
455
+ | Decompress a compressed asset | `cpu({op:'decompress'})` / `cpu({op:'call', pure:true})` (run the ROM's own codec, interference-free) |
456
+ | Where does this on-screen graphic come from | `watch({on:'copy', start, end})` (all 14 — writer PC per VRAM write; Genesis DMA also via `watch({on:'dma'})`) |
410
457
  | Re-inject edited bytes the game accepts | `romPatch({op:'makeStored'})` (verbatim-expand block) → `romPatch({op:'findFree'})` → `romPatch({op:'relocate'})` |
411
458
  | Find the pointer that loads an asset | `romPatch({op:'findPointer', romOffset})` |
412
459
  | FIND the unknown routine touching X | `watch({on:'range', start,end})` (all hits) / `watch({on:'pc'})` (coverage) |
@@ -215,7 +215,11 @@ What you can read:
215
215
  registers.
216
216
  - **`disasm({target:'rom'})`** and **`disasm({target:'references'})`** —
217
217
  both anchor to the top of the bank (`$F000-$FFFF`) and label the vector
218
- table (NMI / RESET / IRQ at `$FFFA`).
218
+ table (NMI / RESET / IRQ at `$FFFA`). On banked carts (F8 = 8 KB,
219
+ F6 = 16 KB, F4 = 32 KB) `references` scans EVERY 4 KB bank at `$F000`,
220
+ refs tagged `romBank`; `disasm({target:'project'})` likewise emits one
221
+ region per bank plus per-bank `BANKn` wrappers and a multi-area `.cfg`
222
+ blob for the native ca65/ld65 rebuild.
219
223
 
220
224
  Memory regions for **`memory({op:'read'})`**:
221
225
 
@@ -159,3 +159,43 @@ snippet for one approach.
159
159
  ## "First build is slow but later ones are fast"
160
160
 
161
161
  Expected. dasm cold-load is ~500ms. Steady-state builds < 100ms.
162
+
163
+
164
+ ## Pressing RIGHT also "presses" LEFT (or the player can't move at all)
165
+
166
+ The classic `LDA SWCHA / ASL / BCS … / ASL / BCS …` carry-chain only works if
167
+ NOTHING between the shifts touches A. The moment a branch body does
168
+ `LDA P_X` (a bounds check, a compare), the next `ASL` shifts your *position*
169
+ instead of SWCHA — and since positions are < $80, carry comes back clear and
170
+ the "other direction" fires too. Net effect: moves cancel, the sprite sticks
171
+ to one edge. **Re-load SWCHA and AND a single bit per direction instead:**
172
+
173
+ ```asm
174
+ LDA SWCHA
175
+ AND #$80 ; bit7 = P0 Right (active LOW: 0 = pressed)
176
+ BNE .noRight
177
+ ...move right (clobber A freely)...
178
+ .noRight:
179
+ LDA SWCHA
180
+ AND #$40 ; bit6 = P0 Left
181
+ BNE .noLeft
182
+ ...
183
+ ```
184
+
185
+ ## Jump plays its sound but the player never leaves the ground
186
+
187
+ Signed-velocity clamps must check the SIGN first. An unsigned
188
+ `CMP #$F8 / BCS keep` "terminal velocity" clamp also catches every POSITIVE
189
+ (rising) velocity — +6 is less than $F8 unsigned — so the jump impulse is
190
+ instantly slammed to falling and the whole arc resolves inside one frame
191
+ (SFX plays, screen blips, no visible jump). Clamp only while falling:
192
+
193
+ ```asm
194
+ LDA P_VY
195
+ BPL .vyok ; rising → terminal clamp doesn't apply
196
+ CMP #$F8
197
+ BCS .vyok ; -8..-1 → fine
198
+ LDA #$F8 ; clamp to -8
199
+ STA P_VY
200
+ .vyok:
201
+ ```