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.
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +322 -3
- package/README.md +1 -1
- package/examples/README.md +1 -1
- package/examples/atari2600/templates/platformer.asm +18 -9
- package/examples/atari2600/templates/racing.asm +25 -4
- package/examples/atari2600/templates/shmup.asm +30 -5
- package/examples/atari2600/templates/sports.asm +41 -9
- package/examples/atari7800/templates/hello_sprite.c +8 -4
- package/examples/atari7800/templates/platformer.c +12 -8
- package/examples/atari7800/templates/puzzle.c +7 -4
- package/examples/atari7800/templates/racing.c +5 -2
- package/examples/atari7800/templates/shmup.c +8 -4
- package/examples/atari7800/templates/sports.c +6 -3
- package/examples/c64/templates/platformer.c +28 -24
- package/examples/c64/templates/puzzle.c +77 -16
- package/examples/c64/templates/racing.c +9 -0
- package/examples/c64/templates/shmup.c +13 -1
- package/examples/c64/templates/sports.c +9 -4
- package/examples/gb/templates/platformer.c +6 -2
- package/examples/gb/templates/puzzle.c +279 -101
- package/examples/gb/templates/racing.c +13 -1
- package/examples/gb/templates/shmup.c +13 -1
- package/examples/gb/templates/sports.c +9 -3
- package/examples/gba/templates/platformer.c +7 -13
- package/examples/gba/templates/puzzle.c +93 -15
- package/examples/gba/templates/racing.c +13 -1
- package/examples/gba/templates/shmup.c +13 -1
- package/examples/gba/templates/sports.c +17 -5
- package/examples/gbc/templates/platformer.c +6 -2
- package/examples/gbc/templates/puzzle.c +878 -178
- package/examples/gbc/templates/racing.c +13 -1
- package/examples/gbc/templates/shmup.c +13 -1
- package/examples/gbc/templates/sports.c +9 -3
- package/examples/genesis/templates/puzzle.c +76 -15
- package/examples/genesis/templates/racing.c +13 -1
- package/examples/genesis/templates/shmup_2p.c +13 -1
- package/examples/gg/templates/platformer.c +4 -0
- package/examples/gg/templates/puzzle.c +80 -14
- package/examples/gg/templates/racing.c +17 -1
- package/examples/gg/templates/shmup.c +17 -1
- package/examples/gg/templates/sports.c +4 -0
- package/examples/lynx/templates/platformer.c +25 -6
- package/examples/lynx/templates/puzzle.c +77 -14
- package/examples/lynx/templates/shmup.c +13 -1
- package/examples/lynx/templates/sports.c +5 -2
- package/examples/msx/platformer/main.c +2 -0
- package/examples/msx/puzzle/main.c +78 -15
- package/examples/msx/racing/main.c +1 -0
- package/examples/msx/shmup/main.c +1 -0
- package/examples/msx/sports/main.c +3 -2
- package/examples/nes/templates/platformer.c +11 -3
- package/examples/nes/templates/puzzle.c +81 -21
- package/examples/nes/templates/racing.c +15 -1
- package/examples/nes/templates/shmup.c +1 -0
- package/examples/nes/templates/sports.c +1 -0
- package/examples/pce/platformer/main.c +3 -1
- package/examples/pce/puzzle/main.c +78 -12
- package/examples/pce/racing/main.c +1 -0
- package/examples/pce/shmup/main.c +5 -4
- package/examples/pce/sports/main.c +4 -3
- package/examples/sms/templates/platformer.c +4 -0
- package/examples/sms/templates/puzzle.c +80 -14
- package/examples/sms/templates/racing.c +17 -1
- package/examples/sms/templates/shmup.c +17 -1
- package/examples/sms/templates/shmup_2p.c +17 -1
- package/examples/sms/templates/sports.c +4 -0
- package/examples/snes/templates/platformer.c +32 -15
- package/examples/snes/templates/puzzle.c +84 -16
- package/examples/snes/templates/racing.c +20 -1
- package/examples/snes/templates/shmup.c +20 -2
- package/examples/snes/templates/sports.c +7 -0
- package/package.json +12 -12
- package/src/cores/wasm/bluemsx_libretro.js +1 -1
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +1 -1
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +1 -1
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +1 -1
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +1 -1
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +1 -1
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +1 -1
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +1 -1
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +1 -1
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +245 -10
- package/src/mcp/server.js +6 -0
- package/src/mcp/tools/disasm-rebuild.js +315 -65
- package/src/mcp/tools/disasm.js +149 -28
- package/src/mcp/tools/find-references.js +216 -51
- package/src/mcp/tools/frame.js +11 -4
- package/src/mcp/tools/index.js +15 -1
- package/src/mcp/tools/input.js +26 -3
- package/src/mcp/tools/memory.js +208 -39
- package/src/mcp/tools/playtest.js +56 -4
- package/src/mcp/tools/project.js +35 -9
- package/src/mcp/tools/toolchain.js +43 -10
- package/src/mcp/tools/watch-memory.js +172 -25
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
- package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
- package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
- package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
- package/src/platforms/gb/MENTAL_MODEL.md +16 -1
- package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
- package/src/platforms/gb/lib/c/patch-header.js +7 -4
- package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
- package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
- package/src/platforms/gbc/lib/c/font.h +43 -0
- package/src/platforms/gbc/lib/c/patch-header.js +7 -4
- package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
- package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
- package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
- package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
- package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
- package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
- package/src/platforms/msx/MENTAL_MODEL.md +6 -0
- package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
- package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
- package/src/platforms/msx/lib/c/msx_hw.h +2 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
- package/src/platforms/nes/MENTAL_MODEL.md +10 -3
- package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
- package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
- package/src/platforms/pce/MENTAL_MODEL.md +9 -0
- package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
- package/src/platforms/pce/lib/c/pce_hw.h +2 -1
- package/src/platforms/pce/lib/c/pce_sound.c +22 -0
- package/src/platforms/sms/MENTAL_MODEL.md +5 -0
- package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
- package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
- package/src/platforms/snes/MENTAL_MODEL.md +5 -0
- package/src/playtest/playtest.js +73 -3
- 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
|
|
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
|
|
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:
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
319
|
-
|
|
320
|
-
|
|
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-
|
|
334
|
-
for
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
|
361
|
-
|
|
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
|
+
```
|