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