romdevtools 0.21.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 (127) hide show
  1. package/AGENTS.md +15 -4
  2. package/CHANGELOG.md +58 -0
  3. package/examples/atari7800/templates/hello_sprite.c +48 -4
  4. package/examples/atari7800/templates/music_demo.c +47 -2
  5. package/examples/c64/templates/tile_engine.c +77 -27
  6. package/examples/gb/templates/hello_sprite.c +15 -6
  7. package/examples/gb/templates/music_demo.c +36 -0
  8. package/examples/gb/templates/platformer.c +3 -2
  9. package/examples/gb/templates/puzzle.c +3 -2
  10. package/examples/gb/templates/racing.c +3 -2
  11. package/examples/gb/templates/shmup.c +3 -2
  12. package/examples/gb/templates/sports.c +3 -2
  13. package/examples/gb/templates/tile_engine.c +3 -2
  14. package/examples/gba/templates/maxmod_demo.c +36 -2
  15. package/examples/gba/templates/platformer.c +3 -1
  16. package/examples/gba/templates/tonc_hello_sprite.c +35 -1
  17. package/examples/gbc/templates/hello_sprite.c +12 -3
  18. package/examples/gbc/templates/music_demo.c +56 -12
  19. package/examples/gbc/templates/platformer.c +3 -2
  20. package/examples/gbc/templates/puzzle.c +3 -2
  21. package/examples/gbc/templates/racing.c +3 -2
  22. package/examples/gbc/templates/shmup.c +3 -2
  23. package/examples/gbc/templates/sports.c +3 -2
  24. package/examples/gbc/templates/tile_engine.c +3 -2
  25. package/examples/genesis/main.s +53 -1
  26. package/examples/genesis/templates/hello_sprite.c +25 -3
  27. package/examples/genesis/templates/shmup_2p.c +31 -0
  28. package/examples/genesis/templates/xgm2_demo.c +20 -0
  29. package/examples/gg/templates/hello_sprite.c +25 -2
  30. package/examples/gg/templates/music_demo.c +24 -2
  31. package/examples/gg/templates/racing.c +7 -4
  32. package/examples/gg/templates/sports.c +11 -13
  33. package/examples/gg/templates/tile_engine.c +12 -6
  34. package/examples/lynx/templates/hello_sprite.c +15 -1
  35. package/examples/lynx/templates/music_demo.c +13 -1
  36. package/examples/nes/templates/hello_sprite.c +35 -0
  37. package/examples/nes/templates/music_demo.c +40 -0
  38. package/examples/pce/catch_game/main.c +22 -3
  39. package/examples/pce/music_sfx/main.c +28 -1
  40. package/examples/pce/sprite_move/main.c +7 -2
  41. package/examples/sms/templates/hello_sprite.c +29 -3
  42. package/examples/sms/templates/music_demo.c +18 -4
  43. package/examples/sms/templates/shmup_2p.c +24 -1
  44. package/examples/sms/templates/sports.c +4 -2
  45. package/examples/snes/main.asm +108 -17
  46. package/examples/snes/templates/c-hello-data.asm +23 -0
  47. package/examples/snes/templates/c-hello.c +18 -1
  48. package/examples/snes/templates/hello_sprite-data.asm +23 -0
  49. package/examples/snes/templates/hello_sprite.c +17 -1
  50. package/examples/snes/templates/music_demo-data.asm +23 -0
  51. package/examples/snes/templates/music_demo.c +22 -4
  52. package/examples/snes/templates/platformer.c +4 -1
  53. package/examples/snes/templates/puzzle.c +4 -1
  54. package/package.json +1 -1
  55. package/src/cheats/gamegenie.js +0 -1
  56. package/src/cli/smoke.js +1 -3
  57. package/src/host/LibretroHost.js +69 -15
  58. package/src/host/chafa-render.js +2 -0
  59. package/src/host/dsp-state.js +2 -2
  60. package/src/host/gpgx-state.js +4 -0
  61. package/src/http/routes.js +1 -1
  62. package/src/mcp/server.js +1 -1
  63. package/src/mcp/state.js +36 -0
  64. package/src/mcp/tools/address-to-symbol.js +0 -1
  65. package/src/mcp/tools/art-loaders.js +1 -1
  66. package/src/mcp/tools/cart-parts.js +0 -1
  67. package/src/mcp/tools/classify-region.js +1 -1
  68. package/src/mcp/tools/diff-roms.js +1 -1
  69. package/src/mcp/tools/disasm-rebuild.js +1 -1
  70. package/src/mcp/tools/disasm.js +2 -3
  71. package/src/mcp/tools/find-references.js +1 -2
  72. package/src/mcp/tools/font-map.js +1 -1
  73. package/src/mcp/tools/index.js +0 -49
  74. package/src/mcp/tools/input-layout.js +0 -1
  75. package/src/mcp/tools/input.js +33 -3
  76. package/src/mcp/tools/lifecycle.js +14 -2
  77. package/src/mcp/tools/lospec.js +0 -19
  78. package/src/mcp/tools/platform-docs.js +1 -1
  79. package/src/mcp/tools/platform-tools.js +4 -4
  80. package/src/mcp/tools/project.js +0 -2
  81. package/src/mcp/tools/reinject.js +0 -1
  82. package/src/mcp/tools/rom-id.js +2 -2
  83. package/src/mcp/tools/snippets.js +2 -2
  84. package/src/mcp/tools/sprite-pipeline.js +1 -2
  85. package/src/mcp/tools/tile-inspect.js +1 -1
  86. package/src/mcp/tools/toolchain.js +29 -9
  87. package/src/mcp/tools/watch-memory.js +13 -3
  88. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +34 -0
  89. package/src/platforms/atari2600/TROUBLESHOOTING.md +6 -0
  90. package/src/platforms/atari7800/TROUBLESHOOTING.md +6 -0
  91. package/src/platforms/c64/TROUBLESHOOTING.md +6 -0
  92. package/src/platforms/c64/d64.js +0 -1
  93. package/src/platforms/c64/sid.js +0 -2
  94. package/src/platforms/common/metasprite-adapters.js +1 -1
  95. package/src/platforms/common/metasprite-codegen.js +3 -3
  96. package/src/platforms/common/registers.js +5 -3
  97. package/src/platforms/gb/TROUBLESHOOTING.md +6 -0
  98. package/src/platforms/gb/lib/c/gb_runtime.c +4 -4
  99. package/src/platforms/gba/TROUBLESHOOTING.md +6 -0
  100. package/src/platforms/gbc/TROUBLESHOOTING.md +6 -0
  101. package/src/platforms/gbc/lib/c/gb_runtime.c +4 -4
  102. package/src/platforms/genesis/TROUBLESHOOTING.md +6 -0
  103. package/src/platforms/gg/TROUBLESHOOTING.md +6 -0
  104. package/src/platforms/lynx/TROUBLESHOOTING.md +6 -0
  105. package/src/platforms/msx/TROUBLESHOOTING.md +6 -0
  106. package/src/platforms/nes/TROUBLESHOOTING.md +6 -0
  107. package/src/platforms/nes/image-to-tilemap.js +3 -0
  108. package/src/platforms/nes/lib/asm/famitone2.s +5 -1
  109. package/src/platforms/pce/TROUBLESHOOTING.md +6 -0
  110. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  111. package/src/platforms/snes/TROUBLESHOOTING.md +6 -0
  112. package/src/platforms/snes/brr.js +0 -2
  113. package/src/playtest/playtest.js +0 -7
  114. package/src/toolchains/asar/asar.js +0 -9
  115. package/src/toolchains/assemble-snippet.js +30 -12
  116. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +14 -1
  117. package/src/toolchains/common/reassemble.js +0 -1
  118. package/src/toolchains/common/sdk-cache.js +1 -1
  119. package/src/toolchains/genesis-c/genesis-c.js +5 -3
  120. package/src/toolchains/index.js +27 -3
  121. package/src/toolchains/parse-errors.js +78 -1
  122. package/src/toolchains/sdcc/preflight-lint.js +5 -1
  123. package/src/toolchains/sdcc/sdcc.js +1 -1
  124. package/src/toolchains/sjasm/sjasm.js +1 -1
  125. package/src/toolchains/snes-c/snes-c.js +2 -2
  126. package/src/toolchains/vasm68k/vasm68k.js +2 -4
  127. package/src/toolchains/wladx/wladx.js +1 -1
