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
package/src/host/LibretroHost.js
CHANGED
|
@@ -43,6 +43,12 @@ const PLATFORM_CORE_OPTIONS = {
|
|
|
43
43
|
// `… - C-BIOS` machine tree ships in romdev-core-bluemsx/bios and is mirrored
|
|
44
44
|
// into the wasm FS as the system dir (see loadMedia + resolveSystemDir).
|
|
45
45
|
msx: { bluemsx_msxtype: "MSX2+ - C-BIOS" },
|
|
46
|
+
// geargrafx ships with the TurboTap disabled, which makes port-1 input
|
|
47
|
+
// unreachable in-game (every pad scan slot mirrors pad 0). Enabling it
|
|
48
|
+
// costs nothing for 1P games (slot 0 still reads pad 0) and routes the
|
|
49
|
+
// host's port-1 input to pad slot 2 — PCE 2P works (probed 2026-06-10
|
|
50
|
+
// during the ZENITH BARRAGE gold round).
|
|
51
|
+
pce: { geargrafx_turbotap: "Enabled" },
|
|
46
52
|
// VICE mounts a .d64/.tap/.crt but, with autostart off, just sits at the BASIC
|
|
47
53
|
// `READY.` prompt — the agent would see a blue boot screen, not the game. Force
|
|
48
54
|
// autostart so a disk/tape image runs the first program automatically (same as
|
|
@@ -57,6 +63,19 @@ const PLATFORM_CORE_OPTIONS = {
|
|
|
57
63
|
// files back into the .d64 (VICE updates the in-FS image in place). Without
|
|
58
64
|
// this a game's SAVE silently fails / errors — defeating disk-save support.
|
|
59
65
|
vice_floppy_write_protection: "disabled",
|
|
66
|
+
// TWO live C64 control ports so 2P games see player 2. The VICE core drives
|
|
67
|
+
// ONE control port per RetroPad by default (every retro port → cur_port);
|
|
68
|
+
// the per-port split only happens with the userport adapter, where the
|
|
69
|
+
// mapper does vice_port = cur_port + retro_port. With joyport=1 + a userport
|
|
70
|
+
// adapter that gives retro0→control-port-1, retro1→control-port-2 — BOTH
|
|
71
|
+
// standard ports live. Our games read P1 on control port 2 ($DC00) and P2
|
|
72
|
+
// on port 1 ($DC01); the host swaps the two retro ports below
|
|
73
|
+
// (portInputToMask C64 path) so host port 0 = P1 (control port 2) and host
|
|
74
|
+
// port 1 = P2 (control port 1), matching the universal "port 0 = player 1"
|
|
75
|
+
// convention. Verified: drives both paddles independently in 2P, 1P-vs-CPU
|
|
76
|
+
// still reachable. (Both options ship in the wasm — no core rebuild.)
|
|
77
|
+
vice_joyport: "1",
|
|
78
|
+
vice_userport_joytype: "HIT",
|
|
60
79
|
},
|
|
61
80
|
};
|
|
62
81
|
|
|
@@ -415,6 +434,9 @@ export class LibretroHost {
|
|
|
415
434
|
|
|
416
435
|
// Configure controller port 0 as joypad (some cores default to NONE).
|
|
417
436
|
mod._retro_set_controller_port_device(0, RETRO_DEVICE_JOYPAD);
|
|
437
|
+
// Port 1 too — needed for 2P. The C64/VICE 2P path (two live control ports)
|
|
438
|
+
// only reads RetroPad port 1 when it's registered as a joypad device.
|
|
439
|
+
mod._retro_set_controller_port_device(1, RETRO_DEVICE_JOYPAD);
|
|
418
440
|
|
|
419
441
|
// ---- Settle the framebuffer to the ROM's chosen geometry ----
|
|
420
442
|
//
|
|
@@ -623,7 +645,14 @@ export class LibretroHost {
|
|
|
623
645
|
this._applyC64ButtonKeys(input.ports[0] || {});
|
|
624
646
|
}
|
|
625
647
|
for (let port = 0; port < this.state.inputPorts.length; port++) {
|
|
626
|
-
|
|
648
|
+
// C64 2P port swap: with joyport=1 + userport the VICE mapper binds
|
|
649
|
+
// RetroPad 0 → C64 control port 1 and RetroPad 1 → control port 2. But
|
|
650
|
+
// our games read player 1 on control port 2 ($DC00) and player 2 on
|
|
651
|
+
// control port 1 ($DC01). So feed host port 0's input to RetroPad slot 1
|
|
652
|
+
// (→ control port 2 = P1) and host port 1's to slot 0 (→ port 1 = P2),
|
|
653
|
+
// restoring the universal "host port 0 = player 1" convention.
|
|
654
|
+
const srcPort = platform === "c64" ? (port ^ 1) : port;
|
|
655
|
+
const portInput = this._c64StripKeyButtons(input.ports[srcPort], platform);
|
|
627
656
|
this.state.inputPorts[port][0] = portInputToMask(portInput, platform);
|
|
628
657
|
}
|
|
629
658
|
}
|
|
@@ -776,6 +805,18 @@ export class LibretroHost {
|
|
|
776
805
|
return mod._retro_get_memory_size(id) || 0;
|
|
777
806
|
}
|
|
778
807
|
|
|
808
|
+
/** gpgx stores 68k work RAM as 16-bit words in host-LE order — the CPU's
|
|
809
|
+
* byte at $FF0000+A physically lives at work_ram[A^1] (core/macros.h
|
|
810
|
+
* READ_BYTE/WRITE_BYTE on little-endian builds). Normalize the system_ram
|
|
811
|
+
* region to CPU byte order here so offset X IS the byte the 68k sees at
|
|
812
|
+
* $FF0000+X — otherwise every byte-granular tool (search, diff, write,
|
|
813
|
+
* classify) is off-by-XOR-1 vs disassembly addresses and cheat-DB maps
|
|
814
|
+
* (self-consistent within raw-only loops, which is why it hid; poisonous
|
|
815
|
+
* the moment an address crosses to/from the CPU view). */
|
|
816
|
+
_byteSwapRegion(region) {
|
|
817
|
+
return region === "system_ram" && this.status.platform === "genesis";
|
|
818
|
+
}
|
|
819
|
+
|
|
779
820
|
readMemory(region, offset, length) {
|
|
780
821
|
const mod = this._needMod();
|
|
781
822
|
const id = MemoryRegionToRetro[region];
|
|
@@ -786,6 +827,12 @@ export class LibretroHost {
|
|
|
786
827
|
if (offset < 0 || offset + length > size) {
|
|
787
828
|
throw new RangeError(`read out of bounds: offset=${offset} len=${length} size=${size}`);
|
|
788
829
|
}
|
|
830
|
+
if (this._byteSwapRegion(region)) {
|
|
831
|
+
const heap = mod.HEAPU8;
|
|
832
|
+
const out = new Uint8Array(length);
|
|
833
|
+
for (let i = 0; i < length; i++) out[i] = heap[ptr + ((offset + i) ^ 1)];
|
|
834
|
+
return out;
|
|
835
|
+
}
|
|
789
836
|
return new Uint8Array(mod.HEAPU8.buffer, ptr + offset, length).slice();
|
|
790
837
|
}
|
|
791
838
|
|
|
@@ -804,6 +851,11 @@ export class LibretroHost {
|
|
|
804
851
|
if (offset < 0 || offset + bytes.length > size) {
|
|
805
852
|
throw new RangeError(`write out of bounds: offset=${offset} len=${bytes.length} size=${size}`);
|
|
806
853
|
}
|
|
854
|
+
if (this._byteSwapRegion(region)) {
|
|
855
|
+
const heap = mod.HEAPU8;
|
|
856
|
+
for (let i = 0; i < bytes.length; i++) heap[ptr + ((offset + i) ^ 1)] = bytes[i];
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
807
859
|
mod.HEAPU8.set(bytes, ptr + offset);
|
|
808
860
|
}
|
|
809
861
|
|
|
@@ -1072,7 +1124,36 @@ export class LibretroHost {
|
|
|
1072
1124
|
this.reset();
|
|
1073
1125
|
return false;
|
|
1074
1126
|
}
|
|
1127
|
+
// Battery semantics: SAVE_RAM survives a power-cycle on a battery cart.
|
|
1128
|
+
// Carry it across the reload (the reload itself zeroes it).
|
|
1129
|
+
let sram = null;
|
|
1130
|
+
try {
|
|
1131
|
+
const size = this.regionSize("save_ram");
|
|
1132
|
+
if (size > 0) sram = Uint8Array.from(this.readMemory("save_ram", 0, size));
|
|
1133
|
+
} catch { /* no save_ram region on this core/cart — nothing to carry */ }
|
|
1075
1134
|
await this.loadMedia(this._loadArgs);
|
|
1135
|
+
if (sram && sram.some((b) => b !== 0)) {
|
|
1136
|
+
// Restore like a frontend restores the .srm: bytes in place BEFORE the
|
|
1137
|
+
// game's boot code reads them. Some cores size SAVE_RAM lazily (gpgx
|
|
1138
|
+
// scans for the last non-empty byte → size 0 on a fresh boot), so fall
|
|
1139
|
+
// back to the raw region pointer when the sized path refuses.
|
|
1140
|
+
let restored = false;
|
|
1141
|
+
try {
|
|
1142
|
+
if (this.regionSize("save_ram") >= sram.length) {
|
|
1143
|
+
this.writeMemory("save_ram", 0, sram);
|
|
1144
|
+
restored = true;
|
|
1145
|
+
}
|
|
1146
|
+
} catch { /* sized path unavailable */ }
|
|
1147
|
+
if (!restored) {
|
|
1148
|
+
try {
|
|
1149
|
+
const ptr = this.mod._retro_get_memory_data(0); // RETRO_MEMORY_SAVE_RAM
|
|
1150
|
+
if (ptr) { this.mod.HEAPU8.set(sram, ptr); restored = true; }
|
|
1151
|
+
} catch { /* no save buffer on this core/cart */ }
|
|
1152
|
+
}
|
|
1153
|
+
// loadMedia's settle frames may already have run the game's
|
|
1154
|
+
// hi-score load against empty SRAM — soft-reset so boot re-reads.
|
|
1155
|
+
if (restored) { try { this.reset(); } catch { /* keep the loaded state */ } }
|
|
1156
|
+
}
|
|
1076
1157
|
return true;
|
|
1077
1158
|
}
|
|
1078
1159
|
|
|
@@ -1263,6 +1344,161 @@ export class LibretroHost {
|
|
|
1263
1344
|
return !!(this.mod && typeof this.mod._romdev_watchdog_set === "function");
|
|
1264
1345
|
}
|
|
1265
1346
|
|
|
1347
|
+
/** True when this core build exposes the at-hit register snapshot (gpgx). */
|
|
1348
|
+
regSnapSupported() {
|
|
1349
|
+
return !!(this.mod && typeof this.mod._romdev_regsnap_get === "function");
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Read the at-hit register snapshot: the FULL register file frozen by the
|
|
1354
|
+
* core hook at the instant a pc-break / watchdog / write-watch / read-watch
|
|
1355
|
+
* fired. The live register file keeps running after a hit (per-scanline CPU
|
|
1356
|
+
* scheduling / next-frame re-entry), so post-hit register reads drift —
|
|
1357
|
+
* this snapshot is the truth. Shipped by ALL patched cores (all 14
|
|
1358
|
+
* platforms). Returns { kind, named } or null when no hit has been
|
|
1359
|
+
* snapshotted (or the core build predates the export). kind:
|
|
1360
|
+
* 1=pc-break/step, 2=watchdog, 3=write-watch, 4=read-watch. `named` keys
|
|
1361
|
+
* follow each CPU's own register file; `pc` is always the EXECUTING
|
|
1362
|
+
* instruction (ARM: its pipeline PC, the same convention breakpoint
|
|
1363
|
+
* addresses use). Pass clear to reset the kind.
|
|
1364
|
+
*/
|
|
1365
|
+
getRegSnapshot(clear = false) {
|
|
1366
|
+
const mod = this.mod;
|
|
1367
|
+
if (!mod || typeof mod._romdev_regsnap_get !== "function") return null;
|
|
1368
|
+
const ptr = mod._malloc(21 * 4);
|
|
1369
|
+
try {
|
|
1370
|
+
mod._romdev_regsnap_get(ptr, clear ? 1 : 0);
|
|
1371
|
+
const u = new Uint32Array(mod.HEAPU8.buffer, ptr, 21);
|
|
1372
|
+
const kind = u[0];
|
|
1373
|
+
if (!kind) return null;
|
|
1374
|
+
const r = Array.from(u.subarray(2, 2 + Math.min(u[1] >>> 0, 19)));
|
|
1375
|
+
const platform = this.status.platform;
|
|
1376
|
+
const h2 = (v) => "$" + (v & 0xFF).toString(16).toUpperCase();
|
|
1377
|
+
const h4 = (v) => "$" + (v & 0xFFFF).toString(16).toUpperCase();
|
|
1378
|
+
const hx = (v) => "$" + (v >>> 0).toString(16).toUpperCase();
|
|
1379
|
+
let named;
|
|
1380
|
+
if (platform === "genesis") {
|
|
1381
|
+
// m68k regId order: D0-7, A0-7, PC(instr start), SR, SP.
|
|
1382
|
+
named = {};
|
|
1383
|
+
for (let i = 0; i < 8; i++) named["d" + i] = hx(r[i]);
|
|
1384
|
+
for (let i = 0; i < 8; i++) named["a" + i] = hx(r[8 + i]);
|
|
1385
|
+
named.pc = hx(r[16]);
|
|
1386
|
+
named.sr = h4(r[17]);
|
|
1387
|
+
named.sp = hx(r[18]);
|
|
1388
|
+
} else if (platform === "gba") {
|
|
1389
|
+
// ARM regId order: r0-r15 raw, CPSR at 16, instr pipeline PC at 17, SP at 18.
|
|
1390
|
+
named = {};
|
|
1391
|
+
for (let i = 0; i < 16; i++) named["r" + i] = hx(r[i]);
|
|
1392
|
+
named.cpsr = hx(r[16]);
|
|
1393
|
+
named.pc = hx(r[17]); // EXECUTING instruction's pipeline PC (pc-break convention)
|
|
1394
|
+
named.sp = hx(r[18]);
|
|
1395
|
+
} else if (platform === "snes") {
|
|
1396
|
+
// 65816 regId order: A, X, Y, P, S, DB, D, …, PBPC(instr start).
|
|
1397
|
+
named = {
|
|
1398
|
+
a: h4(r[0]), x: h4(r[1]), y: h4(r[2]), p: h4(r[3]), s: h4(r[4]),
|
|
1399
|
+
db: h2(r[5]), d: h4(r[6]), pc: hx(r[16]),
|
|
1400
|
+
};
|
|
1401
|
+
} else if (platform === "gb" || platform === "gbc") {
|
|
1402
|
+
named = {
|
|
1403
|
+
a: h2(r[0]), f: h2(r[1]), b: h2(r[2]), c: h2(r[3]),
|
|
1404
|
+
d: h2(r[4]), e: h2(r[5]), h: h2(r[6]), l: h2(r[7]),
|
|
1405
|
+
pc: h4(r[16]), sp: h4(r[18]),
|
|
1406
|
+
};
|
|
1407
|
+
} else if (platform === "sms" || platform === "gg" || platform === "msx") {
|
|
1408
|
+
named = {
|
|
1409
|
+
a: h2(r[0]), f: h2(r[1]), b: h2(r[2]), c: h2(r[3]),
|
|
1410
|
+
d: h2(r[4]), e: h2(r[5]), h: h2(r[6]), l: h2(r[7]),
|
|
1411
|
+
ix: h4(r[8]), iy: h4(r[9]), pc: h4(r[16]), sp: h4(r[18]),
|
|
1412
|
+
};
|
|
1413
|
+
} else {
|
|
1414
|
+
// 6502 family (nes, atari2600, atari7800, c64, lynx, pce/huc6280):
|
|
1415
|
+
// regId order A, X, Y, P, S, …, PC(instr start).
|
|
1416
|
+
named = {
|
|
1417
|
+
a: h2(r[0]), x: h2(r[1]), y: h2(r[2]), p: h2(r[3]), s: h2(r[4]),
|
|
1418
|
+
pc: h4(r[16]),
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
return { kind, named };
|
|
1422
|
+
} finally {
|
|
1423
|
+
mod._free(ptr);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/** True when this core build exposes the pure-CPU run (gpgx). */
|
|
1428
|
+
runPureSupported() {
|
|
1429
|
+
return !!(this.mod && typeof this.mod._romdev_run_pure === "function");
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** True when this core build exposes the interrupt block (the pure-call
|
|
1433
|
+
* primitive on cores without a separable CPU loop). */
|
|
1434
|
+
irqBlockSupported() {
|
|
1435
|
+
return !!(this.mod && typeof this.mod._romdev_irqblock_set === "function");
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** Suppress (or restore) interrupt DELIVERY to the active CPU. While
|
|
1439
|
+
* blocked, pending IRQ/NMI lines stay pending and no game handler can run
|
|
1440
|
+
* — the mechanism behind pure calls on cores whose CPU/video loops are
|
|
1441
|
+
* interleaved (everything except gpgx, which steps the CPU alone). */
|
|
1442
|
+
setIrqBlock(on) {
|
|
1443
|
+
const mod = this._needMod();
|
|
1444
|
+
if (typeof mod._romdev_irqblock_set !== "function") {
|
|
1445
|
+
throw new Error("this core build does not expose the interrupt block (rebuild with romdev_irqblock_set).");
|
|
1446
|
+
}
|
|
1447
|
+
mod._romdev_irqblock_set(on ? 1 : 0);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** True when a pure call is possible on this platform by ANY mechanism:
|
|
1451
|
+
* a separable CPU run (gpgx), an interrupt block, or hardware with no
|
|
1452
|
+
* interrupts at all (the 2600's 6507 has no IRQ/NMI lines wired — every
|
|
1453
|
+
* call is inherently pure). */
|
|
1454
|
+
pureCallSupported() {
|
|
1455
|
+
return this.runPureSupported() || this.irqBlockSupported() || this.status.platform === "atari2600";
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/** True when this core build exposes the VRAM-port copy trace (port-based
|
|
1459
|
+
* video memory: NES/SNES/PCE/MSX/SMS/GG/Genesis). Direct-mapped platforms
|
|
1460
|
+
* answer the same question through watchRange on the CPU-visible VRAM. */
|
|
1461
|
+
vramWatchSupported() {
|
|
1462
|
+
const mod = this.mod;
|
|
1463
|
+
return !!(mod && typeof mod._romdev_vramwatch_set === "function" && typeof mod._romdev_vramwatch_get === "function");
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* Run `frames` frames logging every data-port write landing in the VRAM
|
|
1468
|
+
* address window [lo,hi] — {vramAddr, pc, value} per event, pc being the
|
|
1469
|
+
* EXECUTING instruction (during DMA on SNES, the instruction that triggered
|
|
1470
|
+
* it). The "where does this graphic come from?" primitive for port-based
|
|
1471
|
+
* video memory. Returns { events, total, stored, truncated }.
|
|
1472
|
+
*/
|
|
1473
|
+
watchVram(lo, hi, frames, perFrame) {
|
|
1474
|
+
const mod = this._needMod();
|
|
1475
|
+
this._needMedia();
|
|
1476
|
+
if (!this.vramWatchSupported()) throw new Error("VRAM copy trace not supported by this core.");
|
|
1477
|
+
mod._romdev_vramwatch_set(lo >>> 0, hi >>> 0, 1);
|
|
1478
|
+
try {
|
|
1479
|
+
this._runFramesExclusive(perFrame ?? (() => false), frames);
|
|
1480
|
+
} finally {
|
|
1481
|
+
// drained below; disarm after
|
|
1482
|
+
}
|
|
1483
|
+
const CAP = 1024;
|
|
1484
|
+
const outPtr = mod._malloc(CAP * 3 * 4);
|
|
1485
|
+
const out2Ptr = mod._malloc(8);
|
|
1486
|
+
try {
|
|
1487
|
+
const n = mod._romdev_vramwatch_get(outPtr, CAP, out2Ptr);
|
|
1488
|
+
const u = new Uint32Array(mod.HEAPU8.buffer, outPtr, n * 3);
|
|
1489
|
+
const u2 = new Uint32Array(mod.HEAPU8.buffer, out2Ptr, 2);
|
|
1490
|
+
const events = [];
|
|
1491
|
+
for (let i = 0; i < n; i++) {
|
|
1492
|
+
events.push({ vramAddr: u[i * 3], pc: u[i * 3 + 1], value: u[i * 3 + 2] & 0xFF });
|
|
1493
|
+
}
|
|
1494
|
+
return { events, total: u2[0], stored: u2[1], truncated: u2[0] > u2[1] };
|
|
1495
|
+
} finally {
|
|
1496
|
+
mod._free(outPtr);
|
|
1497
|
+
mod._free(out2Ptr);
|
|
1498
|
+
mod._romdev_vramwatch_set(0, 0, 0);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1266
1502
|
/** Arm/disarm the read watchpoint on a CPU address. */
|
|
1267
1503
|
setReadWatch(address, enabled = true) {
|
|
1268
1504
|
const mod = this._needMod();
|
|
@@ -1488,6 +1724,13 @@ export class LibretroHost {
|
|
|
1488
1724
|
const {
|
|
1489
1725
|
pc, regs = {}, spReg = prof.spReg, pcReg = prof.pcReg, sentinelPC = prof.defaultSentinel,
|
|
1490
1726
|
sentinelBytes = prof.retBytes, maxFrames = 600, sandbox = true, capture,
|
|
1727
|
+
// pure: step ONLY the active CPU (no frame machinery — VDP lines, co-CPU,
|
|
1728
|
+
// interrupt raising). Without it, each "frame" of the call runs the
|
|
1729
|
+
// game's OWN per-frame logic concurrently (VBlank handlers via RAM
|
|
1730
|
+
// vectors etc.), which can stomp the buffer the driven routine is
|
|
1731
|
+
// writing — a real session diffed a CORRECT codec reimplementation
|
|
1732
|
+
// against that poisoned output for hours. gpgx (Genesis/SMS/GG) only.
|
|
1733
|
+
pure = false,
|
|
1491
1734
|
// presetMemory: [{addr, bytes}] CPU-space writes applied before the call
|
|
1492
1735
|
// (codecs that read a global from RAM — a dest stride, a mode flag, etc).
|
|
1493
1736
|
presetMemory = [],
|
|
@@ -1523,6 +1766,7 @@ export class LibretroHost {
|
|
|
1523
1766
|
|
|
1524
1767
|
const snapshot = sandbox ? this.serializeState() : null;
|
|
1525
1768
|
let captured, returned = false, framesRun = 0, watchdogTripped = false, stoppedAtPC = false;
|
|
1769
|
+
let pureMode = null;
|
|
1526
1770
|
try {
|
|
1527
1771
|
// Apply pre-call memory writes (CPU-space).
|
|
1528
1772
|
for (const m of presetMemory) {
|
|
@@ -1575,19 +1819,67 @@ export class LibretroHost {
|
|
|
1575
1819
|
const target = (stopAtPC !== undefined ? stopAtPC : sentinelPC) >>> 0;
|
|
1576
1820
|
this.setPCBreak(target, true, false);
|
|
1577
1821
|
let finalState = null;
|
|
1822
|
+
let irqBlocked = false;
|
|
1578
1823
|
try {
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1824
|
+
if (pure && this.runPureSupported()) {
|
|
1825
|
+
// STRONGEST pure mode (gpgx): step ONLY the CPU — no frame machinery
|
|
1826
|
+
// at all. Mask m68k interrupts so a PENDING VINT raised before the
|
|
1827
|
+
// call can't redirect entry (no NEW interrupts are raised — the
|
|
1828
|
+
// system loop never runs). The sandbox restore (or the game's own
|
|
1829
|
+
// RTE discipline) makes the IPL change invisible afterward.
|
|
1830
|
+
pureMode = "cpu-only";
|
|
1831
|
+
if (this.status.platform === "genesis") {
|
|
1832
|
+
this.setReg(17, (this.getReg(17) | 0x0700) & 0xFFFF);
|
|
1587
1833
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1834
|
+
// Drive the CPU in cycle chunks, checking the sentinel/watchdog
|
|
1835
|
+
// between chunks. The watchdog bounds total instructions; the chunk
|
|
1836
|
+
// cap is only a backstop against a watchdog-less older core.
|
|
1837
|
+
const CHUNK_CYCLES = 1_000_000;
|
|
1838
|
+
const maxChunks = 512;
|
|
1839
|
+
for (let i = 0; i < maxChunks; i++) {
|
|
1840
|
+
this.mod._romdev_run_pure(CHUNK_CYCLES);
|
|
1841
|
+
const st = this.getPCBreak(false);
|
|
1842
|
+
if (st.hit) {
|
|
1843
|
+
finalState = st;
|
|
1844
|
+
if (st.watchdog) watchdogTripped = true;
|
|
1845
|
+
else if (stopAtPC !== undefined) stoppedAtPC = true;
|
|
1846
|
+
else returned = true;
|
|
1847
|
+
break;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
} else {
|
|
1851
|
+
if (pure) {
|
|
1852
|
+
// INTERRUPT-BLOCKED pure mode (every other core): the frame
|
|
1853
|
+
// machinery still runs (video/timers advance — harmless, they
|
|
1854
|
+
// don't write game RAM), but interrupt DELIVERY is suppressed, so
|
|
1855
|
+
// no game handler can execute. The only running game code is the
|
|
1856
|
+
// routine we called — the same guarantee that matters for the
|
|
1857
|
+
// output buffer. The 2600's 6507 has no interrupt lines at all,
|
|
1858
|
+
// so every call there is pure by hardware.
|
|
1859
|
+
if (this.irqBlockSupported()) {
|
|
1860
|
+
this.setIrqBlock(true);
|
|
1861
|
+
irqBlocked = true;
|
|
1862
|
+
pureMode = "irq-blocked";
|
|
1863
|
+
} else if (this.status.platform === "atari2600") {
|
|
1864
|
+
pureMode = "no-interrupts";
|
|
1865
|
+
} else {
|
|
1866
|
+
throw new Error("cpu({op:'call', pure:true}) not supported by this core build (needs the romdev_irqblock_set export — update the core package).");
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
framesRun = this._runFramesExclusive(() => {
|
|
1870
|
+
const st = this.getPCBreak(false);
|
|
1871
|
+
if (st.hit) {
|
|
1872
|
+
finalState = st;
|
|
1873
|
+
if (st.watchdog) watchdogTripped = true;
|
|
1874
|
+
else if (stopAtPC !== undefined) stoppedAtPC = true;
|
|
1875
|
+
else returned = true;
|
|
1876
|
+
return true;
|
|
1877
|
+
}
|
|
1878
|
+
return false;
|
|
1879
|
+
}, maxFrames);
|
|
1880
|
+
}
|
|
1590
1881
|
} finally {
|
|
1882
|
+
if (irqBlocked) { try { this.setIrqBlock(false); } catch { /* core gone */ } }
|
|
1591
1883
|
this.setPCBreak(0, false, false);
|
|
1592
1884
|
this.setWatchdog(0);
|
|
1593
1885
|
if (!finalState) finalState = this.getPCBreak(true); else this.getPCBreak(true);
|
|
@@ -1607,6 +1899,7 @@ export class LibretroHost {
|
|
|
1607
1899
|
const fin = this._lastCallResult || {};
|
|
1608
1900
|
return {
|
|
1609
1901
|
returned, framesRun,
|
|
1902
|
+
...(pure ? { pure: true, pureMode } : {}),
|
|
1610
1903
|
...(watchdogTripped ? { watchdog: true, reason: "watchdog: hit the instruction budget (likely a runaway loop — wrong A0/regs, a needed preset, or legitimately huge; raise maxInstructions or check the entry setup)" } : {}),
|
|
1611
1904
|
...(stoppedAtPC ? { stoppedAtPC: "$" + (stopAtPC >>> 0).toString(16).toUpperCase() } : {}),
|
|
1612
1905
|
...(fin.finalPC != null ? { finalPC: "$" + fin.finalPC.toString(16).toUpperCase(), finalPCRaw: fin.finalPC } : {}),
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { registerTools } from "../mcp/tools/index.js";
|
|
19
19
|
import { withClearToolErrors } from "../mcp/util.js";
|
|
20
|
-
import { observer, summarizeForLog, extractImages } from "../observer/bus.js";
|
|
20
|
+
import { observer, summarizeForLog, extractImages, pushObserverFrame } from "../observer/bus.js";
|
|
21
21
|
import { getHostOrNull } from "../mcp/state.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -151,21 +151,21 @@ export async function runTool(tool, args, sessionKey) {
|
|
|
151
151
|
// Strip both from the caller-visible result before it's serialized.
|
|
152
152
|
let sidebandImages = [];
|
|
153
153
|
let frameProvider = null;
|
|
154
|
+
let frameCaption = null;
|
|
154
155
|
if (r && typeof r === "object") {
|
|
155
156
|
if (Array.isArray(r._observerImages)) { sidebandImages = r._observerImages; delete r._observerImages; }
|
|
156
157
|
if (typeof r._observerFrameProvider === "function") { frameProvider = r._observerFrameProvider; delete r._observerFrameProvider; }
|
|
158
|
+
if (typeof r._observerFrameCaption === "string") { frameCaption = r._observerFrameCaption; delete r._observerFrameCaption; }
|
|
157
159
|
}
|
|
158
160
|
if (frameProvider) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
} catch { /* livestream is best-effort; never affects the caller */ }
|
|
168
|
-
});
|
|
161
|
+
// Throttled to one per 2s per (session, tool), trailing-edge — same
|
|
162
|
+
// policy as the MCP path (bus.js pushObserverFrame). Platform is
|
|
163
|
+
// re-resolved at emit time (loadMedia sets it DURING the call).
|
|
164
|
+
pushObserverFrame({
|
|
165
|
+
sessionKey, tool: tool.name, ts: startedAt, platform,
|
|
166
|
+
resolvePlatform: () => { try { return getHostOrNull(sessionKey)?.status?.platform ?? platform; } catch { return platform; } },
|
|
167
|
+
...(frameCaption ? { caption: frameCaption } : {}),
|
|
168
|
+
}, frameProvider);
|
|
169
169
|
}
|
|
170
170
|
const inlineImages = extractImages(r);
|
|
171
171
|
const images = inlineImages.length > 0 ? inlineImages : sidebandImages;
|
package/src/mcp/server.js
CHANGED
|
@@ -418,6 +418,12 @@ async function main() {
|
|
|
418
418
|
log.info(`optional observer: http://${bannerHost}:${port}/livestream`);
|
|
419
419
|
log.info("");
|
|
420
420
|
log.info("connect your coding agent: https://github.com/monteslu/romdev#connect");
|
|
421
|
+
// One conditional line so an agent knows the constraint BEFORE promising a
|
|
422
|
+
// playtest window to a human (the op itself still errors with the full fix).
|
|
423
|
+
if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
424
|
+
log.info("");
|
|
425
|
+
log.info("note: no display detected (headless) — playtest({op:'open'}) is unavailable; all other tools work.");
|
|
426
|
+
}
|
|
421
427
|
});
|
|
422
428
|
const extraServers = [];
|
|
423
429
|
for (const h of bindHosts.slice(1)) {
|
package/src/mcp/tools/cheats.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { getHost } from "../state.js";
|
|
4
4
|
import { jsonContent, safeTool } from "../util.js";
|
|
5
|
+
import { attachObserverFrame } from "./watch-memory.js";
|
|
5
6
|
import { lookupCheats, searchCheatGames } from "../../cheats/lookup.js";
|
|
6
7
|
import { encodeForDevice, nativeDevicesFor, decodeCode } from "../../cheats/gamegenie.js";
|
|
7
8
|
|
|
@@ -334,7 +335,7 @@ export function registerCheatTools(server, z, sessionKey) {
|
|
|
334
335
|
switch (args.op) {
|
|
335
336
|
case "lookup": return jsonContent(await cheatsLookupCore(args));
|
|
336
337
|
case "search": return jsonContent(await cheatsSearchCore(args));
|
|
337
|
-
case "apply": return jsonContent(await cheatsApplyCore(args, sessionKey));
|
|
338
|
+
case "apply": return attachObserverFrame(jsonContent(await cheatsApplyCore(args, sessionKey)), getHost(sessionKey), "cheat applied");
|
|
338
339
|
case "clear": return jsonContent(await cheatsClearCore(args, sessionKey));
|
|
339
340
|
case "make": return jsonContent(await cheatsMakeCore(args));
|
|
340
341
|
default: throw new Error(`cheats: unknown op '${args.op}'`);
|