romdevtools 0.27.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 +309 -0
- 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 +141 -24
- 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
package/src/mcp/tools/project.js
CHANGED
|
@@ -92,7 +92,7 @@ const TEMPLATES = {
|
|
|
92
92
|
linkerConfig: { presetSrc: "presets/nes/chr-ram-runtime.cfg", dst: "chr-ram-runtime.cfg" },
|
|
93
93
|
lang: "C (cc65)",
|
|
94
94
|
ext: ".nes",
|
|
95
|
-
describe: "Match-3 falling-block puzzle. 6×12 grid, 1×3 active piece (3 colors), rotate via A, soft-drop on DOWN,
|
|
95
|
+
describe: "Match-3 falling-block puzzle. 6×12 grid, 1×3 active piece (3 colors), rotate via A, soft-drop on DOWN, 3+-in-a-row clears in all 4 directions (H/V/diagonals) with gravity + cascade chains.",
|
|
96
96
|
},
|
|
97
97
|
sports: {
|
|
98
98
|
main: "templates/sports.c",
|
|
@@ -214,7 +214,7 @@ const TEMPLATES = {
|
|
|
214
214
|
],
|
|
215
215
|
lang: "C (SDCC sm83)",
|
|
216
216
|
ext: ".gb",
|
|
217
|
-
describe: "Match-3 falling-block puzzle scaffold for GB. 6×12 grid rendered via BG tilemap, 1×3 active piece (3 colours via 3 BG tile shapes), rotate via A, hard-drop on START,
|
|
217
|
+
describe: "Match-3 falling-block puzzle scaffold for GB. 6×12 grid rendered via BG tilemap, 1×3 active piece (3 colours via 3 BG tile shapes), rotate via A, hard-drop on START, 3+-in-a-row clears in all 4 directions (H/V/diagonals) with gravity + cascade chains.",
|
|
218
218
|
},
|
|
219
219
|
sports: {
|
|
220
220
|
main: "templates/sports.c",
|
|
@@ -284,7 +284,7 @@ const TEMPLATES = {
|
|
|
284
284
|
catch_game: mk("catch_game", "A complete tiny game: a paddle catches a falling object with the d-pad; full game loop with waitvsync(), two sprites, collision, scoring."),
|
|
285
285
|
shmup: mk("shmup", "Vertical shoot-'em-up for PC Engine. Player ship + bullet/enemy object pools, a wave spawner, AABB collisions, score HUD, scrolling-band starfield BG. d-pad flies, button I fires. The base for any action shooter."),
|
|
286
286
|
platformer: mk("platformer", "Side-scrolling platformer for PC Engine. Gravity + jump + land-on-top platform collision, a multi-screen world streamed via BG X-scroll (BXR), solid platform tiles, sub-pixel physics. d-pad moves, button I jumps."),
|
|
287
|
-
puzzle: mk("puzzle", "Match-3 / falling-block puzzle for PC Engine. A 6x12 well drawn with BG tiles, a 1x3 active piece you move/rotate/soft-drop/hard-drop,
|
|
287
|
+
puzzle: mk("puzzle", "Match-3 / falling-block puzzle for PC Engine. A 6x12 well drawn with BG tiles, a 1x3 active piece you move/rotate/soft-drop/hard-drop, 3+-in-a-row clears (H+V) with gravity + cascade chains, score. d-pad moves, I rotates, II hard-drops."),
|
|
288
288
|
sports: mk("sports", "Pong-style sports game for PC Engine. Two paddles + a bouncing ball on a netted court, score to 9, paddle-deflect physics; player 2 falls back to chase-AI when no input. d-pad moves P1."),
|
|
289
289
|
racing: mk("racing", "Top-down lane racer for PC Engine. Player car at the bottom, obstacle cars spawn from the top and slide down, LEFT/RIGHT switches lanes, speed grows with score, crash freeze + auto-reset. Scrolling road BG."),
|
|
290
290
|
};
|
|
@@ -305,7 +305,7 @@ const TEMPLATES = {
|
|
|
305
305
|
catch_game: mk("catch_game", "A complete tiny game: a paddle catches falling fruit with the joystick; full game loop with vblank sync, two sprites, collision, scoring."),
|
|
306
306
|
shmup: mk("shmup", "Vertical-shmup scaffold for MSX (screen 2). Player ship (sprite plane 0) + 4 bullet + 4 enemy object pools, a wave spawner, AABB collision, on-screen SCORE tiles, over a banded starfield filling the whole 32x24 name table. Joystick PORT 1 moves the ship (UP/DOWN/LEFT/RIGHT), trigger A (GTTRIG) fires; PSG blip on fire, noise-ish tone on a kill. Interrupt-free vsync via VDP status S#0. Extend with enemy fire, lives, scrolling stars."),
|
|
307
307
|
platformer: mk("platformer", "Side-scrolling platformer for MSX (screen 2). Subpixel gravity/jump/land-on-top collision against a table of platforms across a 512-px (64-cell) world, drawn by COLUMN STREAMING into the wrapping screen-2 name table as the camera follows the player; the player sprite draws in screen space. Joystick LEFT/RIGHT walks, trigger A jumps (only when grounded); PSG jump blip. Interrupt-free vsync. Extend with enemies, pickups, goal."),
|
|
308
|
-
puzzle: mk("puzzle", "Match-3 / falling-block puzzle for MSX (screen 2). A 6-wide x 12-tall well drawn with the BG tilemap (distinct R/G/B cell tiles + grey border + dim field interior so the playfield is always visible). A 1x3 active piece: joystick LEFT/RIGHT shifts, trigger A rotates the colour order, DOWN soft-drops, trigger B hard-drops;
|
|
308
|
+
puzzle: mk("puzzle", "Match-3 / falling-block puzzle for MSX (screen 2). A 6-wide x 12-tall well drawn with the BG tilemap (distinct R/G/B cell tiles + grey border + dim field interior so the playfield is always visible). A 1x3 active piece: joystick LEFT/RIGHT shifts, trigger A rotates the colour order, DOWN soft-drops, trigger B hard-drops; 3+-in-a-row clears in all 4 directions with gravity + cascade chains; PSG chime per clear. Interrupt-free vsync. Extend with levels/next-piece preview."),
|
|
309
309
|
sports: mk("sports", "Pong-style 2-player sports for MSX (screen 2). Court (green field + white sidelines + dashed centre net) fills the 32x24 name table; two paddles (stacked sprites) + a ball. Player 1 = joystick PORT 1 UP/DOWN; Player 2 = joystick PORT 2 UP/DOWN, falling back to chase-the-ball AI when no second pad is present so it is playable solo. Wall/paddle bounces + scoring with PSG bonks. Interrupt-free vsync. Extend with serve angles, score display, win condition."),
|
|
310
310
|
racing: mk("racing", "Top-down 3-lane racing for MSX (screen 2). Grey road + green-grass shoulders fill the name table; player car at the bottom, obstacle cars (object pool) spawn at the top and slide down. Joystick LEFT/RIGHT (edge-detected) switches lanes; obstacle speed grows with score; an AABB crash triggers a ~60-frame freeze then auto-reset, with a PSG crash tone. SCORE drawn as tiles. Interrupt-free vsync. Extend with pseudo-3D road, fuel, multiple cars."),
|
|
311
311
|
};
|
|
@@ -351,9 +351,19 @@ TEMPLATES.gbc = {
|
|
|
351
351
|
describe: "SIDE-SCROLLING platformer for GBC. Full CGB color palette (BG + sprite via BCPS/OCPS) over the GB side-scroller core: subpixel gravity + jump + land-on-top collision against platforms across a 256-px world (the wrapping BG map). The camera follows the player and scrolls the BG via SCX; the player sprite draws in screen space. A=jump, d-pad=move. One BG map wide (no streaming) — for a wider world, stream a new BG-map column on each 8px camera step (window for a fixed HUD). See the GBC MENTAL_MODEL.md 'Horizontal scrolling'. Extend with enemies, goals, pickups.",
|
|
352
352
|
},
|
|
353
353
|
puzzle: {
|
|
354
|
-
main: "templates/puzzle.c",
|
|
354
|
+
main: "templates/puzzle.c",
|
|
355
|
+
runtime: [
|
|
356
|
+
...GBC_RUNTIME,
|
|
357
|
+
{ src: "lib/c/font.h", dst: "font.h" }, /* digits+A-Z 2bpp glyphs for the HUD */
|
|
358
|
+
],
|
|
355
359
|
lang: GBC_LANG, ext: ".gbc",
|
|
356
|
-
describe: "
|
|
360
|
+
describe: "Falling-jewel matcher for GBC (the polished reference puzzle). 8x17 well, 6 jewel colors with " +
|
|
361
|
+
"real CGB palettes, matches in all 4 directions (H/V/both diagonals), gravity + cascade chains, magic " +
|
|
362
|
+
"jewel every 18th piece, level speedup, 6-digit score, title + game-over screens, SFX + toggleable " +
|
|
363
|
+
"music. Rendering: falling column + NEXT preview are OAM sprites; the locked well is BG tiles via a " +
|
|
364
|
+
"COLLECT/FLUSH vblank queue with an idle scrub (writes outside vblank silently drop on this core — " +
|
|
365
|
+
"never bypass the queue). Statics need dataLoc 0xC200 (above shadow_oam at $C100) — the project build " +
|
|
366
|
+
"recipe sets that automatically.",
|
|
357
367
|
},
|
|
358
368
|
sports: {
|
|
359
369
|
main: "templates/sports.c", runtime: GBC_RUNTIME,
|
|
@@ -398,6 +408,11 @@ TEMPLATES.gbc = {
|
|
|
398
408
|
// against. Factored to a constant so adding a new template is a one-line
|
|
399
409
|
// change at the bottom.
|
|
400
410
|
const SMS_RUNTIME = [
|
|
411
|
+
// The crt0 ships IN the project (like GG/MSX) so the dir is genuinely
|
|
412
|
+
// self-contained: build({output:'project'}) routes it via the crt0 channel
|
|
413
|
+
// (projectBuildRecipe), and an external stock-SDCC rebuild has the real
|
|
414
|
+
// boot stub on disk instead of silently linking SDCC's non-booting one.
|
|
415
|
+
{ src: "lib/c/sms_crt0.s", dst: "sms_crt0.s" },
|
|
401
416
|
{ src: "lib/c/sms_hw.h", dst: "sms_hw.h" },
|
|
402
417
|
{ src: "lib/c/vdp_init.c", dst: "vdp_init.c" },
|
|
403
418
|
{ src: "lib/c/load_palette.c", dst: "load_palette.c" },
|
|
@@ -764,7 +779,7 @@ TEMPLATES.snes = {
|
|
|
764
779
|
runtimeDirs: SNES_PVSNESLIB_VENDOR_DIRS,
|
|
765
780
|
lang: "C (tcc-65816 + PVSnesLib)",
|
|
766
781
|
ext: ".sfc",
|
|
767
|
-
describe: "Match-3 falling-block puzzle for SNES. 6×12 grid (text mode), rotate/soft-drop/hard-drop,
|
|
782
|
+
describe: "Match-3 falling-block puzzle for SNES. 6×12 grid (text mode), rotate/soft-drop/hard-drop, 3+-in-a-row clears in all 4 directions with gravity + cascade chains. Rotate click + clear chime via bundled SPC700 sfx.",
|
|
768
783
|
},
|
|
769
784
|
sports: {
|
|
770
785
|
main: "templates/sports.c",
|
|
@@ -939,7 +954,7 @@ TEMPLATES.genesis = {
|
|
|
939
954
|
runtimeDirs: SGDK_RUNTIME_DIRS,
|
|
940
955
|
lang: SGDK_LANG,
|
|
941
956
|
ext: ".bin",
|
|
942
|
-
describe: "Match-3 falling-block puzzle genre scaffold. 6×12 grid, 1×3 active piece (3 colours), rotate via A, soft-drop on DOWN, hard-drop on START,
|
|
957
|
+
describe: "Match-3 falling-block puzzle genre scaffold. 6×12 grid, 1×3 active piece (3 colours), rotate via A, soft-drop on DOWN, hard-drop on START, 3+-in-a-row clears in all 4 directions with gravity + cascade chains. xorshift RNG so cell colours actually vary.",
|
|
943
958
|
},
|
|
944
959
|
sports: {
|
|
945
960
|
main: "templates/sports.c",
|
|
@@ -1757,7 +1772,18 @@ Compiles **C89**, not C99/C11. Stick to:
|
|
|
1757
1772
|
let filesSection = `## Files\n\n- \`${mainFilename}\` — your game's entry point.\n`;
|
|
1758
1773
|
if (tmpl?.runtime) {
|
|
1759
1774
|
for (const { dst } of tmpl.runtime) {
|
|
1760
|
-
|
|
1775
|
+
if (dst === "patch-header.js") {
|
|
1776
|
+
// NOT game code — calling it a "runtime helper" implied it compiles
|
|
1777
|
+
// into the ROM and confused readers. It's a standalone sidecar tool.
|
|
1778
|
+
filesSection += `- \`${dst}\` — sidecar TOOL, not game code (never compiled into the ROM). ` +
|
|
1779
|
+
`Stamps the Nintendo logo + header/global checksums a GB ROM needs to boot ` +
|
|
1780
|
+
`(\`node patch-header.js game.gb\`) — a zero-install stand-in for RGBDS's rgbfix when you ` +
|
|
1781
|
+
`rebuild OUTSIDE romdev with stock SDCC. romdev's own builds fix the header automatically.\n`;
|
|
1782
|
+
} else if (dst.endsWith("_crt0.s")) {
|
|
1783
|
+
filesSection += `- \`${dst}\` — startup assembly (reset/interrupt vectors, RAM init; routed as the crt0 by the project build). **You own this.**\n`;
|
|
1784
|
+
} else {
|
|
1785
|
+
filesSection += `- \`${dst}\` — runtime helper. **You own this** — edit or replace at will.\n`;
|
|
1786
|
+
}
|
|
1761
1787
|
}
|
|
1762
1788
|
}
|
|
1763
1789
|
if (tmpl?.crt0) {
|
|
@@ -266,7 +266,7 @@ export function installToolchainCore({ id }) {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
export function registerToolchainTools(server, z, sessionKey) {
|
|
269
|
-
async function buildSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, crt0, crt0Path, codeLoc, dataLoc, options, linkerConfig, inesHeader, outputPath, inline = false, includeSymbols = false, lint = "advisory", runtime, maxmod, rebuildSdk }) {
|
|
269
|
+
async function buildSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, crt0, crt0Path, codeLoc, dataLoc, options, linkerConfig, linkerConfigPath, inesHeader, outputPath, inline = false, includeSymbols = false, lint = "advisory", runtime, maxmod, rebuildSdk }) {
|
|
270
270
|
// Reject conflicting inline vs path args — fail loud, not silent.
|
|
271
271
|
if (source != null && sourcePath != null) {
|
|
272
272
|
throw new Error("build({output:'rom'}): pass either `source` OR `sourcePath`, not both.");
|
|
@@ -281,6 +281,12 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
281
281
|
if (crt0Path) {
|
|
282
282
|
crt0 = await readFile(crt0Path, "utf-8");
|
|
283
283
|
}
|
|
284
|
+
// linkerConfigPath: read the .cfg from disk so a large multi-bank
|
|
285
|
+
// config (e.g. a disasm'd mapper-2 rebuild) isn't re-streamed through
|
|
286
|
+
// context on every build (0.27.0 feedback #2).
|
|
287
|
+
if (linkerConfigPath && linkerConfig == null) {
|
|
288
|
+
linkerConfig = await readFile(linkerConfigPath, "utf-8");
|
|
289
|
+
}
|
|
284
290
|
// Auto-inject the bundled crt0 for SMS/GG when caller didn't pass
|
|
285
291
|
// one. Stock SDCC crt0 doesn't boot these targets; without this,
|
|
286
292
|
// user main() is never called → black screen. See AUTO_CRT0_PLATFORMS.
|
|
@@ -464,7 +470,7 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
464
470
|
return jsonContent(payload);
|
|
465
471
|
}
|
|
466
472
|
|
|
467
|
-
async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, inesHeader, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
|
|
473
|
+
async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, linkerConfigPath, inesHeader, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
|
|
468
474
|
const { buildForPlatform } = await import("../../toolchains/index.js");
|
|
469
475
|
const resolved = resolveCore(platform);
|
|
470
476
|
if (!resolved) throw new Error(`no core available for platform '${platform}'`);
|
|
@@ -480,6 +486,7 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
480
486
|
binaryIncludes = { ...(binaryIncludes ?? {}), ...r.binaryIncludes };
|
|
481
487
|
if (r.crt0 != null) crt0 = r.crt0;
|
|
482
488
|
if (r.codeLoc != null) codeLoc = r.codeLoc;
|
|
489
|
+
if (r.dataLoc != null && dataLoc == null) dataLoc = r.dataLoc;
|
|
483
490
|
if (r.linkerConfig != null && linkerConfig == null) linkerConfig = r.linkerConfig;
|
|
484
491
|
if (r.runtime != null && runtime == null) runtime = r.runtime;
|
|
485
492
|
if (r.maxmod != null && maxmod == null) maxmod = r.maxmod;
|
|
@@ -510,6 +517,9 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
510
517
|
if (crt0Path) {
|
|
511
518
|
crt0 = await readFile(crt0Path, "utf-8");
|
|
512
519
|
}
|
|
520
|
+
if (linkerConfigPath && linkerConfig == null) {
|
|
521
|
+
linkerConfig = await readFile(linkerConfigPath, "utf-8");
|
|
522
|
+
}
|
|
513
523
|
// Auto-inject bundled crt0 for SMS/GG when caller didn't pass one
|
|
514
524
|
// (stock SDCC crt0 doesn't boot these targets — see buildSource).
|
|
515
525
|
if (crt0 == null) {
|
|
@@ -714,6 +724,7 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
714
724
|
dataLoc: z.coerce.number().int().optional().describe("SDCC — _DATA (WRAM) load address (default $C000 on Z80). NOT read by output:'romWithDebug'."),
|
|
715
725
|
options: z.array(z.string()).optional().describe("output:'rom' — extra toolchain CLI options."),
|
|
716
726
|
linkerConfig: z.string().optional().describe("ld65 linker config (cc65). NES presets: 'chr-ram-runtime' (RECOMMENDED for homebrew C — full crt0 + iNES header + NMI w/ OAM DMA + `_shadow_oam` at $0200), 'chr-ram' (bare nmi:rti stub), 'chr-rom' (cc65-C with FIXED CHR-ROM art — segment split + CHARS segment; supply CHR via binaryIncludePaths into a CHARS source + the header via `inesHeader`). Or full .cfg contents. Preset NAMES only resolve on output:'rom'/'run'; output:'romWithDebug' takes raw .cfg contents only. **For rebuilding a commercial NROM game from its disassembly, prefer `inesHeader` over a raw .cfg.**"),
|
|
727
|
+
linkerConfigPath: z.string().optional().describe("Path-based `linkerConfig`: absolute path to a .cfg file on disk (the server reads it — the cfg never enters your context; e.g. the multi-bank cfg a banked-NES disasm project ships). Ignored when `linkerConfig` is passed inline."),
|
|
717
728
|
inesHeader: z.object({
|
|
718
729
|
prgBanks: z.coerce.number().int().min(1).max(255).describe("16KB PRG-ROM banks (1 = NROM-128, 2 = NROM-256)."),
|
|
719
730
|
chrBanks: z.coerce.number().int().min(0).max(255).optional().describe("8KB CHR-ROM banks (0 = CHR-RAM, no CHARS segment). Default 0."),
|
|
@@ -785,8 +796,8 @@ export function registerToolchainTools(server, z, sessionKey) {
|
|
|
785
796
|
*/
|
|
786
797
|
export function projectBuildRecipe(platform, names) {
|
|
787
798
|
const has = (n) => names.includes(n);
|
|
788
|
-
/** @type {{crt0File:string|null, codeLoc:number|undefined, linkerConfig:string|undefined, runtime:string|undefined, maxmod:boolean|undefined, skip:Set<string>, includeAsC:Set<string>}} */
|
|
789
|
-
const r = { crt0File: null, codeLoc: undefined, linkerConfig: undefined, runtime: undefined, maxmod: undefined, skip: new Set(), includeAsC: new Set() };
|
|
799
|
+
/** @type {{crt0File:string|null, codeLoc:number|undefined, dataLoc:number|undefined, linkerConfig:string|undefined, runtime:string|undefined, maxmod:boolean|undefined, skip:Set<string>, includeAsC:Set<string>}} */
|
|
800
|
+
const r = { crt0File: null, codeLoc: undefined, dataLoc: undefined, linkerConfig: undefined, runtime: undefined, maxmod: undefined, skip: new Set(), includeAsC: new Set() };
|
|
790
801
|
|
|
791
802
|
// Reference/upstream sources ship for grepping, not compiling (e.g. GB
|
|
792
803
|
// music_demo's hUGEDriver.upstream.asm — the .c port is what builds). Skip
|
|
@@ -796,7 +807,11 @@ export function projectBuildRecipe(platform, names) {
|
|
|
796
807
|
if (platform === "gb" || platform === "gbc") {
|
|
797
808
|
// GB/GBC ship gb_crt0.s — it MUST go via crt0+codeLoc:0x150, never as a
|
|
798
809
|
// source (SDCC emits its own gsinit → "Multiple definition of gsinit").
|
|
799
|
-
|
|
810
|
+
// dataLoc 0xC200: statics start ABOVE shadow_oam ($C100-$C19F, fixed by
|
|
811
|
+
// the runtime). The sdld default of $C000 let any project with >256 bytes
|
|
812
|
+
// of statics silently overlap the OAM shadow — oam_clear() then zeroed
|
|
813
|
+
// game state (grid/RNG seed). 512 bytes of 8KB WRAM is cheap insurance.
|
|
814
|
+
if (has("gb_crt0.s")) { r.crt0File = "gb_crt0.s"; r.codeLoc = 0x150; r.dataLoc = 0xC200; }
|
|
800
815
|
} else if (platform === "nes") {
|
|
801
816
|
// A SCAFFOLDED NES project ships nes_runtime.c + a crt0 + a .cfg and needs
|
|
802
817
|
// the chr-ram-runtime preset (it defines the OAM/CHARS segments + a NMI with
|
|
@@ -824,9 +839,17 @@ export function projectBuildRecipe(platform, names) {
|
|
|
824
839
|
// msx_crt0.s through crt0 makes ITS header + init the cartridge entry.
|
|
825
840
|
if (has("msx_crt0.s")) { r.crt0File = "msx_crt0.s"; r.codeLoc = 0x4010; }
|
|
826
841
|
} else if (platform === "sms" || platform === "gg") {
|
|
827
|
-
// SMS/GG
|
|
828
|
-
//
|
|
829
|
-
|
|
842
|
+
// SMS/GG: route the project's *_crt0.s through the crt0 channel (like
|
|
843
|
+
// GB/MSX), NOT as a plain source TU. The OLD recipe skipped it on the
|
|
844
|
+
// belief that "buildForPlatform auto-injects the bundled crt0" — IT DOES
|
|
845
|
+
// NOT (only the output:'rom'/'run' MCP handlers auto-inject). So every
|
|
846
|
+
// output:'project' SMS/GG build linked SDCC's STOCK z80 crt0, whose boot
|
|
847
|
+
// is `ld a,#2 / rst $08 / halt` — main() never ran and every scaffold
|
|
848
|
+
// booted to a BLACK SCREEN (the RetroDECK "all broken" report; our
|
|
849
|
+
// output:'run' verifications were false-green via the other path).
|
|
850
|
+
// readProjectDir falls back to the bundled crt0 when the dir has none.
|
|
851
|
+
const crt0Name = names.find((n) => /_crt0\.s$/i.test(n));
|
|
852
|
+
if (crt0Name) r.crt0File = crt0Name;
|
|
830
853
|
} else if (platform === "genesis" || platform === "megadrive" || platform === "md") {
|
|
831
854
|
// SGDK supplies sega startup + rom header. The scaffold dir may contain
|
|
832
855
|
// generated intermediates (sega.s, sega.preprocessed.s, rom_header.*, and an
|
|
@@ -919,6 +942,15 @@ export async function readProjectDir(projPath, platform) {
|
|
|
919
942
|
}
|
|
920
943
|
}
|
|
921
944
|
|
|
945
|
+
// SMS/GG with no crt0 file in the dir → fall back to the bundled crt0,
|
|
946
|
+
// exactly like the output:'rom'/'run' handlers do. Without this the link
|
|
947
|
+
// silently uses SDCC's stock z80 crt0, which never calls main() (black
|
|
948
|
+
// screen at boot). The SMS scaffold historically shipped without a crt0
|
|
949
|
+
// file, so this fallback is load-bearing for existing project dirs.
|
|
950
|
+
if (crt0 == null && (platform === "sms" || platform === "gg")) {
|
|
951
|
+
crt0 = await resolveAutoCrt0(platform);
|
|
952
|
+
}
|
|
953
|
+
|
|
922
954
|
// GBA runtime refinement: libgba if the entry includes <gba.h>, else the
|
|
923
955
|
// libtonc default the recipe set.
|
|
924
956
|
let runtime = recipe.runtime;
|
|
@@ -926,11 +958,11 @@ export async function readProjectDir(projPath, platform) {
|
|
|
926
958
|
runtime = "libgba";
|
|
927
959
|
}
|
|
928
960
|
|
|
929
|
-
return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
|
|
961
|
+
return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, dataLoc: recipe.dataLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
|
|
930
962
|
}
|
|
931
963
|
|
|
932
964
|
export async function buildProjectCore({ path: projPath, platform, outputPath }) {
|
|
933
|
-
const { sources, includes, binaryIncludes, crt0, codeLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
|
|
965
|
+
const { sources, includes, binaryIncludes, crt0, codeLoc, dataLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
|
|
934
966
|
|
|
935
967
|
// Linker preset: the recipe names it (e.g. NES 'chr-ram-runtime', which ships
|
|
936
968
|
// the OAM/CHARS segments + its own crt0). resolveLinkerConfig also returns any
|
|
@@ -968,6 +1000,7 @@ export async function buildProjectCore({ path: projPath, platform, outputPath })
|
|
|
968
1000
|
linkerConfig: resolvedLinkerConfig,
|
|
969
1001
|
crt0: crt0Rel,
|
|
970
1002
|
codeLoc,
|
|
1003
|
+
dataLoc,
|
|
971
1004
|
});
|
|
972
1005
|
if (outputPath && result.binary) {
|
|
973
1006
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
@@ -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\`.`),
|
|
@@ -659,16 +671,36 @@ 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);
|
|
672
704
|
// captureMemory: read the requested regions AT the hit (before we clear/step),
|
|
673
705
|
// returned inline so break→read RAM collapses into ONE call. NOTE: registers
|
|
674
706
|
// are the true break instant (core snapshot); these RAM reads are taken now —
|
|
@@ -700,8 +732,9 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
700
732
|
// end-of-frame state. Prefer registersAtHit; only fall back to a live read on
|
|
701
733
|
// cores that don't snapshot.
|
|
702
734
|
const frozenNote = atHit
|
|
703
|
-
? "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."
|
|
704
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
|
|
705
738
|
return attachObserverFrame(jsonContent({
|
|
706
739
|
hit: true,
|
|
707
740
|
address: "$" + address.toString(16).toUpperCase(),
|
|
@@ -711,7 +744,9 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
711
744
|
...(capturedMemory ? { capturedMemory } : {}),
|
|
712
745
|
frame: host.status.frameCount,
|
|
713
746
|
framesRun,
|
|
714
|
-
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,
|
|
715
750
|
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
716
751
|
note: frozenNote,
|
|
717
752
|
}), host);
|
|
@@ -749,17 +784,21 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
749
784
|
});
|
|
750
785
|
}
|
|
751
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;
|
|
752
789
|
return attachObserverFrame(jsonContent({
|
|
753
790
|
hit: true,
|
|
754
791
|
address: "$" + address.toString(16).toUpperCase(),
|
|
755
792
|
pc: last.lastPC != null ? "$" + last.lastPC.toString(16).toUpperCase() : null,
|
|
756
793
|
pcRaw: last.lastPC,
|
|
757
|
-
|
|
794
|
+
valueByte: "0x" + (last.lastValue & 0xFF).toString(16).toUpperCase().padStart(2, "0"),
|
|
758
795
|
frame: host.status.frameCount,
|
|
759
796
|
framesRun,
|
|
760
|
-
hits: fin.hits,
|
|
797
|
+
hits: fin.hits || 1,
|
|
798
|
+
...(rdRegs ? { registersAtHit: rdRegs } : {}),
|
|
761
799
|
...(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."
|
|
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)." : ""),
|
|
763
802
|
}), host);
|
|
764
803
|
}
|
|
765
804
|
|
|
@@ -782,10 +821,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
782
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, " +
|
|
783
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`; " +
|
|
784
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" +
|
|
785
|
-
"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.",
|
|
786
826
|
{
|
|
787
827
|
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
|
|
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)."),
|
|
789
829
|
precision: z.enum(["exact", "sampled"]).default("exact")
|
|
790
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)."),
|
|
791
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."),
|
|
@@ -857,7 +897,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
857
897
|
return jsonContent({ regId, value: "0x" + (now >>> 0).toString(16).toUpperCase(), valueRaw: now });
|
|
858
898
|
}
|
|
859
899
|
|
|
860
|
-
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 }) {
|
|
861
901
|
const host = getHost(sessionKey);
|
|
862
902
|
if (!host.setRegSupported || !host.setRegSupported()) {
|
|
863
903
|
return jsonContent({ returned: false, notSupported: true,
|
|
@@ -868,17 +908,28 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
868
908
|
const r = host.callSubroutine({
|
|
869
909
|
pc, regs: numRegs, sentinelPC, stopAtPC,
|
|
870
910
|
presetMemory: (presetMemory ?? []).map((m) => ({ addr: m.addr, hex: m.hex })),
|
|
871
|
-
maxFrames, ...(maxInstructions ? { maxInstructions } : {}), sandbox,
|
|
911
|
+
maxFrames, ...(maxInstructions ? { maxInstructions } : {}), sandbox, pure,
|
|
872
912
|
});
|
|
873
|
-
|
|
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
|
|
874
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.)"
|
|
875
924
|
: r.watchdog
|
|
876
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."
|
|
877
926
|
: r.stoppedAtPC
|
|
878
927
|
? `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."
|
|
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;
|
|
880
930
|
return jsonContent({
|
|
881
931
|
returned: r.returned, framesRun: r.framesRun, sandbox,
|
|
932
|
+
...(r.pure ? { pure: true, pureMode: r.pureMode } : {}),
|
|
882
933
|
...(r.watchdog ? { watchdog: true, reason: r.reason } : {}),
|
|
883
934
|
...(r.stoppedAtPC ? { stoppedAtPC: r.stoppedAtPC } : {}),
|
|
884
935
|
...(r.finalPC ? { finalPC: r.finalPC } : {}),
|
|
@@ -911,7 +962,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
911
962
|
"OP CHEAT-SHEET (the params each op uses): " +
|
|
912
963
|
"read → {cpu?, platform?}; " +
|
|
913
964
|
"setReg → {regId, value}; " +
|
|
914
|
-
"call → {pc, regs?, sandbox?, maxInstructions?, sentinelPC?, stopAtPC?, presetMemory?, maxFrames?}; " +
|
|
965
|
+
"call → {pc, regs?, pure?, sandbox?, maxInstructions?, sentinelPC?, stopAtPC?, presetMemory?, maxFrames?}; " +
|
|
915
966
|
"decompress → {entryPC, sourceAddress, destAddress?, maxFrames?}.\n" +
|
|
916
967
|
"• op:'read' — read a CPU's {pc, registers, flags, sp}. Main CPU wired for all 14 tier-1 systems (nes, snes, " +
|
|
917
968
|
"genesis, sms, gg, gb, gbc, atari2600, atari7800, c64, lynx, gba (ARM7TDMI: 16 gprs + cpsr/spsr + execPc for " +
|
|
@@ -924,7 +975,13 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
924
975
|
"buffer it wrote stays live for memory({op:'read'}).** Classic use: drive a decompressor (A0=source, A1=dest) then read " +
|
|
925
976
|
"the dst. **NEVER HANGS: an instruction WATCHDOG (`maxInstructions`) force-stops a runaway and returns PROGRESS — " +
|
|
926
977
|
"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
|
|
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" +
|
|
928
985
|
"• op:'decompress' — convenience wrapper over op:'call' for the common decompressor shape: call `entryPC` with " +
|
|
929
986
|
"A0=`sourceAddress` (and optionally A1=`destAddress`), run until it returns, then read `destAddress`. For the " +
|
|
930
987
|
"NBA-Jam-style 'name + portrait are LZ-compressed' wall: point it at the game's own decompressor.",
|
|
@@ -949,6 +1006,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
949
1006
|
maxFrames: z.number().int().min(1).max(100000).default(600).describe("op:call/decompress — frame cap (the outer bound)."),
|
|
950
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."),
|
|
951
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."),
|
|
952
1010
|
// decompress
|
|
953
1011
|
entryPC: z.number().int().min(0).optional().describe("op:decompress — decompressor entry PC."),
|
|
954
1012
|
sourceAddress: z.number().int().min(0).optional().describe("op:decompress — compressed-source address → A0 (reg-id 8 on m68k)."),
|
|
@@ -1044,10 +1102,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1044
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" +
|
|
1045
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" +
|
|
1046
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" +
|
|
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
|
|
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.",
|
|
1048
1107
|
{
|
|
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."),
|
|
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?')."),
|
|
1051
1110
|
// on:'mem'
|
|
1052
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`."),
|
|
1053
1112
|
offset: z.number().int().min(0).default(0).describe("on:'mem' single-range — first byte of the watched range."),
|
|
@@ -1084,7 +1143,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1084
1143
|
button: z.string(),
|
|
1085
1144
|
port: z.number().int().min(0).max(3).default(0),
|
|
1086
1145
|
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."),
|
|
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."),
|
|
1088
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."),
|
|
1089
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."),
|
|
1090
1149
|
},
|
|
@@ -1107,11 +1166,69 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1107
1166
|
}
|
|
1108
1167
|
return await dmaExact(a);
|
|
1109
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
|
+
}
|
|
1110
1173
|
default: throw new Error(`watch: unknown on '${args.on}'`);
|
|
1111
1174
|
}
|
|
1112
1175
|
}),
|
|
1113
1176
|
);
|
|
1114
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
|
+
|
|
1115
1232
|
// ── watch({on:'dma'}) helpers (Genesis only) ────────────────────────────────
|
|
1116
1233
|
// precision:exact = dmaExact (watchDma, per-DMA core log), precision:sampled =
|
|
1117
1234
|
// traceVramSourceCore (frame-sampled, dest-agnostic). Routed by the `watch`
|