@@ -24,6 +24,7 @@ let lastSettings = "";
24
24
  // I had BLOCK=1, ASCII=2; real BLOCK=0x8, ASCII=0x4000. The bad
25
25
  // values silently picked an unrelated tag, which is why "ascii"
26
26
  // mode was still rendering Unicode block glyphs.
27
+ /* eslint-disable no-unused-vars -- the full chafa tag enum is kept for reference; not all are used. */
27
28
  const TAG_SPACE = 0x1;
28
29
  const TAG_SOLID = 0x2;
29
30
  const TAG_STIPPLE = 0x4;
@@ -35,6 +36,7 @@ const TAG_BRAILLE = 0x800;
35
36
  const TAG_ASCII = 0x4000;
36
37
  const TAG_SEXTANT = 0x400000;
37
38
  const TAG_OCTANT = 0x4000000;
39
+ /* eslint-enable no-unused-vars */
38
40
 
39
41
  const SYMBOL_TAGS = {
40
42
  // Pure ASCII glyphs (space + printable 7-bit) — most text-shaped,
@@ -102,8 +102,8 @@ export function decodeSnes9xDSP(state) {
102
102
  bufLastSamples.push(u >= 0x8000 ? u - 0x10000 : u);
103
103
  }
104
104
  p += 24;
105
- const interpPos = state[p] | (state[p + 1] << 8); p += 2;
106
- const brrAddr = state[p] | (state[p + 1] << 8); p += 2;
105
+ p += 2; // interpPos (decoded field, not surfaced)
106
+ p += 2; // brrAddr (decoded field, not surfaced)
107
107
  const env = state[p] | (state[p + 1] << 8); p += 2;
108
108
  const hiddenEnvU = state[p] | (state[p + 1] << 8); p += 2;
109
109
  const hiddenEnv = hiddenEnvU >= 0x8000 ? hiddenEnvU - 0x10000 : hiddenEnvU;
@@ -17,7 +17,11 @@ const formatCpuState = (s) => s;
17
17
  // entry is 5 × 4 = 20 bytes, so memory_map takes 256 × 20 = 5120 bytes.
18
18
  // After that come the fields we want.
19
19
  const M68K_BASE = 5120; // start of cpu_idle_t poll
20
+ // M68K_POLL / M68K_CYCLES document the struct layout (consumed implicitly by the
21
+ // next offset) — keep them named even though nothing reads them directly.
22
+ // eslint-disable-next-line no-unused-vars
20
23
  const M68K_POLL = M68K_BASE + 0; // 12 bytes
24
+ // eslint-disable-next-line no-unused-vars
21
25
  const M68K_CYCLES = M68K_BASE + 12; // 12 bytes (cycles + refresh_cycles + cycle_end)
22
26
  const M68K_DAR = M68K_BASE + 24; // uint dar[16] — D0..D7 then A0..A7
23
27
  const M68K_PC = M68K_DAR + 64; // uint pc
@@ -18,7 +18,7 @@
18
18
  // (the app already mounts localhostHostValidation()).
19
19
 
20
20
  import { buildToolRegistry, runTool, toolJsonSchema } from "./tool-registry.js";
21
- import { skillPreamble, skillToolReference, buildSkillDoc } from "./skill-doc.js";
21
+ import { buildSkillDoc } from "./skill-doc.js";
22
22
  import { swaggerHtml, swaggerAsset } from "./swagger.js";
23
23
  import { observer } from "../observer/bus.js";
24
24
  import { log } from "../mcp/log.js";
package/src/mcp/server.js CHANGED
@@ -140,7 +140,7 @@ async function main() {
140
140
  // in verbose mode (log.debug handles that split). This keeps the console
141
141
  // quiet in prod while /log stays rich enough to diagnose what an agent did.
142
142
  if (req.method === "POST" && req.body) {
143
- const { method, id, params } = req.body;
143
+ const { method, params } = req.body;
144
144
  if (method === "tools/call") {
145
145
  const argKeys = params?.arguments ? Object.keys(params.arguments) : [];
146
146
  const summary = argKeys.map((k) => {
package/src/mcp/state.js CHANGED
@@ -14,6 +14,24 @@ import { LibretroHost } from "../host/index.js";
14
14
  /** @type {Map<string, LibretroHost>} */
15
15
  const hosts = new Map();
16
16
 
17
+ // What this session last loaded, kept OUTSIDE the host map so it SURVIVES a
18
+ // host eviction (server restart / session reconnect / unload). The host itself
19
+ // is gone in those cases, so the "No ROM loaded" error has nothing to read —
20
+ // this is the breadcrumb that lets the error tell the agent exactly how to
21
+ // recover ("you last loaded <X>; re-run loadMedia to pick back up") instead of
22
+ // a generic wipe. Set by loadMedia on success; never cleared on eviction.
23
+ /** @type {Map<string, {platform?: string, path?: string, fromBase64?: boolean}>} */
24
+ const lastMedia = new Map();
25
+
26
+ /** Record the media a session last loaded (for recovery hints). @param {string} sessionKey */
27
+ export function rememberLastMedia(sessionKey, info) {
28
+ lastMedia.set(sessionKey, info);
29
+ }
30
+ /** @param {string} sessionKey */
31
+ export function getLastMedia(sessionKey) {
32
+ return lastMedia.get(sessionKey) ?? null;
33
+ }
34
+
17
35
  /**
18
36
  * @param {string} sessionKey
19
37
  * @returns {LibretroHost}
@@ -21,6 +39,24 @@ const hosts = new Map();
21
39
  export function getHost(sessionKey) {
22
40
  const host = hosts.get(sessionKey);
23
41
  if (!host) {
42
+ // If THIS session loaded media before, the host was evicted (restart /
43
+ // reconnect / unload) — lead with the exact recovery call instead of the
44
+ // generic "you're in the wrong session" guidance, which doesn't apply here.
45
+ const prev = lastMedia.get(sessionKey);
46
+ if (prev && (prev.path || prev.fromBase64)) {
47
+ const recall = prev.path
48
+ ? `loadMedia({ platform: "${prev.platform}", path: "${prev.path}" })`
49
+ : `loadMedia({ platform: "${prev.platform}", base64: ... }) (your ROM came from base64 — re-supply the bytes)`;
50
+ throw new Error(
51
+ "No ROM loaded in this session — the host was evicted (the server restarted, " +
52
+ "your session reconnected, or the media was unloaded). Emulator state lives in " +
53
+ "server memory only, so it did not survive. RECOVER by re-running your last load:\n " +
54
+ recall +
55
+ "\nThen replay any boot/navigate steps to get back to where you were. " +
56
+ "(If instead you expected a DIFFERENT session, you may be sending an inconsistent " +
57
+ "`x-romdev-session` header — reuse one stable id on every call.)",
58
+ );
59
+ }
24
60
  throw new Error(
25
61
  "No ROM loaded in this session — call loadMedia({path}) first. " +
26
62
  "If you DID loadMedia and still see this, your calls are landing in DIFFERENT " +
@@ -18,7 +18,6 @@
18
18
  // - Future: vasm listings, asar symbol tables
19
19
 
20
20
  import { readFile } from "node:fs/promises";
21
- import { jsonContent, safeTool } from "../util.js";
22
21
  import { parseGnuLdMap, isGnuLdMap } from "../../toolchains/gnu-ld-map.js";
23
22
 
24
23
  function parseSdldStyle(text) {
@@ -407,7 +407,7 @@ function aseCelToRgba(cel, colorDepth, palette) {
407
407
  return rgba;
408
408
  }
409
409
 
410
- async function loadAsepriteSheetImpl({ path: asePath, platform, tile_size = 8, outputDir, slice_strategy = "slices", emit = "raw", emitDefines = false }) {
410
+ async function loadAsepriteSheetImpl({ path: asePath, platform, _tile_size = 8, outputDir, slice_strategy = "slices", emit = "raw", emitDefines = false }) {
411
411
  const buf = await readFile(asePath);
412
412
  const ase = new Aseprite(buf, path.basename(asePath));
413
413
  ase.parse();
@@ -131,7 +131,6 @@ function extractNes(data) {
131
131
  */
132
132
  function extractSnes(data) {
133
133
  const copierOff = (data.length % 0x8000 === 0x200) ? 0x200 : 0;
134
- const loMapper = data[copierOff + 0x7FC0 + 0x15];
135
134
  const hiMapper = data[copierOff + 0xFFC0 + 0x15];
136
135
  const isLo = !(hiMapper === 0x21 || hiMapper === 0x31);
137
136
  const internalHeaderBase = copierOff + (isLo ? 0x7FC0 : 0xFFC0);
@@ -51,7 +51,7 @@ function zeroRatio(bytes) {
51
51
  * @param {boolean} [opts.bigEndian] platform endianness (for pointer-table guess)
52
52
  * @returns {{ looksLike: string, printableRatio:number, entropy:number, zeroRatio:number, longestAsciiRun:number, asciiPreview:string|null, confidence:string, note:string }}
53
53
  */
54
- export function classifyBytes(bytes, opts = {}) {
54
+ export function classifyBytes(bytes, _opts = {}) {
55
55
  const pr = printableRatio(bytes);
56
56
  const ent = entropy(bytes);
57
57
  const zr = zeroRatio(bytes);
@@ -49,7 +49,7 @@ function regionMapForRom(platform, data) {
49
49
  * live anywhere the developer wants. We tag the header so diffs there
50
50
  * are obvious.
51
51
  */
52
- function regionMapSms(data, platform) {
52
+ function regionMapSms(data, _platform) {
53
53
  const regions = [];
54
54
  // Header lives at $7FF0 IF the file is at least 32 KB. Some homebrew
55
55
  // skips the header entirely (just runs from $0000) — flag with kind
@@ -135,7 +135,7 @@ const PLANNERS = {
135
135
  // a one-call build() rebuild. planRegions strips the 2-byte load address
136
136
  // (fileOffset 2); we re-emit it via a synthesized LOADADDR segment + a custom
137
137
  // 2-area .cfg (LOADADDR then the body). PROVEN byte-identical via build().
138
- c64(data, regions) {
138
+ c64(data, _regions) {
139
139
  const loadAddr = data[0] | (data[1] << 8);
140
140
  const bodyLen = data.length - 2;
141
141
  const loadaddrSrc =
@@ -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.)
@@ -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
 
@@ -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,5 +1,5 @@
1
1
  import { resolveCore } from "../../cores/registry.js";
2
- import { clearHost, getHost, getHostOrNull, resetHost } from "../state.js";
2
+ import { clearHost, getHost, getHostOrNull, rememberLastMedia, resetHost } from "../state.js";
3
3
  import { jsonContent, safeTool, textContent } from "../util.js";
4
4
  import { resolveCheatCodeForApply } from "./cheats.js";
5
5
 
@@ -44,6 +44,13 @@ export function registerLifecycleTools(server, z, sessionKey) {
44
44
  }
45
45
  });
46
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
+
47
54
  // Framebuffer dimensions are NOT known until the core has run at least one
48
55
  // frame — before that, fbWidth/fbHeight hold a pre-boot default (e.g.
49
56
  // 256×192 on Genesis) that does NOT match the real output resolution
@@ -103,7 +110,12 @@ export function registerLifecycleTools(server, z, sessionKey) {
103
110
  switch (op) {
104
111
  case "unload": {
105
112
  const host = getHostOrNull(sessionKey);
106
- 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();
107
119
  return textContent("unloaded");
108
120
  }
109
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]);
@@ -8,7 +8,6 @@
8
8
  // the project elsewhere and rebuild with cc65/sdcc directly, every byte
9
9
  // that compiles is in the directory.
10
10
 
11
- import { readFile, writeFile } from "node:fs/promises";
12
11
  import { jsonContent, safeTool } from "../util.js";
13
12
  import { starterSnippetsCore, copyStarterSnippetsCore } from "./snippets.js";
14
13
 
@@ -1776,7 +1775,6 @@ Compiles **C89**, not C99/C11. Stick to:
1776
1775
  // runtime tmpl.runtime list typically includes them) are skipped.
1777
1776
  const snippetFiles = [];
1778
1777
  if (withSnippets) {
1779
- const { listSnippetsForPlatform } = await import("./snippets.js").catch(() => ({}));
1780
1778
  // Inline minimal duplicate of listSnippetsForPlatform since snippets.js
1781
1779
  // doesn't export it. Keep this in sync with snippets.js.
1782
1780
  const LIB_DIR = path.join(PLATFORM_LIB_DIR, "lib");
@@ -16,7 +16,6 @@
16
16
  // dev wiki (citations inline).
17
17
 
18
18
  import { readFile, writeFile } from "node:fs/promises";
19
- import { jsonContent, safeTool } from "../util.js";
20
19
 
21
20
  // ───────────────────────────────────────────────────────────────────────────
22
21
  // Pointer encoding per platform.
@@ -1,5 +1,5 @@
1
1
  import { imageContent, jsonContent, safeTool } from "../util.js";
2
- import { intentZod, resolveIntent } from "../../platforms/common/intent.js";
2
+ import { resolveIntent } from "../../platforms/common/intent.js";
3
3
  import { getDefaultPalette, DEFAULT_PALETTES } from "../../platforms/common/default-palette.js";
4
4
  import { spliceChrCore } from "./splice-chr.js";
5
5
  import { relocateBlockCore, makeStoredBlockCore, findPointerToCore, PLATFORM_REGISTRY } from "./reinject.js";
@@ -65,7 +65,7 @@ export async function gbHeaderCore({ path: inPath, outputPath, cgb, title, cartT
65
65
  };
66
66
  }
67
67
 
68
- export function registerRomIdTools(server, z, sessionKey) {
68
+ export function registerRomIdTools(server, z, _sessionKey) {
69
69
  // identifyRom folded into `cart`; patchFile/patchRom/spliceCHR/relocate/etc.
70
70
  // folded into the `romPatch` tool (router below).
71
71
  const PLATFORMS = Object.keys(PLATFORM_REGISTRY);
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  import path from "node:path";
14
14
  import { readdir, readFile, stat, mkdir, writeFile } from "node:fs/promises";
15
15
 
16
- import { jsonContent, safeTool, textContent, writeOutput } from "../util.js";
16
+ import { jsonContent, textContent, writeOutput } from "../util.js";
17
17
 
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = path.dirname(__filename);
@@ -87,7 +87,7 @@ async function listSnippetsForPlatform(platform) {
87
87
  return out;
88
88
  }
89
89
 
90
- export function registerSnippetTools(server, z) {
90
+ export function registerSnippetTools(_server, _z) {
91
91
  // ── Shared implementations for the three snippet modes ──────────
92
92
  async function snippetsList(platform, language) {
93
93
  const all = await listSnippetsForPlatform(platform);
@@ -15,7 +15,6 @@ import { readFile, writeFile } from "node:fs/promises";
15
15
  import { PNG } from "pngjs";
16
16
  import { jsonContent, safeTool } from "../util.js";
17
17
  import { intentZod, resolveIntent, intentError } from "../../platforms/common/intent.js";
18
- import { getDefaultPalette } from "../../platforms/common/default-palette.js";
19
18
  import { convertImageToTilesCore, imageToTilemapCore } from "./platform-tools.js";
20
19
  import { validateGenesisTilesCore } from "./metasprite-tools.js";
21
20
 
@@ -542,7 +541,7 @@ async function crossPlatformSpriteImportImpl(args) {
542
541
 
543
542
  // ── Register all three tools ─────────────────────────────────────────
544
543
 
545
- export function registerSpritePipelineTools(server, z, sessionKey) {
544
+ export function registerSpritePipelineTools(server, z, _sessionKey) {
546
545
  server.tool(
547
546
  "encodeArt",
548
547
  "Encode a PNG into a platform's native art format, one tool keyed by `stage` — the PNG→tiles pipeline. " +
@@ -8,7 +8,7 @@
8
8
  // always reports `source: "file" | "emulator"` so the caller knows which.
9
9
 
10
10
  import { readFile } from "node:fs/promises";
11
- import { getHostOrNull, getHost } from "../state.js";
11
+ import { getHost } from "../state.js";
12
12
  import { jsonContent, imageContent, textContent, safeTool } from "../util.js";
13
13
  import { inspectPatternTilesCore } from "./platform-tools.js";
14
14
  import { extractSpriteSheetCore } from "./rom-id.js";