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.
Files changed (209) hide show
  1. package/AGENTS.md +75 -16
  2. package/CHANGELOG.md +316 -0
  3. package/examples/README.md +2 -0
  4. package/examples/atari2600/templates/platformer.asm +460 -0
  5. package/examples/atari2600/templates/racing.asm +463 -0
  6. package/examples/atari2600/templates/shmup.asm +386 -0
  7. package/examples/atari2600/templates/sports.asm +362 -0
  8. package/examples/atari7800/templates/default.c +49 -5
  9. package/examples/atari7800/templates/hello_sprite.c +48 -4
  10. package/examples/atari7800/templates/music_demo.c +47 -2
  11. package/examples/atari7800/templates/platformer.c +43 -4
  12. package/examples/atari7800/templates/puzzle.c +39 -4
  13. package/examples/atari7800/templates/racing.c +39 -4
  14. package/examples/atari7800/templates/shmup.c +40 -2
  15. package/examples/atari7800/templates/sports.c +36 -5
  16. package/examples/c64/templates/platformer.c +19 -5
  17. package/examples/c64/templates/puzzle.c +32 -2
  18. package/examples/c64/templates/shmup.c +28 -2
  19. package/examples/c64/templates/sports.c +30 -2
  20. package/examples/c64/templates/tile_engine.c +77 -27
  21. package/examples/gb/templates/default.c +110 -16
  22. package/examples/gb/templates/hello_sprite.c +15 -6
  23. package/examples/gb/templates/music_demo.c +36 -0
  24. package/examples/gb/templates/platformer.c +28 -6
  25. package/examples/gb/templates/puzzle.c +35 -4
  26. package/examples/gb/templates/racing.c +75 -10
  27. package/examples/gb/templates/shmup.c +41 -3
  28. package/examples/gb/templates/sports.c +51 -3
  29. package/examples/gb/templates/tile_engine.c +3 -2
  30. package/examples/gba/templates/gba_hello.c +29 -11
  31. package/examples/gba/templates/maxmod_demo.c +36 -2
  32. package/examples/gba/templates/platformer.c +3 -1
  33. package/examples/gba/templates/puzzle.c +15 -3
  34. package/examples/gba/templates/racing.c +65 -3
  35. package/examples/gba/templates/shmup.c +41 -4
  36. package/examples/gba/templates/sports.c +36 -2
  37. package/examples/gba/templates/tonc_hello.c +41 -5
  38. package/examples/gba/templates/tonc_hello_sprite.c +35 -1
  39. package/examples/gbc/templates/default.c +103 -26
  40. package/examples/gbc/templates/hello_sprite.c +12 -3
  41. package/examples/gbc/templates/music_demo.c +56 -12
  42. package/examples/gbc/templates/platformer.c +28 -6
  43. package/examples/gbc/templates/puzzle.c +35 -4
  44. package/examples/gbc/templates/racing.c +88 -21
  45. package/examples/gbc/templates/shmup.c +37 -3
  46. package/examples/gbc/templates/sports.c +48 -3
  47. package/examples/gbc/templates/tile_engine.c +3 -2
  48. package/examples/genesis/main.s +53 -1
  49. package/examples/genesis/templates/hello_sprite.c +25 -3
  50. package/examples/genesis/templates/puzzle.c +37 -3
  51. package/examples/genesis/templates/racing.c +44 -11
  52. package/examples/genesis/templates/sgdk_hello.c +34 -1
  53. package/examples/genesis/templates/shmup.c +31 -1
  54. package/examples/genesis/templates/shmup_2p.c +31 -0
  55. package/examples/genesis/templates/xgm2_demo.c +20 -0
  56. package/examples/gg/templates/default.c +56 -18
  57. package/examples/gg/templates/hello_sprite.c +25 -2
  58. package/examples/gg/templates/music_demo.c +24 -2
  59. package/examples/gg/templates/platformer.c +18 -12
  60. package/examples/gg/templates/puzzle.c +38 -7
  61. package/examples/gg/templates/racing.c +58 -9
  62. package/examples/gg/templates/shmup.c +47 -3
  63. package/examples/gg/templates/sports.c +57 -16
  64. package/examples/gg/templates/tile_engine.c +12 -6
  65. package/examples/lynx/templates/default.c +39 -8
  66. package/examples/lynx/templates/hello_sprite.c +15 -1
  67. package/examples/lynx/templates/music_demo.c +13 -1
  68. package/examples/lynx/templates/puzzle.c +28 -1
  69. package/examples/lynx/templates/racing.c +34 -7
  70. package/examples/lynx/templates/shmup.c +42 -3
  71. package/examples/lynx/templates/sports.c +29 -2
  72. package/examples/msx/platformer/main.c +213 -0
  73. package/examples/msx/puzzle/main.c +250 -0
  74. package/examples/msx/racing/main.c +249 -0
  75. package/examples/msx/shmup/main.c +288 -0
  76. package/examples/msx/sports/main.c +182 -0
  77. package/examples/nes/templates/default.c +67 -19
  78. package/examples/nes/templates/hello_sprite.c +35 -0
  79. package/examples/nes/templates/music_demo.c +40 -0
  80. package/examples/nes/templates/platformer.c +65 -6
  81. package/examples/nes/templates/puzzle.c +67 -6
  82. package/examples/nes/templates/racing.c +45 -13
  83. package/examples/nes/templates/shmup.c +51 -2
  84. package/examples/nes/templates/sports.c +51 -6
  85. package/examples/pce/catch_game/main.c +22 -3
  86. package/examples/pce/music_sfx/main.c +28 -1
  87. package/examples/pce/platformer/main.c +283 -0
  88. package/examples/pce/puzzle/main.c +304 -0
  89. package/examples/pce/racing/main.c +304 -0
  90. package/examples/pce/shmup/main.c +346 -0
  91. package/examples/pce/sports/main.c +254 -0
  92. package/examples/pce/sprite_move/main.c +7 -2
  93. package/examples/sms/main.c +35 -6
  94. package/examples/sms/templates/hello_sprite.c +29 -3
  95. package/examples/sms/templates/music_demo.c +18 -4
  96. package/examples/sms/templates/puzzle.c +34 -5
  97. package/examples/sms/templates/racing.c +39 -2
  98. package/examples/sms/templates/shmup.c +41 -2
  99. package/examples/sms/templates/shmup_2p.c +24 -1
  100. package/examples/sms/templates/sports.c +47 -4
  101. package/examples/snes/main.asm +108 -17
  102. package/examples/snes/templates/c-hello-data.asm +23 -0
  103. package/examples/snes/templates/c-hello.c +18 -1
  104. package/examples/snes/templates/default.c +50 -28
  105. package/examples/snes/templates/hello_sprite-data.asm +23 -0
  106. package/examples/snes/templates/hello_sprite.c +17 -1
  107. package/examples/snes/templates/music_demo-data.asm +23 -0
  108. package/examples/snes/templates/music_demo.c +22 -4
  109. package/examples/snes/templates/platformer-data.asm +22 -0
  110. package/examples/snes/templates/platformer.c +20 -2
  111. package/examples/snes/templates/puzzle-data.asm +22 -0
  112. package/examples/snes/templates/puzzle.c +21 -2
  113. package/examples/snes/templates/racing-data.asm +22 -0
  114. package/examples/snes/templates/racing.c +17 -1
  115. package/examples/snes/templates/shmup-data.asm +22 -0
  116. package/examples/snes/templates/shmup.c +20 -1
  117. package/examples/snes/templates/sports-data.asm +22 -0
  118. package/examples/snes/templates/sports.c +16 -1
  119. package/package.json +1 -1
  120. package/src/cheats/gamegenie.js +0 -1
  121. package/src/cli/smoke.js +1 -3
  122. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  123. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  124. package/src/host/LibretroHost.js +191 -16
  125. package/src/host/callbacks.js +9 -1
  126. package/src/host/chafa-render.js +2 -0
  127. package/src/host/dsp-state.js +2 -2
  128. package/src/host/gpgx-state.js +4 -0
  129. package/src/host/types.js +15 -8
  130. package/src/http/routes.js +1 -1
  131. package/src/http/tool-registry.js +26 -1
  132. package/src/mcp/server.js +1 -1
  133. package/src/mcp/state.js +36 -0
  134. package/src/mcp/tools/address-to-symbol.js +0 -1
  135. package/src/mcp/tools/art-loaders.js +1 -1
  136. package/src/mcp/tools/cart-parts.js +75 -4
  137. package/src/mcp/tools/classify-region.js +1 -1
  138. package/src/mcp/tools/diff-roms.js +1 -1
  139. package/src/mcp/tools/disasm-rebuild.js +507 -0
  140. package/src/mcp/tools/disasm.js +97 -9
  141. package/src/mcp/tools/find-references.js +1 -2
  142. package/src/mcp/tools/font-map.js +1 -1
  143. package/src/mcp/tools/frame.js +168 -3
  144. package/src/mcp/tools/index.js +0 -49
  145. package/src/mcp/tools/input-layout.js +0 -1
  146. package/src/mcp/tools/input.js +33 -3
  147. package/src/mcp/tools/lifecycle.js +18 -4
  148. package/src/mcp/tools/lospec.js +0 -19
  149. package/src/mcp/tools/platform-docs.js +1 -1
  150. package/src/mcp/tools/platform-tools.js +4 -4
  151. package/src/mcp/tools/project.js +54 -11
  152. package/src/mcp/tools/reinject.js +0 -1
  153. package/src/mcp/tools/rom-id.js +2 -2
  154. package/src/mcp/tools/snippets.js +2 -2
  155. package/src/mcp/tools/sprite-pipeline.js +1 -2
  156. package/src/mcp/tools/state.js +201 -14
  157. package/src/mcp/tools/tile-inspect.js +1 -1
  158. package/src/mcp/tools/toolchain.js +105 -12
  159. package/src/mcp/tools/watch-memory.js +137 -16
  160. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +34 -0
  161. package/src/platforms/atari2600/TROUBLESHOOTING.md +6 -0
  162. package/src/platforms/atari7800/TROUBLESHOOTING.md +6 -0
  163. package/src/platforms/c64/MENTAL_MODEL.md +45 -1
  164. package/src/platforms/c64/TROUBLESHOOTING.md +6 -0
  165. package/src/platforms/c64/d64.js +280 -0
  166. package/src/platforms/c64/sid.js +0 -2
  167. package/src/platforms/common/metasprite-adapters.js +1 -1
  168. package/src/platforms/common/metasprite-codegen.js +3 -3
  169. package/src/platforms/common/registers.js +5 -3
  170. package/src/platforms/gb/MENTAL_MODEL.md +10 -0
  171. package/src/platforms/gb/TROUBLESHOOTING.md +6 -0
  172. package/src/platforms/gb/lib/c/gb_runtime.c +4 -4
  173. package/src/platforms/gba/TROUBLESHOOTING.md +6 -0
  174. package/src/platforms/gbc/TROUBLESHOOTING.md +6 -0
  175. package/src/platforms/gbc/lib/c/gb_runtime.c +4 -4
  176. package/src/platforms/genesis/TROUBLESHOOTING.md +6 -0
  177. package/src/platforms/gg/TROUBLESHOOTING.md +6 -0
  178. package/src/platforms/lynx/TROUBLESHOOTING.md +6 -0
  179. package/src/platforms/msx/MENTAL_MODEL.md +10 -6
  180. package/src/platforms/msx/TROUBLESHOOTING.md +6 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +63 -2
  182. package/src/platforms/nes/TROUBLESHOOTING.md +6 -0
  183. package/src/platforms/nes/image-to-tilemap.js +3 -0
  184. package/src/platforms/nes/lib/asm/famitone2.s +5 -1
  185. package/src/platforms/pce/MENTAL_MODEL.md +9 -4
  186. package/src/platforms/pce/TROUBLESHOOTING.md +6 -0
  187. package/src/platforms/pce/lib/c/pce_video.c +1 -1
  188. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  189. package/src/platforms/snes/TROUBLESHOOTING.md +6 -0
  190. package/src/platforms/snes/brr.js +0 -2
  191. package/src/playtest/playtest.js +0 -7
  192. package/src/rom-id/identifier.js +15 -0
  193. package/src/toolchains/asar/asar.js +0 -9
  194. package/src/toolchains/assemble-snippet.js +30 -12
  195. package/src/toolchains/cc65/ines.js +145 -0
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +14 -1
  197. package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
  198. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
  199. package/src/toolchains/common/reassemble.js +10 -3
  200. package/src/toolchains/common/sdk-cache.js +1 -1
  201. package/src/toolchains/genesis-c/genesis-c.js +5 -3
  202. package/src/toolchains/index.js +27 -3
  203. package/src/toolchains/parse-errors.js +78 -1
  204. package/src/toolchains/sdcc/preflight-lint.js +5 -1
  205. package/src/toolchains/sdcc/sdcc.js +1 -1
  206. package/src/toolchains/sjasm/sjasm.js +1 -1
  207. package/src/toolchains/snes-c/snes-c.js +2 -2
  208. package/src/toolchains/vasm68k/vasm68k.js +2 -4
  209. package/src/toolchains/wladx/wladx.js +1 -1
