romdevtools 0.16.0 → 0.22.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 +75 -16
- package/CHANGELOG.md +316 -0
- package/examples/README.md +2 -0
- package/examples/atari2600/templates/platformer.asm +460 -0
- package/examples/atari2600/templates/racing.asm +463 -0
- package/examples/atari2600/templates/shmup.asm +386 -0
- package/examples/atari2600/templates/sports.asm +362 -0
- package/examples/atari7800/templates/default.c +49 -5
- package/examples/atari7800/templates/hello_sprite.c +48 -4
- package/examples/atari7800/templates/music_demo.c +47 -2
- package/examples/atari7800/templates/platformer.c +43 -4
- package/examples/atari7800/templates/puzzle.c +39 -4
- package/examples/atari7800/templates/racing.c +39 -4
- package/examples/atari7800/templates/shmup.c +40 -2
- package/examples/atari7800/templates/sports.c +36 -5
- package/examples/c64/templates/platformer.c +19 -5
- package/examples/c64/templates/puzzle.c +32 -2
- package/examples/c64/templates/shmup.c +28 -2
- package/examples/c64/templates/sports.c +30 -2
- package/examples/c64/templates/tile_engine.c +77 -27
- package/examples/gb/templates/default.c +110 -16
- package/examples/gb/templates/hello_sprite.c +15 -6
- package/examples/gb/templates/music_demo.c +36 -0
- package/examples/gb/templates/platformer.c +28 -6
- package/examples/gb/templates/puzzle.c +35 -4
- package/examples/gb/templates/racing.c +75 -10
- package/examples/gb/templates/shmup.c +41 -3
- package/examples/gb/templates/sports.c +51 -3
- package/examples/gb/templates/tile_engine.c +3 -2
- package/examples/gba/templates/gba_hello.c +29 -11
- package/examples/gba/templates/maxmod_demo.c +36 -2
- package/examples/gba/templates/platformer.c +3 -1
- package/examples/gba/templates/puzzle.c +15 -3
- package/examples/gba/templates/racing.c +65 -3
- package/examples/gba/templates/shmup.c +41 -4
- package/examples/gba/templates/sports.c +36 -2
- package/examples/gba/templates/tonc_hello.c +41 -5
- package/examples/gba/templates/tonc_hello_sprite.c +35 -1
- package/examples/gbc/templates/default.c +103 -26
- package/examples/gbc/templates/hello_sprite.c +12 -3
- package/examples/gbc/templates/music_demo.c +56 -12
- package/examples/gbc/templates/platformer.c +28 -6
- package/examples/gbc/templates/puzzle.c +35 -4
- package/examples/gbc/templates/racing.c +88 -21
- package/examples/gbc/templates/shmup.c +37 -3
- package/examples/gbc/templates/sports.c +48 -3
- package/examples/gbc/templates/tile_engine.c +3 -2
- package/examples/genesis/main.s +53 -1
- package/examples/genesis/templates/hello_sprite.c +25 -3
- package/examples/genesis/templates/puzzle.c +37 -3
- package/examples/genesis/templates/racing.c +44 -11
- package/examples/genesis/templates/sgdk_hello.c +34 -1
- package/examples/genesis/templates/shmup.c +31 -1
- package/examples/genesis/templates/shmup_2p.c +31 -0
- package/examples/genesis/templates/xgm2_demo.c +20 -0
- package/examples/gg/templates/default.c +56 -18
- package/examples/gg/templates/hello_sprite.c +25 -2
- package/examples/gg/templates/music_demo.c +24 -2
- package/examples/gg/templates/platformer.c +18 -12
- package/examples/gg/templates/puzzle.c +38 -7
- package/examples/gg/templates/racing.c +58 -9
- package/examples/gg/templates/shmup.c +47 -3
- package/examples/gg/templates/sports.c +57 -16
- package/examples/gg/templates/tile_engine.c +12 -6
- package/examples/lynx/templates/default.c +39 -8
- package/examples/lynx/templates/hello_sprite.c +15 -1
- package/examples/lynx/templates/music_demo.c +13 -1
- package/examples/lynx/templates/puzzle.c +28 -1
- package/examples/lynx/templates/racing.c +34 -7
- package/examples/lynx/templates/shmup.c +42 -3
- package/examples/lynx/templates/sports.c +29 -2
- package/examples/msx/platformer/main.c +213 -0
- package/examples/msx/puzzle/main.c +250 -0
- package/examples/msx/racing/main.c +249 -0
- package/examples/msx/shmup/main.c +288 -0
- package/examples/msx/sports/main.c +182 -0
- package/examples/nes/templates/default.c +67 -19
- package/examples/nes/templates/hello_sprite.c +35 -0
- package/examples/nes/templates/music_demo.c +40 -0
- package/examples/nes/templates/platformer.c +65 -6
- package/examples/nes/templates/puzzle.c +67 -6
- package/examples/nes/templates/racing.c +45 -13
- package/examples/nes/templates/shmup.c +51 -2
- package/examples/nes/templates/sports.c +51 -6
- package/examples/pce/catch_game/main.c +22 -3
- package/examples/pce/music_sfx/main.c +28 -1
- package/examples/pce/platformer/main.c +283 -0
- package/examples/pce/puzzle/main.c +304 -0
- package/examples/pce/racing/main.c +304 -0
- package/examples/pce/shmup/main.c +346 -0
- package/examples/pce/sports/main.c +254 -0
- package/examples/pce/sprite_move/main.c +7 -2
- package/examples/sms/main.c +35 -6
- package/examples/sms/templates/hello_sprite.c +29 -3
- package/examples/sms/templates/music_demo.c +18 -4
- package/examples/sms/templates/puzzle.c +34 -5
- package/examples/sms/templates/racing.c +39 -2
- package/examples/sms/templates/shmup.c +41 -2
- package/examples/sms/templates/shmup_2p.c +24 -1
- package/examples/sms/templates/sports.c +47 -4
- package/examples/snes/main.asm +108 -17
- package/examples/snes/templates/c-hello-data.asm +23 -0
- package/examples/snes/templates/c-hello.c +18 -1
- package/examples/snes/templates/default.c +50 -28
- package/examples/snes/templates/hello_sprite-data.asm +23 -0
- package/examples/snes/templates/hello_sprite.c +17 -1
- package/examples/snes/templates/music_demo-data.asm +23 -0
- package/examples/snes/templates/music_demo.c +22 -4
- package/examples/snes/templates/platformer-data.asm +22 -0
- package/examples/snes/templates/platformer.c +20 -2
- package/examples/snes/templates/puzzle-data.asm +22 -0
- package/examples/snes/templates/puzzle.c +21 -2
- package/examples/snes/templates/racing-data.asm +22 -0
- package/examples/snes/templates/racing.c +17 -1
- package/examples/snes/templates/shmup-data.asm +22 -0
- package/examples/snes/templates/shmup.c +20 -1
- package/examples/snes/templates/sports-data.asm +22 -0
- package/examples/snes/templates/sports.c +16 -1
- package/package.json +1 -1
- package/src/cheats/gamegenie.js +0 -1
- package/src/cli/smoke.js +1 -3
- 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 +191 -16
- package/src/host/callbacks.js +9 -1
- package/src/host/chafa-render.js +2 -0
- package/src/host/dsp-state.js +2 -2
- package/src/host/gpgx-state.js +4 -0
- package/src/host/types.js +15 -8
- package/src/http/routes.js +1 -1
- package/src/http/tool-registry.js +26 -1
- package/src/mcp/server.js +1 -1
- package/src/mcp/state.js +36 -0
- package/src/mcp/tools/address-to-symbol.js +0 -1
- package/src/mcp/tools/art-loaders.js +1 -1
- package/src/mcp/tools/cart-parts.js +75 -4
- package/src/mcp/tools/classify-region.js +1 -1
- package/src/mcp/tools/diff-roms.js +1 -1
- package/src/mcp/tools/disasm-rebuild.js +507 -0
- package/src/mcp/tools/disasm.js +97 -9
- package/src/mcp/tools/find-references.js +1 -2
- package/src/mcp/tools/font-map.js +1 -1
- package/src/mcp/tools/frame.js +168 -3
- package/src/mcp/tools/index.js +0 -49
- package/src/mcp/tools/input-layout.js +0 -1
- package/src/mcp/tools/input.js +33 -3
- package/src/mcp/tools/lifecycle.js +18 -4
- package/src/mcp/tools/lospec.js +0 -19
- package/src/mcp/tools/platform-docs.js +1 -1
- package/src/mcp/tools/platform-tools.js +4 -4
- package/src/mcp/tools/project.js +54 -11
- package/src/mcp/tools/reinject.js +0 -1
- package/src/mcp/tools/rom-id.js +2 -2
- package/src/mcp/tools/snippets.js +2 -2
- package/src/mcp/tools/sprite-pipeline.js +1 -2
- package/src/mcp/tools/state.js +201 -14
- package/src/mcp/tools/tile-inspect.js +1 -1
- package/src/mcp/tools/toolchain.js +105 -12
- package/src/mcp/tools/watch-memory.js +137 -16
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +34 -0
- package/src/platforms/atari2600/TROUBLESHOOTING.md +6 -0
- package/src/platforms/atari7800/TROUBLESHOOTING.md +6 -0
- package/src/platforms/c64/MENTAL_MODEL.md +45 -1
- package/src/platforms/c64/TROUBLESHOOTING.md +6 -0
- package/src/platforms/c64/d64.js +280 -0
- package/src/platforms/c64/sid.js +0 -2
- package/src/platforms/common/metasprite-adapters.js +1 -1
- package/src/platforms/common/metasprite-codegen.js +3 -3
- package/src/platforms/common/registers.js +5 -3
- package/src/platforms/gb/MENTAL_MODEL.md +10 -0
- package/src/platforms/gb/TROUBLESHOOTING.md +6 -0
- package/src/platforms/gb/lib/c/gb_runtime.c +4 -4
- package/src/platforms/gba/TROUBLESHOOTING.md +6 -0
- package/src/platforms/gbc/TROUBLESHOOTING.md +6 -0
- package/src/platforms/gbc/lib/c/gb_runtime.c +4 -4
- package/src/platforms/genesis/TROUBLESHOOTING.md +6 -0
- package/src/platforms/gg/TROUBLESHOOTING.md +6 -0
- package/src/platforms/lynx/TROUBLESHOOTING.md +6 -0
- package/src/platforms/msx/MENTAL_MODEL.md +10 -6
- package/src/platforms/msx/TROUBLESHOOTING.md +6 -0
- package/src/platforms/nes/MENTAL_MODEL.md +63 -2
- package/src/platforms/nes/TROUBLESHOOTING.md +6 -0
- package/src/platforms/nes/image-to-tilemap.js +3 -0
- package/src/platforms/nes/lib/asm/famitone2.s +5 -1
- package/src/platforms/pce/MENTAL_MODEL.md +9 -4
- package/src/platforms/pce/TROUBLESHOOTING.md +6 -0
- package/src/platforms/pce/lib/c/pce_video.c +1 -1
- package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
- package/src/platforms/snes/TROUBLESHOOTING.md +6 -0
- package/src/platforms/snes/brr.js +0 -2
- package/src/playtest/playtest.js +0 -7
- package/src/rom-id/identifier.js +15 -0
- package/src/toolchains/asar/asar.js +0 -9
- package/src/toolchains/assemble-snippet.js +30 -12
- package/src/toolchains/cc65/ines.js +145 -0
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +14 -1
- package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
- package/src/toolchains/common/reassemble.js +10 -3
- package/src/toolchains/common/sdk-cache.js +1 -1
- package/src/toolchains/genesis-c/genesis-c.js +5 -3
- package/src/toolchains/index.js +27 -3
- package/src/toolchains/parse-errors.js +78 -1
- package/src/toolchains/sdcc/preflight-lint.js +5 -1
- package/src/toolchains/sdcc/sdcc.js +1 -1
- package/src/toolchains/sjasm/sjasm.js +1 -1
- package/src/toolchains/snes-c/snes-c.js +2 -2
- package/src/toolchains/vasm68k/vasm68k.js +2 -4
- package/src/toolchains/wladx/wladx.js +1 -1
package/src/mcp/tools/disasm.js
CHANGED
|
@@ -208,7 +208,6 @@ function nesVectors(data) {
|
|
|
208
208
|
*/
|
|
209
209
|
function snesVectors(data) {
|
|
210
210
|
const copierOff = (data.length % 0x8000 === 0x200) ? 0x200 : 0;
|
|
211
|
-
const loMapper = data[copierOff + 0x7FC0 + 0x15];
|
|
212
211
|
const hiMapper = data[copierOff + 0xFFC0 + 0x15];
|
|
213
212
|
const isLo = !(hiMapper === 0x21 || hiMapper === 0x31);
|
|
214
213
|
const headerBase = copierOff + (isLo ? 0x7FC0 : 0xFFC0);
|
|
@@ -513,7 +512,7 @@ export function mapGenesisAddress(data, cpuAddr, length) {
|
|
|
513
512
|
* 48 KB: $4000-$FFFF (rare)
|
|
514
513
|
* 144 KB SuperGame: bank-switched at $8000-$BFFF + fixed at $C000
|
|
515
514
|
*/
|
|
516
|
-
export function mapAtari7800Address(data, cpuAddr, length,
|
|
515
|
+
export function mapAtari7800Address(data, cpuAddr, length, _bank = 0) {
|
|
517
516
|
// Detect header. "ATARI7800" magic at offset 1.
|
|
518
517
|
const hasHeader =
|
|
519
518
|
data.length > 128 &&
|
|
@@ -557,7 +556,7 @@ export function mapAtari7800Address(data, cpuAddr, length, bank = 0) {
|
|
|
557
556
|
* docs); not implemented here — pass `bank` instead and call with the raw
|
|
558
557
|
* binary if you're hand-mapping.
|
|
559
558
|
*/
|
|
560
|
-
export function mapC64Address(data, cpuAddr, length,
|
|
559
|
+
export function mapC64Address(data, cpuAddr, length, _bank = 0) {
|
|
561
560
|
// Detect .prg by reading the load address and seeing if it makes sense.
|
|
562
561
|
// (Anything is a valid load addr in theory, so we just trust the first
|
|
563
562
|
// 2 bytes here.)
|
|
@@ -1013,7 +1012,7 @@ async function disassembleRomCore(args) {
|
|
|
1013
1012
|
}
|
|
1014
1013
|
|
|
1015
1014
|
async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
|
|
1016
|
-
const { reassembleForPlatform } = await import("../../toolchains/common/reassemble.js");
|
|
1015
|
+
const { reassembleForPlatform, CPU_FAMILY } = await import("../../toolchains/common/reassemble.js");
|
|
1017
1016
|
const data = new Uint8Array(await readFile(romPath));
|
|
1018
1017
|
const resolved = platform ?? sniffPlatformFromPath(romPath);
|
|
1019
1018
|
if (!resolved) throw new Error(`disassembleProject: could not detect platform from '${romPath}'. Pass platform explicitly.`);
|
|
@@ -1029,7 +1028,7 @@ async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
|
|
|
1029
1028
|
// Known-data regions (e.g. the GBA cartridge header) are emitted as a
|
|
1030
1029
|
// clean `.byte` dump — byte-exact by construction, NOT a failed disasm.
|
|
1031
1030
|
const r = reg.kind === "data"
|
|
1032
|
-
? { ok: true, readablePercent: 0, source: dataRegionSource(reg.bytes, reg.startAddress), note: "data region (not code)" }
|
|
1031
|
+
? { ok: true, readablePercent: 0, source: dataRegionSource(reg.bytes, reg.startAddress, CPU_FAMILY[resolved]), note: "data region (not code)" }
|
|
1033
1032
|
: await reassembleForPlatform({ platform: resolved, bytes: reg.bytes, startAddress: reg.startAddress });
|
|
1034
1033
|
const header = `; ${reg.label} — ${reg.bytes.length} bytes @ $${reg.startAddress.toString(16).toUpperCase()} ` +
|
|
1035
1034
|
`(file 0x${reg.fileOffset.toString(16).toUpperCase()}), ${resolved}\n` +
|
|
@@ -1046,6 +1045,32 @@ async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
|
|
|
1046
1045
|
|
|
1047
1046
|
const allOk = out.every((r) => r.roundTripOk);
|
|
1048
1047
|
const avgReadable = Math.round(out.reduce((s, r) => s + r.readablePercent, 0) / out.length);
|
|
1048
|
+
|
|
1049
|
+
// Make the project TURNKEY: write the rebuild glue (data blobs, the exact
|
|
1050
|
+
// build() call, and human-readable instructions) so it rebuilds without
|
|
1051
|
+
// hand-wiring a header/CHR-blob/linker .cfg. See disasm-rebuild.js.
|
|
1052
|
+
const { planRebuild } = await import("./disasm-rebuild.js");
|
|
1053
|
+
const plan = planRebuild(resolved, data, regions);
|
|
1054
|
+
const writtenBlobs = [];
|
|
1055
|
+
for (const [name, bytes] of Object.entries(plan.blobs)) {
|
|
1056
|
+
await writeFile(nodePath.join(outputDir, name), bytes);
|
|
1057
|
+
writtenBlobs.push({ file: name, bytes: bytes.length });
|
|
1058
|
+
}
|
|
1059
|
+
// Absolutize the bare filenames in the build call so the recipe is
|
|
1060
|
+
// copy-pasteable as-is.
|
|
1061
|
+
const absBuild = plan.build ? absolutizeBuild(plan.build, outputDir) : null;
|
|
1062
|
+
if (absBuild) {
|
|
1063
|
+
await writeFile(
|
|
1064
|
+
nodePath.join(outputDir, "rebuild.json"),
|
|
1065
|
+
JSON.stringify(absBuild, null, 2) + "\n"
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
const buildMd = renderBuildMd({
|
|
1069
|
+
platform: resolved, romPath, regions: out, blobs: writtenBlobs,
|
|
1070
|
+
build: absBuild, verifiable: plan.verifiable, notes: plan.notes, allOk,
|
|
1071
|
+
});
|
|
1072
|
+
await writeFile(nodePath.join(outputDir, "BUILD.md"), buildMd);
|
|
1073
|
+
|
|
1049
1074
|
return jsonContent({
|
|
1050
1075
|
ok: allOk,
|
|
1051
1076
|
path: romPath,
|
|
@@ -1053,12 +1078,64 @@ async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
|
|
|
1053
1078
|
regions: out,
|
|
1054
1079
|
roundTrip: { regions: out.length, allByteExact: allOk, failed: out.filter((r) => !r.roundTripOk).map((r) => r.region) },
|
|
1055
1080
|
readablePercentAvg: avgReadable,
|
|
1081
|
+
rebuild: {
|
|
1082
|
+
blobs: writtenBlobs,
|
|
1083
|
+
buildCall: absBuild, // the exact build({...}) args to reproduce the ROM
|
|
1084
|
+
verifiable: plan.verifiable, // true = expected byte-identical via that call
|
|
1085
|
+
buildDoc: "BUILD.md",
|
|
1086
|
+
notes: plan.notes,
|
|
1087
|
+
},
|
|
1056
1088
|
note: allOk
|
|
1057
|
-
? `All ${out.length} region(s) round-trip BYTE-EXACT (avg ${avgReadable}% disassembled as instructions, the rest as .byte data).
|
|
1089
|
+
? `All ${out.length} region(s) round-trip BYTE-EXACT (avg ${avgReadable}% disassembled as instructions, the rest as .byte data). ` +
|
|
1090
|
+
(absBuild
|
|
1091
|
+
? `Rebuild it with the build() call in rebuild.json / BUILD.md` +
|
|
1092
|
+
(plan.verifiable ? " — expected byte-identical." : " (see notes — may need linker tweaks).")
|
|
1093
|
+
: `See BUILD.md for how to rebuild.`)
|
|
1058
1094
|
: `Some regions did NOT round-trip byte-exact — see regions[].note.`,
|
|
1059
1095
|
});
|
|
1060
1096
|
}
|
|
1061
1097
|
|
|
1098
|
+
/** Rewrite a planRebuild build()'s bare *Paths filenames to absolute paths. */
|
|
1099
|
+
function absolutizeBuild(build, outputDir) {
|
|
1100
|
+
const out = { ...build };
|
|
1101
|
+
for (const key of ["sourcesPaths", "binaryIncludePaths", "includePaths"]) {
|
|
1102
|
+
if (out[key]) {
|
|
1103
|
+
const m = {};
|
|
1104
|
+
for (const [virt, file] of Object.entries(out[key])) m[virt] = nodePath.join(outputDir, file);
|
|
1105
|
+
out[key] = m;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return out;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Human + agent readable rebuild instructions for a disassembled project. */
|
|
1112
|
+
function renderBuildMd({ platform, romPath, regions, blobs, build, verifiable, notes, allOk }) {
|
|
1113
|
+
const lines = [];
|
|
1114
|
+
lines.push(`# Rebuilding this ${platform} project`, "");
|
|
1115
|
+
lines.push(`Disassembled from \`${nodePath.basename(romPath)}\` by \`disasm({target:'project'})\`.`, "");
|
|
1116
|
+
lines.push("## Files", "");
|
|
1117
|
+
for (const r of regions) {
|
|
1118
|
+
lines.push(`- \`${r.file}\` — ${r.region}${r.kind === "data" ? " (data)" : ""}, byte-exact${r.roundTripOk === false ? " ⚠ round-trip FAILED" : ""}.`);
|
|
1119
|
+
}
|
|
1120
|
+
for (const b of blobs) lines.push(`- \`${b.file}\` — ${b.bytes} bytes of binary data (extracted from the ROM; do not hand-edit).`);
|
|
1121
|
+
lines.push("- `rebuild.json` — the exact `build()` args below, with absolute paths.", "");
|
|
1122
|
+
if (build) {
|
|
1123
|
+
lines.push("## Rebuild", "");
|
|
1124
|
+
if (verifiable) {
|
|
1125
|
+
lines.push("This rebuilds **byte-identical** to the source ROM. Call:", "");
|
|
1126
|
+
} else {
|
|
1127
|
+
lines.push("Rebuild call (see Notes — may need linker adjustments for an exact match):", "");
|
|
1128
|
+
}
|
|
1129
|
+
lines.push("```json", JSON.stringify(build, null, 2), "```", "");
|
|
1130
|
+
lines.push("Pass these as the arguments to the `build` tool. The same JSON is in `rebuild.json`.", "");
|
|
1131
|
+
} else {
|
|
1132
|
+
lines.push("## Rebuild", "", "No automatic rebuild recipe for this platform yet. " + notes, "");
|
|
1133
|
+
}
|
|
1134
|
+
if (notes) lines.push("## Notes", "", notes, "");
|
|
1135
|
+
if (!allOk) lines.push("> ⚠ Some regions did not round-trip byte-exact — edit those `.asm` files before rebuilding.", "");
|
|
1136
|
+
return lines.join("\n") + "\n";
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1062
1139
|
export function registerDisasmTools(server, z) {
|
|
1063
1140
|
server.tool(
|
|
1064
1141
|
"disasm",
|
|
@@ -1137,12 +1214,23 @@ function trimTrailingPad(bytes) {
|
|
|
1137
1214
|
|
|
1138
1215
|
/** Emit a known-data region as a clean, byte-exact `.byte` dump (GAS/cc65 both
|
|
1139
1216
|
* accept `.byte` with `.org`). 16 bytes per line, with the address in a comment. */
|
|
1140
|
-
|
|
1141
|
-
|
|
1217
|
+
// Emit a byte-exact `.byte` dump for a known-DATA region (e.g. a cartridge
|
|
1218
|
+
// header). The syntax must match the platform's reassembler: ca65/vasm
|
|
1219
|
+
// (6502/65816) take `$`-prefixed hex and a bare `.org $...`; GNU `as`
|
|
1220
|
+
// (z80/sm83/m68k/arm) take `0x` hex and reject a `$` operand (`$2E` reads as an
|
|
1221
|
+
// undefined symbol → the link fails). `family` comes from CPU_FAMILY; default to
|
|
1222
|
+
// the ca65 form for the 6502 platforms that have used this path historically.
|
|
1223
|
+
function dataRegionSource(bytes, startAddress, family = "6502") {
|
|
1224
|
+
const gnu = family === "z80" || family === "sm83" || family === "m68k" || family === "arm";
|
|
1225
|
+
const hex = (b) => (gnu ? "0x" : "$") + b.toString(16).padStart(2, "0").toUpperCase();
|
|
1226
|
+
const org = gnu
|
|
1227
|
+
? `\t.org 0x${startAddress.toString(16).toUpperCase()}`
|
|
1228
|
+
: `\t.org $${startAddress.toString(16).toUpperCase()}`;
|
|
1229
|
+
const rows = [org];
|
|
1142
1230
|
for (let i = 0; i < bytes.length; i += 16) {
|
|
1143
1231
|
const slice = Array.from(bytes.slice(i, i + 16));
|
|
1144
1232
|
rows.push(
|
|
1145
|
-
"\t.byte " + slice.map(
|
|
1233
|
+
"\t.byte " + slice.map(hex).join(",") +
|
|
1146
1234
|
`\t; ${(startAddress + i).toString(16).toUpperCase().padStart(6, "0")}`
|
|
1147
1235
|
);
|
|
1148
1236
|
}
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// though they're not "instructions" per se.
|
|
8
8
|
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
10
|
-
import {
|
|
11
|
-
import { mapNesAddress, mapSnesAddress, mapAtari2600Address, mapAtari7800Address, mapC64Address } from "./disasm.js";
|
|
10
|
+
import { mapSnesAddress, mapAtari2600Address, mapAtari7800Address, mapC64Address } from "./disasm.js";
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* Classify a referring instruction by its mnemonic.
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// in findEncodedText (NES/GB/GBC bank-aware, Genesis flat; SNES is mapper-
|
|
17
17
|
// dependent and left to fileOffset).
|
|
18
18
|
|
|
19
|
-
import { readFile
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
20
|
import { jsonContent, safeTool } from "../util.js";
|
|
21
21
|
import { getHost } from "../state.js";
|
|
22
22
|
|
package/src/mcp/tools/frame.js
CHANGED
|
@@ -5,7 +5,140 @@ import { PNG } from "pngjs";
|
|
|
5
5
|
import { getHost } from "../state.js";
|
|
6
6
|
import { imageContent, jsonContent, safeTool } from "../util.js";
|
|
7
7
|
import { decodeOAM, decodePpuRegs, ppuRegsPopulated } from "../../platforms/snes/ppu.js";
|
|
8
|
-
import { stepInstructionCore } from "./watch-memory.js";
|
|
8
|
+
import { stepInstructionCore, attachObserverFrame } from "./watch-memory.js";
|
|
9
|
+
import { getRenderingContextCore } from "./rendering-context.js";
|
|
10
|
+
|
|
11
|
+
// Normalize each platform's render-context into a CONSERVATIVE renderEnabled
|
|
12
|
+
// (true | false | null). null = "can't tell from the registers" — verify never
|
|
13
|
+
// asserts renderDisabled on null, so a platform we can't decode just relies on
|
|
14
|
+
// the pixel check. This is the cross-platform contract for frame({op:'verify'}).
|
|
15
|
+
function pickRenderFlags(ctx) {
|
|
16
|
+
const p = ctx.platform;
|
|
17
|
+
try {
|
|
18
|
+
if (p === "nes") {
|
|
19
|
+
const m = ctx.nes && ctx.nes.ppumask;
|
|
20
|
+
if (!m) return { renderEnabled: null };
|
|
21
|
+
return { renderEnabled: !!(m.bgVisible || m.spritesVisible) };
|
|
22
|
+
}
|
|
23
|
+
if (p === "snes") {
|
|
24
|
+
const s = ctx.snes;
|
|
25
|
+
if (!s || !s.ppuRegistersAvailable) return { renderEnabled: null }; // regs not live yet
|
|
26
|
+
if (s.forcedBlank) return { renderEnabled: false };
|
|
27
|
+
if (s.brightness === 0) return { renderEnabled: false };
|
|
28
|
+
return { renderEnabled: true };
|
|
29
|
+
}
|
|
30
|
+
if (p === "genesis" || p === "megadrive" || p === "md") {
|
|
31
|
+
return { renderEnabled: ctx.displayEnabled == null ? null : !!ctx.displayEnabled };
|
|
32
|
+
}
|
|
33
|
+
if (p === "sms" || p === "gg") {
|
|
34
|
+
return { renderEnabled: ctx.screenEnabled == null ? null : !!ctx.screenEnabled };
|
|
35
|
+
}
|
|
36
|
+
if (p === "gb" || p === "gbc") {
|
|
37
|
+
const l = ctx.gb && ctx.gb.lcdc ? ctx.gb.lcdc : ctx.lcdc;
|
|
38
|
+
if (!l) return { renderEnabled: null };
|
|
39
|
+
return { renderEnabled: !!l.lcdEnable };
|
|
40
|
+
}
|
|
41
|
+
if (p === "gba") {
|
|
42
|
+
if (ctx.forcedBlank) return { renderEnabled: false };
|
|
43
|
+
const anyBg = Array.isArray(ctx.displayBg) && ctx.displayBg.some(Boolean);
|
|
44
|
+
return { renderEnabled: !!(anyBg || ctx.displayObj) };
|
|
45
|
+
}
|
|
46
|
+
if (p === "pce") {
|
|
47
|
+
return { renderEnabled: ctx.screenEnabled == null ? null : !!ctx.screenEnabled };
|
|
48
|
+
}
|
|
49
|
+
if (p === "msx") {
|
|
50
|
+
return { renderEnabled: ctx.screenEnabled == null ? null : !!ctx.screenEnabled };
|
|
51
|
+
}
|
|
52
|
+
// atari2600 / atari7800 / lynx: no single reliable display-enable bit — let
|
|
53
|
+
// the pixel check carry it; don't false-assert.
|
|
54
|
+
return { renderEnabled: null };
|
|
55
|
+
} catch {
|
|
56
|
+
return { renderEnabled: null };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Dominant-color fraction at/above which a screen reads as "blank" to a
|
|
62
|
+
* human even though *something* technically rendered. Set to 0.92 (one color
|
|
63
|
+
* filling >=92% of the screen) — empirically the perceptual threshold where a
|
|
64
|
+
* backdrop-with-a-lone-sprite still looks empty. Below this, there's enough
|
|
65
|
+
* on-screen content that a person sees a populated frame. (Truly one/two-color
|
|
66
|
+
* frames are caught separately by the distinctColors<=1 blankScreen check.)
|
|
67
|
+
*/
|
|
68
|
+
const NEARLY_BLANK_DOMINANT = 0.92;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The cross-platform render-health computation behind frame({op:'verify'}).
|
|
72
|
+
* Exported so tests can drive it per platform without the MCP wrapper.
|
|
73
|
+
* @returns plain object {verified, frame, platform, pixels, render, issues?, note}
|
|
74
|
+
*/
|
|
75
|
+
export async function computeVerify(host, frames, sessionKey) {
|
|
76
|
+
const platform = host.status.platform;
|
|
77
|
+
if (frames && frames > 0) host.stepFrames(frames);
|
|
78
|
+
const frameCount = host.status.frameCount;
|
|
79
|
+
|
|
80
|
+
// --- pixel content check (platform-agnostic) ---
|
|
81
|
+
const { width, height, rgba } = host.screenshotRgba();
|
|
82
|
+
const counts = new Map();
|
|
83
|
+
const total = width * height;
|
|
84
|
+
for (let i = 0; i + 3 < rgba.length; i += 4) {
|
|
85
|
+
const key = (rgba[i] << 16) | (rgba[i + 1] << 8) | rgba[i + 2];
|
|
86
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
87
|
+
}
|
|
88
|
+
let topColor = 0, topCount = 0;
|
|
89
|
+
for (const [c, n] of counts) if (n > topCount) { topCount = n; topColor = c; }
|
|
90
|
+
const distinctColors = counts.size;
|
|
91
|
+
const dominantFraction = total ? topCount / total : 1;
|
|
92
|
+
const nonDominant = total - topCount;
|
|
93
|
+
const pixels = {
|
|
94
|
+
width, height,
|
|
95
|
+
distinctColors,
|
|
96
|
+
dominantColor: "#" + topColor.toString(16).padStart(6, "0"),
|
|
97
|
+
dominantPct: Math.round(dominantFraction * 1000) / 10,
|
|
98
|
+
nonDominantPixels: nonDominant,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// --- render-enable / NMI verdict (reused, per-platform) ---
|
|
102
|
+
let render;
|
|
103
|
+
try {
|
|
104
|
+
const ctx = await getRenderingContextCore({ platform, area: "all", sessionKey });
|
|
105
|
+
render = { summary: ctx.summary || [], ...pickRenderFlags(ctx) };
|
|
106
|
+
} catch (e) {
|
|
107
|
+
render = { summary: [`(render-context decode unavailable for '${platform}': ${e.message})`], renderEnabled: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- frame-0 guard: report raw, no verdict (never cry wolf on boot) ---
|
|
111
|
+
if (frameCount === 0) {
|
|
112
|
+
return {
|
|
113
|
+
verified: null, unsettled: true, frame: 0, platform,
|
|
114
|
+
note: "No frame has been stepped yet — render state is the pre-boot default and not meaningful. " +
|
|
115
|
+
"Step frames first (frame({op:'step'}) or pass `frames`), then verify.",
|
|
116
|
+
pixels, render,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- fuse into a verdict + issues[] ---
|
|
121
|
+
const issues = [];
|
|
122
|
+
if (distinctColors <= 1) {
|
|
123
|
+
issues.push({ check: "blankScreen", detail: `the entire framebuffer is one color (${pixels.dominantColor}) — nothing is being drawn.` });
|
|
124
|
+
} else if (dominantFraction >= NEARLY_BLANK_DOMINANT) {
|
|
125
|
+
issues.push({ check: "nearlyBlank", detail: `${pixels.dominantPct}% of the screen is a single color (${pixels.dominantColor}); only ${nonDominant} px differ — a backdrop with almost no content reads as blank to a human even though something rendered. Add visible content (a tilemap/background, more sprites) until <${Math.round(NEARLY_BLANK_DOMINANT * 100)}% is one color.` });
|
|
126
|
+
}
|
|
127
|
+
if (render && render.renderEnabled === false) {
|
|
128
|
+
issues.push({ check: "renderDisabled", detail: `display output is disabled per the ${platform} registers: ${render.summary[0] || "see render.summary"}.` });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ok = issues.length === 0;
|
|
132
|
+
return {
|
|
133
|
+
verified: ok,
|
|
134
|
+
frame: frameCount,
|
|
135
|
+
platform,
|
|
136
|
+
...(ok
|
|
137
|
+
? { note: `Frame ${frameCount}: rendering looks alive (${distinctColors} colors, ${Math.round((100 - pixels.dominantPct) * 10) / 10}% of the screen is non-backdrop).` }
|
|
138
|
+
: { issues, note: "Rendering looks broken — see issues[]. For per-platform thresholds + the full checklist, getPlatformDoc({platform, doc:'mental_model'})." }),
|
|
139
|
+
pixels, render,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
9
142
|
|
|
10
143
|
// Get the platform's visible sprites in the generic shape, or null if
|
|
11
144
|
// not supported. Drives the screenshot overlay AND any future agents
|
|
@@ -231,6 +364,29 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
231
364
|
return shootPng({ path: outPath, inline, overlayBoxes, scale });
|
|
232
365
|
}
|
|
233
366
|
|
|
367
|
+
// op:'verify' — one-call "did the game actually render / is it alive?" health
|
|
368
|
+
// check for agents debugging WITHOUT vision. Fuses two independent signals:
|
|
369
|
+
// 1. the render-enable/NMI verdict from getRenderingContext (per-platform
|
|
370
|
+
// register decode — already correct, reused not re-derived), and
|
|
371
|
+
// 2. a pixel-level content check on the live framebuffer (is the screen
|
|
372
|
+
// actually showing more than one flat color?).
|
|
373
|
+
// Frame-0 guard: before any frame is stepped, report the raw condition WITHOUT
|
|
374
|
+
// an editorial verdict (so the header never "cries wolf" on boot).
|
|
375
|
+
async function doVerify({ frames }) {
|
|
376
|
+
const host = getHost(sessionKey);
|
|
377
|
+
if (!host.status.platform || !host.status.loaded) {
|
|
378
|
+
throw new Error("frame({op:'verify'}): no media loaded — loadMedia or build({output:'run'}) first.");
|
|
379
|
+
}
|
|
380
|
+
const json = jsonContent(await computeVerify(host, frames, sessionKey));
|
|
381
|
+
// verify's whole job is "look at the screen" — so push the exact frame it
|
|
382
|
+
// judged to the human's /livestream. Deferred provider: the PNG encode
|
|
383
|
+
// happens async after the agent's (JSON-only) response goes out, at zero
|
|
384
|
+
// cost to the agent. computeVerify already stepped the frames, so the
|
|
385
|
+
// host's current framebuffer IS the verified frame.
|
|
386
|
+
attachObserverFrame(json, host);
|
|
387
|
+
return json;
|
|
388
|
+
}
|
|
389
|
+
|
|
234
390
|
async function doStepAndShot({ frames, path: outPath, inline }) {
|
|
235
391
|
requireImageTarget(outPath, inline, "frame({op:'stepAndShot'})");
|
|
236
392
|
const host = getHost(sessionKey);
|
|
@@ -252,7 +408,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
252
408
|
|
|
253
409
|
server.tool(
|
|
254
410
|
"frame",
|
|
255
|
-
"Advance the emulator and capture frames. `op`: 'step' | 'screenshot' | 'stepAndShot' | 'stepInstruction'.\n" +
|
|
411
|
+
"Advance the emulator and capture frames. `op`: 'step' | 'screenshot' | 'stepAndShot' | 'stepInstruction' | 'verify'.\n" +
|
|
256
412
|
"'step': advance N `frames` as fast as possible — NO pacing/audio/vsync. Cores run at WASM speed, so frames:3600 " +
|
|
257
413
|
"(1 min of game time) finishes in ~5-30ms, cheaper than a screenshot. Don't be timid — skip a title with 300, a " +
|
|
258
414
|
"level with 7200; prefer ONE big call.\n" +
|
|
@@ -265,10 +421,18 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
265
421
|
"'stepAndShot': step + screenshot in ONE round-trip — the drive-then-look loop. (No overlayBoxes/scale here — png only.)\n" +
|
|
266
422
|
"'stepInstruction': execute exactly ONE CPU instruction and stop (finer than 'step'); freezes the CPU one " +
|
|
267
423
|
"instruction later and returns { pc }. Pair with cpu({op:'read'}) to watch registers change while tracing a routine.\n" +
|
|
424
|
+
"'verify': one-call 'is the game actually rendering / alive?' health check WITHOUT vision — for the spiral where an " +
|
|
425
|
+
"agent can't see the screen and doesn't know if a black frame means broken. Pass `frames` to boot-then-check in one " +
|
|
426
|
+
"call. Fuses (1) a pixel-content scan of the live framebuffer (distinctColors, dominant-color %) and (2) the " +
|
|
427
|
+
"per-platform render-ENABLE/NMI decode (reused from the rendering-context decoder — works on all 14 platforms). " +
|
|
428
|
+
"Returns {verified:true|false|null, issues[], pixels, render}. verified:null + unsettled when no frame has been " +
|
|
429
|
+
"stepped yet (it won't cry wolf on boot — step first). issues[] flags blankScreen/nearlyBlank/renderDisabled. " +
|
|
430
|
+
"renderDisabled is only raised when the registers SAY so (never on an undecodable platform). Pass/fail with no " +
|
|
431
|
+
"image tokens; for WHAT to fix, getPlatformDoc({platform, doc:'mental_model'}).\n" +
|
|
268
432
|
"IMAGE CONTRACT (screenshot/stepAndShot): the image goes to `path` (default, returns {path}) OR inline:true — " +
|
|
269
433
|
"you MUST pass one. Keeps PNGs out of context unless asked.",
|
|
270
434
|
{
|
|
271
|
-
op: z.enum(["step", "screenshot", "stepAndShot", "stepInstruction"]).describe("step frames; capture a screenshot; step+capture in one call;
|
|
435
|
+
op: z.enum(["step", "screenshot", "stepAndShot", "stepInstruction", "verify"]).describe("step frames; capture a screenshot; step+capture in one call; single-step one CPU instruction; or verify the game is actually rendering/alive (no vision needed)."),
|
|
272
436
|
frames: z.number().int().min(1).max(1_000_000).default(1).describe("op=step/stepAndShot: frames to advance (1-1,000,000). 36000 (10 min) usually completes in <1s — don't be conservative."),
|
|
273
437
|
format: z.enum(["png", "ascii"]).default("png").describe("op=screenshot: 'png' (default, real image) or 'ascii' (lossy text render)."),
|
|
274
438
|
path: z.string().optional().describe("op=screenshot/stepAndShot: absolute path to write to (required unless inline:true)."),
|
|
@@ -286,6 +450,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
286
450
|
case "screenshot": return doScreenshot(args);
|
|
287
451
|
case "stepAndShot": return doStepAndShot(args);
|
|
288
452
|
case "stepInstruction": return await stepInstructionCore(sessionKey);
|
|
453
|
+
case "verify": return await doVerify(args);
|
|
289
454
|
default: throw new Error(`frame: unknown op '${args.op}'`);
|
|
290
455
|
}
|
|
291
456
|
}),
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -304,52 +304,3 @@ export function registerTools(server, z, sessionKey) {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
// ---- helper: which category owns a tool name? ----
|
|
308
|
-
// Used so describeTool's error message can name the category to load.
|
|
309
|
-
// Maintained by hand for now; if categories explode we can derive this
|
|
310
|
-
// by registering each category into a dummy server and comparing diffs.
|
|
311
|
-
const TOOL_OWNER = {
|
|
312
|
-
// platforms category
|
|
313
|
-
platform: "platforms",
|
|
314
|
-
// run category
|
|
315
|
-
loadMedia: "run", host: "run",
|
|
316
|
-
frame: "run",
|
|
317
|
-
// input category
|
|
318
|
-
input: "input",
|
|
319
|
-
// state category
|
|
320
|
-
state: "state",
|
|
321
|
-
// memory category
|
|
322
|
-
memory: "memory",
|
|
323
|
-
// debug category
|
|
324
|
-
tiles: "debug", sprites: "debug",
|
|
325
|
-
background: "debug", encodeArt: "assets",
|
|
326
|
-
cpu: "debug", audioDebug: "debug",
|
|
327
|
-
symbols: "debug",
|
|
328
|
-
disasm: "debug",
|
|
329
|
-
cheats: "debug",
|
|
330
|
-
inspectTile: "debug",
|
|
331
|
-
// assets category
|
|
332
|
-
encodeAudio: "assets",
|
|
333
|
-
cart: "assets",
|
|
334
|
-
listRoms: "assets", romPatch: "assets", validateRom: "assets",
|
|
335
|
-
assembleSnippet: "assets",
|
|
336
|
-
|
|
337
|
-
text: "assets",
|
|
338
|
-
importArt: "assets",
|
|
339
|
-
palette: "debug",
|
|
340
|
-
// project category
|
|
341
|
-
scaffold: "project",
|
|
342
|
-
// show category (was: advanced)
|
|
343
|
-
playtest: "show",
|
|
344
|
-
// advanced category
|
|
345
|
-
runUntil: "advanced",
|
|
346
|
-
watch: "advanced", breakpoint: "advanced",
|
|
347
|
-
recordSession: "advanced",
|
|
348
|
-
// entry tier itself
|
|
349
|
-
catalog: "entry",
|
|
350
|
-
build: "entry", listRunnableFormats: "entry",
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
function ownerCategoryOf(name) {
|
|
354
|
-
return TOOL_OWNER[name] ?? null;
|
|
355
|
-
}
|
package/src/mcp/tools/input.js
CHANGED
|
@@ -65,18 +65,45 @@ function buttonShape(z) {
|
|
|
65
65
|
start: z.boolean().optional(),
|
|
66
66
|
select: z.boolean().optional(),
|
|
67
67
|
})
|
|
68
|
+
// passthrough (not the zod default of stripping) so a TYPO'd button name
|
|
69
|
+
// ({jump:true}, {aa:true}) survives into the handler and can be reported as
|
|
70
|
+
// ignored — instead of being silently dropped, leaving the agent believing
|
|
71
|
+
// it pressed something it didn't.
|
|
72
|
+
.passthrough()
|
|
68
73
|
.describe(
|
|
69
74
|
"Per-port controller state. Prefer the spatial face-button names (north/east/south/west) for cross-platform code — they map to the physical button in that compass position on each platform's controller (e.g. on NES east=A, on SNES east=A, on Genesis east=C). Raw libretro names (a/b/x/y/l/r/...) also work if you need direct control. Omitted buttons are released.",
|
|
70
75
|
);
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
// Every button key portInputToMask + the spatial resolver actually honor.
|
|
79
|
+
// Anything else in a `ports` object is a typo and gets reported, not pressed.
|
|
80
|
+
const KNOWN_BUTTONS = new Set([
|
|
81
|
+
"up", "down", "left", "right",
|
|
82
|
+
"north", "east", "south", "west",
|
|
83
|
+
"a", "b", "x", "y", "l", "r", "l2", "r2", "l3", "r3", "start", "select",
|
|
84
|
+
]);
|
|
85
|
+
|
|
73
86
|
// ── *Core functions for the `input` tool ──
|
|
74
87
|
|
|
75
88
|
/** op:'set' — set held controller state (persists until changed). */
|
|
76
89
|
function inputSetCore({ ports }, sessionKey) {
|
|
90
|
+
// Flag any key that isn't a real button BEFORE we set input — a typo
|
|
91
|
+
// ({jump:true}) would otherwise resolve to nothing and press silently.
|
|
92
|
+
const ignoredButtons = [];
|
|
93
|
+
ports.forEach((p, port) => {
|
|
94
|
+
for (const k of Object.keys(p)) {
|
|
95
|
+
if (p[k] === true && !KNOWN_BUTTONS.has(k)) ignoredButtons.push({ port, name: k });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
77
98
|
getHost(sessionKey).setInput({ ports });
|
|
78
|
-
const requested = ports.map((p) => Object.keys(p).filter((k) => p[k] === true));
|
|
79
|
-
return {
|
|
99
|
+
const requested = ports.map((p) => Object.keys(p).filter((k) => p[k] === true && KNOWN_BUTTONS.has(k)));
|
|
100
|
+
return {
|
|
101
|
+
inputSet: true,
|
|
102
|
+
requested,
|
|
103
|
+
...(ignoredButtons.length
|
|
104
|
+
? { ignoredButtons, ignoredNote: `Ignored ${ignoredButtons.length} unknown button name(s) — not pressed. Valid: ${[...KNOWN_BUTTONS].join(", ")}.` }
|
|
105
|
+
: {}),
|
|
106
|
+
};
|
|
80
107
|
}
|
|
81
108
|
|
|
82
109
|
/** op:'press' — press one named button N frames then release (port 0 default). */
|
|
@@ -168,7 +195,10 @@ export function registerInputTools(server, z, sessionKey) {
|
|
|
168
195
|
server.tool(
|
|
169
196
|
"input",
|
|
170
197
|
"Drive the controller. `op`: 'set' | 'press' | 'sequence' | 'navigate' | 'layout'.\n" +
|
|
171
|
-
"'set': hold controller state (persists until changed) via `ports:[{a:true,...},{...}]
|
|
198
|
+
"'set': hold controller state (persists until changed) via `ports:[{a:true,...},{...}]`. " +
|
|
199
|
+
"The held state is honored by frame({op:'step'}) AND by watch/breakpoint runs that have NO `pressDuring` " +
|
|
200
|
+
"schedule (they inherit it). If a watch/breakpoint IS given `pressDuring`, that schedule OWNS the pad for " +
|
|
201
|
+
"the run and this set state is ignored — so drive a watched window with `pressDuring`, not a prior `set`.\n" +
|
|
172
202
|
"'press': press one named `button` for `frames` then release (port 0 default).\n" +
|
|
173
203
|
"'sequence': scripted frame-by-frame `steps:[{input:{ports}, frames}]` for replays/tests.\n" +
|
|
174
204
|
"'navigate': walk a menu by advancing on SCREEN CHANGE — `steps:[{button, holdFrames?, maxWaitFrames?, " +
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { resolveCore } from "../../cores/registry.js";
|
|
2
|
-
import {
|
|
3
|
-
import { clearHost, getHost, getHostOrNull, resetHost } from "../state.js";
|
|
2
|
+
import { clearHost, getHost, getHostOrNull, rememberLastMedia, resetHost } from "../state.js";
|
|
4
3
|
import { jsonContent, safeTool, textContent } from "../util.js";
|
|
5
4
|
import { resolveCheatCodeForApply } from "./cheats.js";
|
|
6
5
|
|
|
@@ -19,7 +18,10 @@ export function registerLifecycleTools(server, z, sessionKey) {
|
|
|
19
18
|
await host.loadMedia({
|
|
20
19
|
platform,
|
|
21
20
|
...(bytes ? { bytes, virtualName } : { path }),
|
|
22
|
-
|
|
21
|
+
// Only force a mediaKind when the caller picked one; otherwise let the host
|
|
22
|
+
// derive it from the file extension (a C64 .d64 → "disk", .tap → "tape",
|
|
23
|
+
// .prg → "program") so status reports the kind honestly.
|
|
24
|
+
...(mediaKind ? { mediaKind } : {}),
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
// Pre-seed cheats BEFORE the first frame — so a boot-time cheat (e.g. a Game
|
|
@@ -42,6 +44,13 @@ export function registerLifecycleTools(server, z, sessionKey) {
|
|
|
42
44
|
}
|
|
43
45
|
});
|
|
44
46
|
}
|
|
47
|
+
// Remember what we loaded so a later host eviction (restart/reconnect) can
|
|
48
|
+
// tell the agent the exact loadMedia call to recover with. Survives reset.
|
|
49
|
+
rememberLastMedia(sessionKey, {
|
|
50
|
+
platform,
|
|
51
|
+
...(bytes ? { fromBase64: true } : { path: host.status.mediaPath ?? path }),
|
|
52
|
+
});
|
|
53
|
+
|
|
45
54
|
// Framebuffer dimensions are NOT known until the core has run at least one
|
|
46
55
|
// frame — before that, fbWidth/fbHeight hold a pre-boot default (e.g.
|
|
47
56
|
// 256×192 on Genesis) that does NOT match the real output resolution
|
|
@@ -101,7 +110,12 @@ export function registerLifecycleTools(server, z, sessionKey) {
|
|
|
101
110
|
switch (op) {
|
|
102
111
|
case "unload": {
|
|
103
112
|
const host = getHostOrNull(sessionKey);
|
|
104
|
-
if (host
|
|
113
|
+
if (!host || !host.status.loaded) {
|
|
114
|
+
// Don't claim success when there was nothing loaded — that masks a
|
|
115
|
+
// session/state mix-up (the agent thinks it unloaded media it never had).
|
|
116
|
+
return textContent("nothing to unload — no media is loaded in this session");
|
|
117
|
+
}
|
|
118
|
+
host.unloadMedia();
|
|
105
119
|
return textContent("unloaded");
|
|
106
120
|
}
|
|
107
121
|
case "shutdown":
|
package/src/mcp/tools/lospec.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// per-palette overrides.
|
|
5
5
|
|
|
6
6
|
import { jsonContent, safeTool } from "../util.js";
|
|
7
|
-
import { resolveIntent } from "../../platforms/common/intent.js";
|
|
8
7
|
import { inspectPaletteCore, getPlatformMasterPaletteCore } from "./platform-tools.js";
|
|
9
8
|
|
|
10
9
|
/** lospec.com hosts each palette at https://lospec.com/palette-list/<id>.json. */
|
|
@@ -61,24 +60,6 @@ async function fetchLospecPalette(id) {
|
|
|
61
60
|
};
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
/**
|
|
65
|
-
* Snap each color in `palette` to the nearest entry in `master`.
|
|
66
|
-
* Used when the caller wants a lospec palette but the platform has a
|
|
67
|
-
* fixed hardware palette (NES 2C02 master, etc.).
|
|
68
|
-
*/
|
|
69
|
-
function snapToMaster(palette, master) {
|
|
70
|
-
return palette.map(([r, g, b]) => {
|
|
71
|
-
let best = master[0];
|
|
72
|
-
let bestD = Infinity;
|
|
73
|
-
for (const [mr, mg, mb] of master) {
|
|
74
|
-
const dr = r - mr, dg = g - mg, db = b - mb;
|
|
75
|
-
const d = dr * dr + dg * dg + db * db;
|
|
76
|
-
if (d < bestD) { bestD = d; best = [mr, mg, mb]; }
|
|
77
|
-
}
|
|
78
|
-
return best;
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
63
|
/**
|
|
83
64
|
* Programmatic equivalent of the MCP tool. Exported for tests.
|
|
84
65
|
*
|
|
@@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { readFile, stat } from "node:fs/promises";
|
|
18
18
|
|
|
19
|
-
import { jsonContent
|
|
19
|
+
import { jsonContent } from "../util.js";
|
|
20
20
|
|
|
21
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
22
|
const __dirname = path.dirname(__filename);
|
|
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { PNG } from "pngjs";
|
|
8
8
|
import { getHost } from "../state.js";
|
|
9
|
-
import { imageContent, jsonContent
|
|
9
|
+
import { imageContent, jsonContent } from "../util.js";
|
|
10
10
|
|
|
11
11
|
// Consolidation: several handlers in this big shared file are extracted as
|
|
12
12
|
// *Core functions that the consolidated domain tools (palette/tiles/background/
|
|
@@ -37,10 +37,10 @@ import { getNesApuState } from "../../host/nes-apu-state.js";
|
|
|
37
37
|
import { decodeGenesisPSG, decodeGenesisYM2612 } from "../../host/gpgx-state.js";
|
|
38
38
|
import { decodeGbApu, decodeGbaApu } from "../../host/gb-apu-state.js";
|
|
39
39
|
import { decodeC64Sid } from "../../host/c64-sid-state.js";
|
|
40
|
-
import { decodeLynxMikey, decodeLynxPalette
|
|
40
|
+
import { decodeLynxMikey, decodeLynxPalette } from "../../host/lynx-mikey-state.js";
|
|
41
41
|
import { getPcePsgState } from "../../host/pce-psg-state.js";
|
|
42
42
|
import { getMsxAyState } from "../../host/msx-ay-state.js";
|
|
43
|
-
import { decodeGbaSprites, decodeGbaPalette
|
|
43
|
+
import { decodeGbaSprites, decodeGbaPalette } from "../../host/gba-video-state.js";
|
|
44
44
|
|
|
45
45
|
/** Resolve the platform to inspect: explicit arg → currently loaded host. */
|
|
46
46
|
function resolvePlatform(host, requested) {
|
|
@@ -119,7 +119,7 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
119
119
|
// SMS tiles live in VRAM at runtime — the cart has no fixed CHR
|
|
120
120
|
// region. Render all 448 tiles (the entire 16KB VRAM mapped to
|
|
121
121
|
// tiles), using the live first-BG-palette so colors look right.
|
|
122
|
-
const {
|
|
122
|
+
const { snapshotPalette } = await import("../../platforms/sms/vdp.js");
|
|
123
123
|
const { colors } = snapshotPalette(host, p);
|
|
124
124
|
// Use BG palette (entries 0..15) for rendering.
|
|
125
125
|
const bgPal = colors.slice(0, 16).map((c) => [c.r, c.g, c.b]);
|