@@ -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, bank = 0) {
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, bank = 0) {
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). Edit the .asm files and rebuild.`
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
- function dataRegionSource(bytes, startAddress) {
1141
- const rows = [`\t.org $${startAddress.toString(16).toUpperCase()}`];
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((b) => "$" + b.toString(16).padStart(2, "0").toUpperCase()).join(",") +
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 { jsonContent, safeTool } from "../util.js";
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, writeFile } from "node:fs/promises";
19
+ import { readFile } from "node:fs/promises";
20
20
  import { jsonContent, safeTool } from "../util.js";
21
21
  import { getHost } from "../state.js";
22
22
 
@@ -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; or single-step one CPU instruction."),
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
  }),
@@ -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
- }
@@ -3,7 +3,6 @@
3
3
  // know exactly what each bit/id means.
4
4
 
5
5
  import { FACE_BUTTON_MAP } from "../../host/types.js";
6
- import { jsonContent, safeTool } from "../util.js";
7
6
 
8
7
  const HARDWARE_LAYOUTS = {
9
8
  nes: {
@@ -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 { inputSet: true, requested };
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,...},{...}]`.\n" +
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 { defaultMediaKind } from "../../host/index.js";
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
- mediaKind: mediaKind ?? defaultMediaKind(platform),
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) host.unloadMedia();
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":
@@ -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, safeTool, textContent } from "../util.js";
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, safeTool, textContent } from "../util.js";
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, decodeLynxRenderingContext } from "../../host/lynx-mikey-state.js";
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, decodeGbaRenderingContext } from "../../host/gba-video-state.js";
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 { snapshotPatternTiles, snapshotPalette } = await import("../../platforms/sms/vdp.js");
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]);