romdevtools 0.13.0 → 0.15.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 (124) hide show
  1. package/AGENTS.md +21 -14
  2. package/CHANGELOG.md +125 -1
  3. package/README.md +13 -8
  4. package/examples/atari2600/main.asm +1 -1
  5. package/examples/atari2600/templates/default.asm +1 -1
  6. package/examples/atari2600/templates/paddle.asm +59 -47
  7. package/examples/atari7800/main.c +1 -1
  8. package/examples/atari7800/templates/default.c +1 -1
  9. package/examples/atari7800/templates/music_demo.c +1 -1
  10. package/examples/c64/main.c +1 -1
  11. package/examples/c64/templates/platformer.c +2 -2
  12. package/examples/c64/templates/puzzle.c +1 -1
  13. package/examples/c64/templates/racing.c +3 -3
  14. package/examples/c64/templates/shmup.c +6 -5
  15. package/examples/c64/templates/sports.c +4 -4
  16. package/examples/gb/main.asm +1 -1
  17. package/examples/gb/main.c +1 -1
  18. package/examples/gb/templates/puzzle.c +1 -1
  19. package/examples/gb/templates/racing.c +1 -1
  20. package/examples/gb/templates/shmup.c +1 -1
  21. package/examples/gba/templates/gba_hello.c +1 -1
  22. package/examples/gba/templates/maxmod_demo.c +1 -1
  23. package/examples/gba/templates/puzzle.c +17 -3
  24. package/examples/gba/templates/racing.c +16 -2
  25. package/examples/gba/templates/shmup.c +23 -4
  26. package/examples/gba/templates/tonc_hello.c +6 -4
  27. package/examples/gbc/main.asm +1 -1
  28. package/examples/gbc/templates/puzzle.c +1 -1
  29. package/examples/gbc/templates/racing.c +1 -1
  30. package/examples/gbc/templates/shmup.c +1 -1
  31. package/examples/genesis/main.s +1 -1
  32. package/examples/genesis/templates/puzzle.c +1 -1
  33. package/examples/genesis/templates/racing.c +45 -1
  34. package/examples/genesis/templates/shmup.c +12 -3
  35. package/examples/genesis/templates/shmup_2p.c +2 -2
  36. package/examples/genesis/templates/sports.c +39 -0
  37. package/examples/gg/templates/hello_sprite.c +38 -23
  38. package/examples/gg/templates/music_demo.c +11 -8
  39. package/examples/gg/templates/platformer.c +37 -15
  40. package/examples/gg/templates/racing.c +25 -12
  41. package/examples/gg/templates/shmup.c +12 -6
  42. package/examples/gg/templates/sports.c +30 -16
  43. package/examples/gg/templates/tile_engine.c +24 -10
  44. package/examples/lynx/templates/platformer.c +7 -1
  45. package/examples/lynx/templates/puzzle.c +8 -2
  46. package/examples/lynx/templates/racing.c +7 -1
  47. package/examples/lynx/templates/sports.c +7 -1
  48. package/examples/nes/main.c +2 -2
  49. package/examples/nes/space-shooter/nes_runtime.h +1 -1
  50. package/examples/nes/templates/default.c +4 -1
  51. package/examples/nes/templates/racing.c +50 -1
  52. package/examples/pce/main.c +1 -1
  53. package/examples/sms/templates/hello_sprite.c +1 -1
  54. package/examples/sms/templates/music_demo.c +1 -1
  55. package/examples/sms/templates/puzzle.c +1 -1
  56. package/examples/sms/templates/racing.c +1 -1
  57. package/examples/sms/templates/shmup.c +1 -1
  58. package/examples/sms/templates/shmup_2p.c +2 -2
  59. package/examples/snes/main.asm +1 -1
  60. package/examples/snes/templates/c-hello-data.asm +309 -14
  61. package/examples/snes/templates/c-hello.c +13 -2
  62. package/examples/snes/templates/default.c +1 -1
  63. package/examples/snes/templates/hello_sprite-data.asm +300 -2
  64. package/examples/snes/templates/hello_sprite.c +10 -1
  65. package/examples/snes/templates/music_demo-data.asm +300 -2
  66. package/examples/snes/templates/music_demo.c +10 -1
  67. package/examples/snes/templates/platformer-data.asm +300 -2
  68. package/examples/snes/templates/platformer.c +10 -1
  69. package/examples/snes/templates/puzzle-data.asm +300 -2
  70. package/examples/snes/templates/puzzle.c +11 -1
  71. package/examples/snes/templates/racing-data.asm +300 -2
  72. package/examples/snes/templates/racing.c +40 -4
  73. package/examples/snes/templates/shmup-data.asm +299 -6
  74. package/examples/snes/templates/shmup.c +11 -7
  75. package/examples/snes/templates/sports-data.asm +300 -2
  76. package/examples/snes/templates/sports.c +40 -5
  77. package/package.json +1 -1
  78. package/src/cheats/lookup.js +39 -18
  79. package/src/http/routes.js +58 -33
  80. package/src/http/skill-doc.js +10 -9
  81. package/src/http/swagger.js +1 -1
  82. package/src/http/tool-registry.js +72 -5
  83. package/src/mcp/server.js +6 -5
  84. package/src/mcp/state.js +8 -6
  85. package/src/mcp/tool-manifest.js +7 -7
  86. package/src/mcp/tools/cheats.js +4 -3
  87. package/src/mcp/tools/index.js +18 -2
  88. package/src/mcp/tools/playtest.js +48 -35
  89. package/src/mcp/tools/project.js +39 -73
  90. package/src/mcp/tools/rom-id.js +49 -4
  91. package/src/mcp/tools/tile-inspect.js +1 -1
  92. package/src/mcp/tools/toolchain.js +183 -19
  93. package/src/mcp/tools/trace-vram-source.js +3 -3
  94. package/src/mcp/tools/watch-memory.js +27 -46
  95. package/src/observer/livestream.html +41 -5
  96. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +5 -5
  97. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  98. package/src/platforms/gb/TROUBLESHOOTING.md +1 -1
  99. package/src/platforms/gb/UPSTREAM_SOURCES.md +1 -1
  100. package/src/platforms/gb/lib/c/README.md +2 -2
  101. package/src/platforms/gb/lib/c/SDCC_GOTCHAS.md +1 -1
  102. package/src/platforms/gbc/MENTAL_MODEL.md +3 -3
  103. package/src/platforms/gbc/TROUBLESHOOTING.md +5 -5
  104. package/src/platforms/gbc/UPSTREAM_SOURCES.md +2 -2
  105. package/src/platforms/gbc/lib/c/README.md +2 -2
  106. package/src/platforms/gbc/lib/c/SDCC_GOTCHAS.md +1 -1
  107. package/src/platforms/gg/MENTAL_MODEL.md +14 -13
  108. package/src/platforms/gg/lib/c/vdp_init.c +10 -8
  109. package/src/platforms/msx/MENTAL_MODEL.md +1 -1
  110. package/src/platforms/nes/TROUBLESHOOTING.md +1 -1
  111. package/src/platforms/nes/lib/c/nes_runtime.c +28 -6
  112. package/src/platforms/pce/MENTAL_MODEL.md +1 -1
  113. package/src/platforms/pce/lib/c/pce_hw.h +1 -0
  114. package/src/platforms/pce/lib/c/pce_video.c +26 -0
  115. package/src/platforms/sms/MENTAL_MODEL.md +12 -12
  116. package/src/platforms/sms/lib/c/vdp_init.c +10 -8
  117. package/src/platforms/sms/lib/vdp_init.s +1 -1
  118. package/src/playtest/playtest.js +25 -0
  119. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +1 -1
  120. package/src/toolchains/cc65/presets/nes/chr-ram.cfg +1 -1
  121. package/src/toolchains/cc65/presets/nes/chr-ram.crt0.s +1 -1
  122. package/src/toolchains/genesis-c/README.md +1 -1
  123. package/src/toolchains/sdcc/preflight-lint.js +47 -7
  124. package/src/toolchains/snes-c/snes-c.js +3 -7
@@ -7,6 +7,7 @@ import { writeFile } from "node:fs/promises";
7
7
 
8
8
  import { getHost, getHostOrNull } from "../state.js";
9
9
  import { imageContent, jsonContent, safeTool, textContent } from "../util.js";
10
+ import { log } from "../log.js";
10
11
 
11
12
  // Playtest windows are PER SESSION: the MCP server is multi-session (one server
12
13
  // serves several agents at once), and the same user can have 2-3 different games
@@ -104,14 +105,13 @@ export function isPlaytestRunning(sessionKey) {
104
105
  export function registerPlaytestTools(server, z, sessionKey) {
105
106
  // op:'open' — open (or reuse) the SDL window for this session.
106
107
  async function ptOpen({ scale = 3, title, aspect = "tv" }) {
107
- // No preflight display checks. We just attempt to open the SDL window and
108
- // report whatever SDL says — env-var guessing (DISPLAY/WAYLAND_DISPLAY)
109
- // is Linux-only and wrong on macOS/Windows, where those vars are never
110
- // set even with a full GUI session. SDL's createWindow already knows
111
- // whether it can draw on any platform; the try/catch below surfaces the
112
- // real error.
113
108
  const host = getHost(sessionKey);
114
109
  const loadedMediaPath = host.status?.mediaPath ?? null;
110
+ // No env-var preflight here — the GROUND-TRUTH "is there a real display?"
111
+ // check lives in loadSdl() (it asks SDL which video driver it selected and
112
+ // throws sdlKind:"no-display" if it's offscreen/dummy). That's cross-
113
+ // platform and doesn't false-bark on valid offscreen setups like Xvfb.
114
+ // The try/catch below surfaces it (and the binary errors) uniformly.
115
115
  if (reconcileSession(sessionKey)) {
116
116
  // THIS session already has a window open. We don't open a second one for
117
117
  // the same session — it shares this session's live host — so report the
@@ -153,44 +153,55 @@ export function registerPlaytestTools(server, z, sessionKey) {
153
153
  "stepFrames / pressButton) still works against the live ROM — only " +
154
154
  "the interactive window is affected.";
155
155
 
156
- if (kind === "missing-binary" || kind === "install-failed") {
156
+ // A failed window-open is a REAL FAILURE — THROW it, don't return a soft
157
+ // {opened:false} object. Returning success-shaped JSON made the failure
158
+ // invisible on the REST/skill surface (HTTP 200 = "it worked"), so an
159
+ // agent driving the routes would report "window's up!" while no window
160
+ // exists. Thrown → safeTool tags isError → runTool maps it to HTTP 400
161
+ // (REST) and a tool error (MCP). We also log to the server console so a
162
+ // human watching the terminal sees it even if the agent buries the error.
163
+ let reason, message;
164
+ if (kind === "no-display") {
165
+ // GROUND TRUTH: SDL came up on the offscreen/dummy driver — there is no
166
+ // physical screen to show the window on (it would render + play audio
167
+ // but be invisible). loadSdl()'s message already says exactly this + the
168
+ // fix; pass it straight through.
169
+ reason = "no-display";
170
+ message = (e?.message ?? String(e)) + headlessNote;
171
+ } else if (kind === "missing-binary" || kind === "install-failed") {
157
172
  // Native-addon problem, NOT a display problem.
158
173
  const fix = e?.fixCmd
159
174
  ? `Run: ${e.fixCmd} (then restart the server). `
160
175
  : "Reinstall @kmamal/sdl so its prebuilt binary is fetched. ";
161
- return jsonContent({
162
- opened: false,
163
- reason: "sdl-binary-missing",
164
- platform: process.platform,
165
- message:
166
- "The playtest window couldn't open because the @kmamal/sdl native " +
167
- "binary isn't installed: " + (e?.message ?? String(e)) + ". " +
168
- (kind === "install-failed"
169
- ? "An automatic install was attempted but failed (often a network/proxy block on the GitHub release download). "
170
- : "(This is common under `npx romdevtools` — npm skips @kmamal/sdl's install script that fetches the binary; the server tried to self-heal but the binary is still absent.) ") +
171
- fix + "This is a one-time native-addon fix, NOT a display/desktop " +
172
- "issue." + headlessNote,
173
- fixCommand: e?.fixCmd ?? null,
174
- loadedMediaPath,
175
- });
176
- }
177
-
178
- // A genuine SDL init / display failure (e.g. no video device, no
179
- // desktop session). NOW the desktop-session advice is the right call.
180
- return jsonContent({
181
- opened: false,
182
- reason: "sdl-error",
183
- platform: process.platform,
184
- message:
176
+ reason = "sdl-binary-missing";
177
+ message =
178
+ "The playtest window couldn't open because the @kmamal/sdl native " +
179
+ "binary isn't installed: " + (e?.message ?? String(e)) + ". " +
180
+ (kind === "install-failed"
181
+ ? "An automatic install was attempted but failed (often a network/proxy block on the GitHub release download). "
182
+ : "(This is common under `npx romdevtools` — npm skips @kmamal/sdl's install script that fetches the binary; the server tried to self-heal but the binary is still absent.) ") +
183
+ fix + "This is a one-time native-addon fix, NOT a display/desktop " +
184
+ "issue." + headlessNote;
185
+ } else {
186
+ // A genuine SDL init / display failure (no video device / no desktop
187
+ // session). The desktop-session advice is the right call here.
188
+ reason = "sdl-error";
189
+ message =
185
190
  "Couldn't open the SDL playtest window: " + (e?.message ?? String(e)) +
186
191
  ". SDL initialized but couldn't get a display. This usually means the " +
187
192
  "server has no access to a logged-in desktop session — e.g. it was " +
188
193
  "spawned as an MCP subprocess by your agent host, or runs over plain " +
189
194
  "SSH/headless. The reliable fix: run the server yourself in a terminal " +
190
195
  "inside your desktop session, then connect your agent to it." +
191
- headlessNote + " You can also open the built ROM in any standalone emulator.",
192
- loadedMediaPath,
193
- });
196
+ headlessNote + " You can also open the built ROM in any standalone emulator.";
197
+ }
198
+ // Server-console breadcrumb (stderr) so a human at the terminal sees the
199
+ // failure regardless of whether the agent relays the tool error.
200
+ log.error(`playtest: window failed to open (${reason}) — ${e?.fixCmd ? "fix: " + e.fixCmd : message.slice(0, 120)}`);
201
+ const err = new Error(message);
202
+ err.reason = reason;
203
+ if (e?.fixCmd) err.fixCommand = e.fixCmd;
204
+ throw err;
194
205
  }
195
206
  // Detach so process doesn't hang on the closed promise. Only clear THIS
196
207
  // session's slot, and only if it still points at this same session (a
@@ -284,7 +295,9 @@ export function registerPlaytestTools(server, z, sessionKey) {
284
295
  });
285
296
  }
286
297
  if (!inline && !outPath) {
287
- return jsonContent({ ok: false, error: "pass `path` (where to write the PNG) or `inline:true`." });
298
+ // Usage error throw so REST returns 400 (not a 200 with ok:false the
299
+ // caller might ignore).
300
+ throw new Error("playtest framebuffer: pass `path` (where to write the PNG) or `inline:true`.");
288
301
  }
289
302
  const frame = sessions.get(sessionKey).captureFrame();
290
303
  if (!frame) {
@@ -3,13 +3,12 @@
3
3
  // Policy (2026-05-25): no auto-injection at build time. createProject copies
4
4
  // every file the template depends on (runtime, headers, crt0, linker .cfg)
5
5
  // into the project directory. The project is then self-contained — any
6
- // `buildSource` / `runSource` call points at the project's own files via
6
+ // `build({output:'run'})` call points at the project's own files via
7
7
  // sources/sourcesPaths/includePaths/crt0/linkerConfig args. If you take
8
8
  // the project elsewhere and rebuild with cc65/sdcc directly, every byte
9
9
  // that compiles is in the directory.
10
10
 
11
11
  import { readFile, writeFile } from "node:fs/promises";
12
- import { patchGbHeader } from "../../platforms/gb/lib/c/patch-header.js";
13
12
  import { jsonContent, safeTool } from "../util.js";
14
13
  import { starterSnippetsCore, copyStarterSnippetsCore } from "./snippets.js";
15
14
 
@@ -320,7 +319,7 @@ TEMPLATES.gbc = {
320
319
  default: {
321
320
  main: "templates/default.c", runtime: GBC_RUNTIME,
322
321
  lang: GBC_LANG, ext: ".gbc",
323
- describe: "Minimal GBC starter. Same shape as the GB default but ROM extension .gbc — patchGbHeader sets $0143=$80 so gambatte boots in CGB mode.",
322
+ describe: "Minimal GBC starter. Same shape as the GB default but ROM extension .gbc — the GB-header patch sets $0143=$80 so gambatte boots in CGB mode.",
324
323
  },
325
324
  hello_sprite: {
326
325
  main: "templates/hello_sprite.c", runtime: GBC_RUNTIME,
@@ -1087,7 +1086,7 @@ TEMPLATES.gba = {
1087
1086
  runtimeDirs: GBA_LIBTONC_RUNTIME_DIRS,
1088
1087
  lang: GBA_TONC_LANG,
1089
1088
  ext: ".gba",
1090
- describe: "Idiomatic Tonc-tutorial GBA C starter. #include <tonc.h>, TTE (Tonc Text Engine) draws 'Hello, Tonc!' on BG0 in MODE_0. Matches what every published GBA C tutorial at gbadev.net teaches. libtonc is compiled from its vendored source by the build (a fast prebuilt seed by default; pass rebuildSdk:true if you edit the SDK source) — the project gets the headers + gba_crt0 + linker script. Build with buildSource({platform:'gba', language:'c'}) — defaults to runtime:'libtonc'.",
1089
+ describe: "Idiomatic Tonc-tutorial GBA C starter. #include <tonc.h>, TTE (Tonc Text Engine) draws 'Hello, Tonc!' on BG0 in MODE_0. Matches what every published GBA C tutorial at gbadev.net teaches. libtonc is compiled from its vendored source by the build (a fast prebuilt seed by default; pass rebuildSdk:true if you edit the SDK source) — the project gets the headers + gba_crt0 + linker script. Build with build({output:'run', platform:'gba', language:'c'}) — defaults to runtime:'libtonc'.",
1091
1090
  },
1092
1091
  tonc_hello_sprite: {
1093
1092
  main: "templates/tonc_hello_sprite.c",
@@ -1145,10 +1144,10 @@ TEMPLATES.gba = {
1145
1144
  runtimeDirs: GBA_LIBGBA_RUNTIME_DIRS,
1146
1145
  lang: GBA_LIBGBA_LANG,
1147
1146
  ext: ".gba",
1148
- describe: "Alternate GBA C starter using devkitPro's libgba SDK. MODE_3 framebuffer + red pixel. Pass runtime:'libgba' to buildSource — or just use the Tonc path (gba_hello_tonc) which is better aligned with what published tutorials teach.",
1147
+ describe: "Alternate GBA C starter using devkitPro's libgba SDK. MODE_3 framebuffer + red pixel. Pass runtime:'libgba' to build({output:'run'}) — or just use the Tonc path (gba_hello_tonc) which is better aligned with what published tutorials teach.",
1149
1148
  },
1150
1149
  // R34: maxmod music demo. Ships a hand-authored CC0 chiptune.xm +
1151
- // its pre-built soundbank.bin. buildSource must be called with
1150
+ // its pre-built soundbank.bin. build({output:'run'}) must be called with
1152
1151
  // `maxmod: true` AND binaryIncludes:{ "soundbank.bin": <bytes> } —
1153
1152
  // the buildGbaC layer auto-emits a `.incbin "soundbank.bin"` asm
1154
1153
  // stub exposing the soundbank under the global symbol soundbank_bin.
@@ -1174,7 +1173,7 @@ TEMPLATES.gba = {
1174
1173
  ext: ".gba",
1175
1174
  maxmod: true,
1176
1175
  binaryIncludes: ["soundbank.bin"],
1177
- describe: "Maxmod music demo (Tonc + libmm). Plays a CC0 chiptune.xm soundbank via mmInitDefault + mmStart + mmFrame, with START toggling pause. Pass `maxmod:true` AND `binaryIncludes:{\"soundbank.bin\": <bytes>}` to buildSource. The .xm source + generator script + pre-built soundbank.bin all ship in the project — edit and re-run mmutil to swap the tune.",
1176
+ describe: "Maxmod music demo (Tonc + libmm). Plays a CC0 chiptune.xm soundbank via mmInitDefault + mmStart + mmFrame, with START toggling pause. Pass `maxmod:true` AND `binaryIncludes:{\"soundbank.bin\": <bytes>}` to build({output:'run'}). The .xm source + generator script + pre-built soundbank.bin all ship in the project — edit and re-run mmutil to swap the tune.",
1178
1177
  },
1179
1178
  };
1180
1179
 
@@ -1559,11 +1558,11 @@ Compiles **C89**, not C99/C11. Stick to:
1559
1558
  const incLines = runtimeHeaders.length > 0
1560
1559
  ? runtimeHeaders.map((h) => ` "${h.dst}": "${h.dst}",`).join("\n")
1561
1560
  : "";
1562
- buildBlock = "```js\nrunSource({\n platform: \"" + platform + "\",\n sourcesPaths: {\n" + srcLines + "\n },\n" + (incLines ? " includePaths: {\n" + incLines + "\n },\n" : "") + " linkerConfig: /* contents of " + tmpl.linkerConfig.dst + " */,\n frames: 60,\n})\n```";
1561
+ buildBlock = "```js\nbuild({\n output: \"run\",\n platform: \"" + platform + "\",\n sourcesPaths: {\n" + srcLines + "\n },\n" + (incLines ? " includePaths: {\n" + incLines + "\n },\n" : "") + " linkerConfig: /* contents of " + tmpl.linkerConfig.dst + " */,\n frames: 240,\n})\n```";
1563
1562
  } else if (isSdccSm83) {
1564
- // GB / GBC (SDCC sm83). runSource BUILDS + RUNS + SCREENSHOTS in one
1565
- // call AND auto-fixes the cartridge header (Nintendo logo, header +
1566
- // global checksums, CGB flag on .gbc) — no manual patchGbHeader step.
1563
+ // GB / GBC (SDCC sm83). build({output:'run'}) BUILDS + RUNS + SCREENSHOTS
1564
+ // in one call AND auto-fixes the cartridge header (Nintendo logo, header +
1565
+ // global checksums, CGB flag on .gbc) — no manual header-patch step.
1567
1566
  // Derive sources/includes from the template's runtime list so extra
1568
1567
  // .c files (e.g. music_demo's hUGEDriver) are listed too.
1569
1568
  const runtimeCs = (tmpl?.runtime ?? []).filter((r) => /\.c$/i.test(r.dst));
@@ -1574,7 +1573,7 @@ Compiles **C89**, not C99/C11. Stick to:
1574
1573
  ? runtimeHeaders.map((h) => ` "${h.dst}": "${h.dst}",`).join("\n")
1575
1574
  : "";
1576
1575
  buildBlock =
1577
- "```js\nrunSource({\n" +
1576
+ "```js\nbuild({\n output: \"run\",\n" +
1578
1577
  " platform: \"" + platform + "\",\n" +
1579
1578
  " sourcesPaths: {\n" + srcLines + "\n },\n" +
1580
1579
  (incLines ? " includePaths: {\n" + incLines + "\n },\n" : "") +
@@ -1582,20 +1581,20 @@ Compiles **C89**, not C99/C11. Stick to:
1582
1581
  " codeLoc: 0x150,\n" +
1583
1582
  " frames: 60,\n" +
1584
1583
  "})\n```\n\n" +
1585
- "`runSource` auto-fixes the GB/GBC cartridge header (logo, checksums, " +
1586
- "CGB flag) — you do **not** call `patchGbHeader` for a freshly built " +
1587
- "ROM. Use `patchGbHeader` only to fix up an existing/external ROM on " +
1588
- "disk or to override header fields (title, cart type, ROM/RAM size).";
1584
+ "`build({output:\"run\"})` auto-fixes the GB/GBC cartridge header (logo, checksums, " +
1585
+ "CGB flag) — you do **not** call a header patch for a freshly built " +
1586
+ "ROM. Use `romPatch({op:'gbHeader'})` only to fix up an existing/external " +
1587
+ "ROM on disk or to override header fields (title, cart type, ROM/RAM size).";
1589
1588
  } else if (isSdccZ80) {
1590
1589
  const inc = runtimeHeaders.length > 0
1591
1590
  ? `\n includePaths: { ${runtimeHeaders.map((h) => `"${h.dst}": "${h.dst}"`).join(", ")} },`
1592
1591
  : "";
1593
- buildBlock = "```js\nrunSource({\n platform: \"" + platform + "\",\n sourcePath: \"" + mainFilename + "\"," + inc + "\n frames: 60,\n})\n```";
1592
+ buildBlock = "```js\nbuild({\n output: \"run\",\n platform: \"" + platform + "\",\n sourcePath: \"" + mainFilename + "\"," + inc + "\n frames: 240,\n})\n```";
1594
1593
  } else if (platform === "c64") {
1595
1594
  const inc = runtimeHeaders.length > 0
1596
1595
  ? `\n includePaths: { ${runtimeHeaders.map((h) => `"${h.dst}": "${h.dst}"`).join(", ")} },`
1597
1596
  : "";
1598
- buildBlock = "```js\nrunSource({\n platform: \"c64\",\n sourcePath: \"" + mainFilename + "\"," + inc + "\n frames: 60,\n})\n```";
1597
+ buildBlock = "```js\nbuild({\n output: \"run\",\n platform: \"c64\",\n sourcePath: \"" + mainFilename + "\"," + inc + "\n frames: 240,\n})\n```";
1599
1598
  } else if (platform === "snes" && /\.c$/i.test(mainFilename)) {
1600
1599
  // R19b: SNES C-mode template (PVSnesLib runtime auto-linked). Multi-file
1601
1600
  // build with sibling .asm providing data symbols (tilfont/palfont).
@@ -1632,7 +1631,7 @@ Compiles **C89**, not C99/C11. Stick to:
1632
1631
  .join("\n");
1633
1632
 
1634
1633
  buildBlock =
1635
- "```js\nrunSource({\n" +
1634
+ "```js\nbuild({\n output: \"run\",\n" +
1636
1635
  " platform: \"snes\",\n" +
1637
1636
  " language: \"c\",\n" +
1638
1637
  " sourcesPaths: {\n" + sourceLines + "\n },\n" +
@@ -1673,12 +1672,12 @@ Compiles **C89**, not C99/C11. Stick to:
1673
1672
  const incLine = runtimeHs.length > 0
1674
1673
  ? `\n includePaths: {\n${runtimeHs.map((r) => ` "${r.dst}": "${r.dst}",`).join("\n")}\n },`
1675
1674
  : "";
1676
- buildBlock = "```js\nrunSource({\n platform: \"" + platform + "\",\n language: \"c\",\n sourcesPaths: {\n" + srcLines + "\n }," + incLine + "\n frames: 60,\n})\n```";
1675
+ buildBlock = "```js\nbuild({\n output: \"run\",\n platform: \"" + platform + "\",\n language: \"c\",\n sourcesPaths: {\n" + srcLines + "\n }," + incLine + "\n frames: 240,\n})\n```";
1677
1676
  } else {
1678
1677
  const inc = runtimeAsmIncludes.length > 0
1679
1678
  ? `\n includePaths: { ${runtimeAsmIncludes.map((r) => `"${r.dst}": "${r.dst}"`).join(", ")} },`
1680
1679
  : "";
1681
- buildBlock = "```js\nrunSource({\n platform: \"" + platform + "\",\n sourcePath: \"" + mainFilename + "\"," + inc + "\n frames: 60,\n})\n```";
1680
+ buildBlock = "```js\nbuild({\n output: \"run\",\n platform: \"" + platform + "\",\n sourcePath: \"" + mainFilename + "\"," + inc + "\n frames: 240,\n})\n```";
1682
1681
  }
1683
1682
  } else if (platform === "gba") {
1684
1683
  // GBA libtonc / libgba runtimes ship gba_sfx.{h,c} as a tiny DMG-APU
@@ -1693,12 +1692,12 @@ Compiles **C89**, not C99/C11. Stick to:
1693
1692
  const incLine = runtimeHs.length > 0
1694
1693
  ? `\n includePaths: {\n${runtimeHs.map((r) => ` "${r.dst}": "${r.dst}",`).join("\n")}\n },`
1695
1694
  : "";
1696
- buildBlock = "```js\nrunSource({\n platform: \"gba\",\n language: \"c\",\n sourcesPaths: {\n" + srcLines + "\n }," + incLine + "\n frames: 60,\n})\n```";
1695
+ buildBlock = "```js\nbuild({\n output: \"run\",\n platform: \"gba\",\n language: \"c\",\n sourcesPaths: {\n" + srcLines + "\n }," + incLine + "\n frames: 240,\n})\n```";
1697
1696
  } else {
1698
- buildBlock = "```js\nrunSource({ platform: \"gba\", language: \"c\", sourcePath: \"" + mainFilename + "\", frames: 60 })\n```";
1697
+ buildBlock = "```js\nbuild({ output: \"run\", platform: \"gba\", language: \"c\", sourcePath: \"" + mainFilename + "\", frames: 240 })\n```";
1699
1698
  }
1700
1699
  } else {
1701
- buildBlock = "```js\nrunSource({ platform: \"" + platform + "\", sourcePath: \"" + mainFilename + "\", frames: 60 })\n```";
1700
+ buildBlock = "```js\nbuild({ output: \"run\", platform: \"" + platform + "\", sourcePath: \"" + mainFilename + "\", frames: 240 })\n```";
1702
1701
  }
1703
1702
 
1704
1703
  let filesSection = `## Files\n\n- \`${mainFilename}\` — your game's entry point.\n`;
@@ -1715,7 +1714,7 @@ Compiles **C89**, not C99/C11. Stick to:
1715
1714
  }
1716
1715
  filesSection += `\nEvery byte that compiles into your ROM is in this directory. If you move the repo somewhere else, you don't need to install anything from romdev to rebuild it — the compiler binaries are the only external dependency.\n\n`;
1717
1716
 
1718
- const readme = `# ${title ?? name}\n\nA ${lang} project for ${platform}, scaffolded by romdev.\n\n${tmpl?.describe ? tmpl.describe + "\n\n" : ""}${filesSection}${c89Note}## Build + run with romdev\n\n${buildBlock}\n\n## Iterating\n\n- Edit \`${mainFilename}\` (or any of the runtime / crt0 / cfg files — they're yours).\n- Call \`runSource\` to see your changes. It builds + loads + runs + screenshots in one round trip.\n- Inspect at byte level: \`readMemory\`, \`inspectSprites\`, \`inspectPalette\`, \`inspectBackgroundMap({render:true})\`.\n- Open a playtest window for human eyes: \`loadCategory({category:"show"}); playtest({});\` — returns immediately, the window follows your rebuilds, and the emulator stays live for every other tool.\n`;
1717
+ const readme = `# ${title ?? name}\n\nA ${lang} project for ${platform}, scaffolded by romdev.\n\n${tmpl?.describe ? tmpl.describe + "\n\n" : ""}${filesSection}${c89Note}## Build + run with romdev\n\n${buildBlock}\n\n## Iterating\n\n- Edit \`${mainFilename}\` (or any of the runtime / crt0 / cfg files — they're yours).\n- Call \`build({output:"run", ...})\` to see your changes. It builds + loads + runs + screenshots in one round trip.\n- Inspect at byte level: \`memory({op:"read"})\`, \`sprites({op:"inspect"})\`, \`palette({source:"live"})\`, \`background({view:"rendered"})\`.\n- Open a playtest window for human eyes: \`playtest({op:"open"})\` — returns immediately, the window follows your rebuilds, and the emulator stays live for every other tool.\n`;
1719
1718
  await fs.writeFile(path.join(projPath, "README.md"), readme, "utf-8");
1720
1719
  writtenFiles.push("README.md");
1721
1720
 
@@ -1784,7 +1783,7 @@ Compiles **C89**, not C99/C11. Stick to:
1784
1783
  snippetsCopied: withSnippets ? snippetFiles : null,
1785
1784
  sourceFile: path.join(projPath, mainFilename),
1786
1785
  toolchain: lang,
1787
- nextStep: `Edit ${path.join(projPath, mainFilename)} and call runSource with sourcesPaths/includePaths pointing at the project's files. Everything you need is in the directory — nothing is hidden.`,
1786
+ nextStep: `Edit ${path.join(projPath, mainFilename)} and call build({output:"run", ...}) with sourcesPaths/includePaths pointing at the project's files (see the README's "Build + run" block for the exact call). Everything you need is in the directory — nothing is hidden.`,
1788
1787
  };
1789
1788
  }
1790
1789
 
@@ -1811,10 +1810,21 @@ async function createGameCore({ platform, genre, name, path: projPath, title, ov
1811
1810
  ? CANONICAL_GENRES.filter((g) => platformTemplates[g])
1812
1811
  : [];
1813
1812
  if (availableGenres.length === 0) {
1813
+ // Point at the real, working project templates each genre-less
1814
+ // platform actually ships, so the agent has a concrete next step
1815
+ // instead of a bare "default".
1816
+ const PROJECT_TEMPLATE_HINTS = {
1817
+ msx: "default, sprite_move, music_sfx, catch_game",
1818
+ pce: "default, sprite_move, music_sfx, catch_game",
1819
+ atari2600: "default, single_screen, paddle, mini_invaders, music_demo",
1820
+ };
1821
+ const hint = PROJECT_TEMPLATE_HINTS[platform];
1814
1822
  throw new Error(
1815
1823
  `createGame: no genre scaffolds for platform '${platform}' yet. ` +
1816
1824
  `Supported platforms: ${genrePlatforms.join(", ") || "(none)"}. ` +
1817
- `For other platforms, use createProject({platform, template:"default"}) and build up from there.`
1825
+ (hint
1826
+ ? `For ${platform}, use createProject({platform:"${platform}", template:"..."}) with one of: ${hint}.`
1827
+ : `For other platforms, use createProject({platform, template:"default"}) and build up from there.`)
1818
1828
  );
1819
1829
  }
1820
1830
  if (!availableGenres.includes(genre)) {
@@ -1890,50 +1900,6 @@ export function registerProjectTools(server, z) {
1890
1900
  }),
1891
1901
  );
1892
1902
 
1893
- server.tool(
1894
- "patchGbHeader",
1895
- "Use this to write a complete, valid GB/GBC cartridge header into a ROM: Nintendo boot logo, EVERY " +
1896
- "header byte ($0134-$014C — title, CGB flag, cart type, ROM/RAM size, etc.) with ROM-only defaults, " +
1897
- "plus the header + global checksums. SDCC-path equivalent of `rgbfix -v -p 0`. Fills ALL bytes " +
1898
- "deliberately: leaving the CGB flag as the linker's $FF pad makes gambatte enter CGB mode and ignore " +
1899
- "DMG palette writes → white screen. Also shipped as `patch-header.js` in every GB/GBC project for use " +
1900
- "outside MCP.",
1901
- {
1902
- path: z.string().describe("Absolute path to the .gb / .gbc ROM file. Patched in place unless outputPath is given."),
1903
- outputPath: z.string().optional().describe("If given, write the patched ROM here instead of overwriting."),
1904
- cgb: z.boolean().optional().describe("If true, sets the CGB flag at $0143 to $80 (CGB-aware + DMG-compatible). If omitted, auto-detects from .gbc extension; default for plain .gb is false (DMG-only)."),
1905
- title: z.string().optional().describe("Cartridge title, up to 11 chars at $0134..$013E. Uppercased + zero-padded. Default = zero-fill."),
1906
- cartType: z.number().int().min(0).max(0xFF).optional().describe("Cart-type byte at $0147. Default $00 (ROM-only). Common alternatives: $01=MBC1, $03=MBC1+RAM+BAT, $11=MBC3, $13=MBC3+RAM+BAT, $19=MBC5."),
1907
- romSize: z.number().int().min(0).max(0xFF).optional().describe("ROM-size byte at $0148. Default $00 (32 KB / 2 banks). 1=64KB, 2=128KB, 3=256KB, 4=512KB, 5=1MB, 6=2MB, 7=4MB."),
1908
- ramSize: z.number().int().min(0).max(0xFF).optional().describe("RAM-size byte at $0149. Default $00 (none). $02=8KB, $03=32KB. Only meaningful with battery-backed MBC."),
1909
- destination: z.number().int().min(0).max(0xFF).optional().describe("Destination at $014A. Default $01 (non-Japan). $00 = Japan."),
1910
- },
1911
- safeTool(async ({ path: inPath, outputPath, cgb, title, cartType, romSize, ramSize, destination }) => {
1912
- const rom = new Uint8Array(await readFile(inPath));
1913
- const cgbFlag = cgb ?? (/\.gbc$/i.test(inPath) || (outputPath && /\.gbc$/i.test(outputPath)));
1914
- patchGbHeader(rom, { cgb: cgbFlag, title, cartType, romSize, ramSize, destination });
1915
- const outPath = outputPath ?? inPath;
1916
- await writeFile(outPath, rom);
1917
- return jsonContent({
1918
- path: outPath,
1919
- bytes: rom.length,
1920
- cgb: !!cgbFlag,
1921
- patched: [
1922
- "nintendo_logo@$0104..$0133",
1923
- "title@$0134..$013E",
1924
- `cgb_flag@$0143=${cgbFlag ? "$80" : "$00"}`,
1925
- "licensee@$0144..$0145=$00$00",
1926
- "sgb_flag@$0146=$00",
1927
- `cart_type@$0147=$${(cartType ?? 0).toString(16).padStart(2, "0").toUpperCase()}`,
1928
- `rom_size@$0148=$${(romSize ?? 0).toString(16).padStart(2, "0").toUpperCase()}`,
1929
- `ram_size@$0149=$${(ramSize ?? 0).toString(16).padStart(2, "0").toUpperCase()}`,
1930
- `destination@$014A=$${(destination ?? 1).toString(16).padStart(2, "0").toUpperCase()}`,
1931
- "old_licensee@$014B=$33",
1932
- "rom_version@$014C=$00",
1933
- "header_checksum@$014D",
1934
- "global_checksum@$014E..$014F",
1935
- ],
1936
- });
1937
- }),
1938
- );
1903
+ // patchGbHeader was folded into romPatch({op:'gbHeader'}) (rom-id.js) — it's a
1904
+ // ROM-file patch op, same family as romPatch's other ops, not a scaffold tool.
1939
1905
  }
@@ -30,6 +30,41 @@ export async function patchRomCore({ input, output, writes, allowExpand }) {
30
30
  return await patchRomFile({ input, output, writes, allowExpand });
31
31
  }
32
32
 
33
+ // romPatch({op:'gbHeader'}) — write a complete valid GB/GBC cartridge header
34
+ // (logo + every header byte + header/global checksums) into a ROM file. Folded
35
+ // in from the old standalone patchGbHeader tool. Also shipped as patch-header.js
36
+ // in every GB/GBC project for use outside romdev.
37
+ export async function gbHeaderCore({ path: inPath, outputPath, cgb, title, cartType, romSize, ramSize, destination }) {
38
+ if (!inPath) throw new Error("romPatch({op:'gbHeader'}): `path` (the .gb/.gbc ROM) is required.");
39
+ const { readFile, writeFile } = await import("node:fs/promises");
40
+ const { patchGbHeader } = await import("../../platforms/gb/lib/c/patch-header.js");
41
+ const rom = new Uint8Array(await readFile(inPath));
42
+ const cgbFlag = cgb ?? (/\.gbc$/i.test(inPath) || (outputPath && /\.gbc$/i.test(outputPath)));
43
+ patchGbHeader(rom, { cgb: cgbFlag, title, cartType, romSize, ramSize, destination });
44
+ const outPath = outputPath ?? inPath;
45
+ await writeFile(outPath, rom);
46
+ return {
47
+ path: outPath,
48
+ bytes: rom.length,
49
+ cgb: !!cgbFlag,
50
+ patched: [
51
+ "nintendo_logo@$0104..$0133",
52
+ "title@$0134..$013E",
53
+ `cgb_flag@$0143=${cgbFlag ? "$80" : "$00"}`,
54
+ "licensee@$0144..$0145=$00$00",
55
+ "sgb_flag@$0146=$00",
56
+ `cart_type@$0147=$${(cartType ?? 0).toString(16).padStart(2, "0").toUpperCase()}`,
57
+ `rom_size@$0148=$${(romSize ?? 0).toString(16).padStart(2, "0").toUpperCase()}`,
58
+ `ram_size@$0149=$${(ramSize ?? 0).toString(16).padStart(2, "0").toUpperCase()}`,
59
+ `destination@$014A=$${(destination ?? 1).toString(16).padStart(2, "0").toUpperCase()}`,
60
+ "old_licensee@$014B=$33",
61
+ "rom_version@$014C=$00",
62
+ "header_checksum@$014D",
63
+ "global_checksum@$014E..$014F",
64
+ ],
65
+ };
66
+ }
67
+
33
68
  export function registerRomIdTools(server, z, sessionKey) {
34
69
  // identifyRom folded into `cart`; patchFile/patchRom/spliceCHR/relocate/etc.
35
70
  // folded into the `romPatch` tool (router below).
@@ -48,7 +83,8 @@ export function registerRomIdTools(server, z, sessionKey) {
48
83
  "makeStored → {rawHex|rawBytes, format, interleave?}; " +
49
84
  "findFree → {minLength, fillBytes?, start?, end?, maxRunsReturned?}; " +
50
85
  "findPointer → {romOffset, mapper?, widths?, suppressShadows?, maxHitsReturned?}; " +
51
- "diff → {a, b, maxChangesReturned?}.\n" +
86
+ "diff → {a, b, maxChangesReturned?}; " +
87
+ "gbHeader → {path, outputPath?, cgb?, title?, cartType?, romSize?, ramSize?, destination?}.\n" +
52
88
  "• op:'write' — write N bytes into any binary file at `offset` (the generic splicer: PRG patches, CHR splices, SNES tile/sample injection). `allowExpand` grows the file — default OFF; most hacks must NOT change size or headers/mapper break. `outputPath` else writes in place.\n" +
53
89
  "• op:'writeMany' — apply a LIST of {offset, hex|base64} `writes` from `input` ROM to `output`.\n" +
54
90
  "• op:'spliceCHR' — inject a PNG's tiles into a CHR region.\n" +
@@ -56,10 +92,11 @@ export function registerRomIdTools(server, z, sessionKey) {
56
92
  "• op:'makeStored' — wrap raw bytes so the game's OWN decompressor expands them VERBATIM (edit tiles → makeStored → write, no compressor needed). `format` (raw/lz77-literal/lz2-direct/sega-rle/konami-rle/packbits/kosinski-literal; invalid → returns the platform's list). ALWAYS verify via cpu({op:'call'}) on the game's decompressor.\n" +
57
93
  "• op:'findFree' — find a run of free space to relocate into (`fillBytes` defaults to [0xFF, 0x00]).\n" +
58
94
  "• op:'findPointer' — find every pointer in the ROM that references `romOffset` (platform-correct encoding), the missing piece for redirecting a loader. `mapper` overrides SNES detection. On wide systems (Genesis/GBA) a 32-bit hit's low bytes also match the narrower form one byte over — those tail SHADOWS are suppressed by default (count in `shadowsSuppressed`); pass `suppressShadows:false` for raw, or `widths:[4]` to search only 32-bit forms. On banked 8-bit systems a 16-bit pointer is page-ambiguous — correlate with the bank-set instruction.\n" +
59
- "• op:'diff' — diff two ROMs (`a`, `b`) → the changed byte ranges.",
95
+ "• op:'diff' — diff two ROMs (`a`, `b`) → the changed byte ranges.\n" +
96
+ "• op:'gbHeader' — GAME BOY / GBC ONLY: write a complete, valid GB/GBC cartridge header into a ROM at `path` — Nintendo boot logo, every header byte ($0134-$014C: title, CGB flag, cart type, ROM/RAM size, …) with ROM-only defaults, plus the header + global checksums. The SDCC-path equivalent of `rgbfix -v -p 0`, for fixing up an externally built / hand-assembled GB ROM. (A normal build({output:'rom'/'run'}) already does this — you do NOT call gbHeader on a freshly built ROM.) Leaving the CGB flag as the linker's $FF pad makes gambatte enter CGB mode and white-screen, so this fills it deliberately.",
60
97
  {
61
- op: z.enum(["write", "writeMany", "spliceCHR", "relocate", "makeStored", "findFree", "findPointer", "diff"])
62
- .describe("write=N bytes at an offset; writeMany=a list of writes; spliceCHR=PNG tiles into CHR; relocate=write a block to free space + repoint; makeStored=wrap bytes for the game's decompressor; findFree=find free space; findPointer=find pointers to an offset; diff=diff two ROMs."),
98
+ op: z.enum(["write", "writeMany", "spliceCHR", "relocate", "makeStored", "findFree", "findPointer", "diff", "gbHeader"])
99
+ .describe("write=N bytes at an offset; writeMany=a list of writes; spliceCHR=PNG tiles into CHR; relocate=write a block to free space + repoint; makeStored=wrap bytes for the game's decompressor; findFree=find free space; findPointer=find pointers to an offset; diff=diff two ROMs; gbHeader=write a valid GB/GBC cartridge header + checksums."),
63
100
  path: z.string().optional().describe("op:write/spliceCHR/relocate/findFree/findPointer — absolute path to the ROM/file."),
64
101
  platform: z.enum(PLATFORMS).optional().describe("op:findPointer/relocate/makeStored/spliceCHR/diff — platform (inferred from extension except makeStored, which requires it)."),
65
102
  offset: z.number().int().min(0).optional().describe("op:write — file offset to write at (NOT a CPU address)."),
@@ -112,6 +149,13 @@ export function registerRomIdTools(server, z, sessionKey) {
112
149
  a: z.string().optional().describe("op:diff — path to ROM A."),
113
150
  b: z.string().optional().describe("op:diff — path to ROM B."),
114
151
  maxChangesReturned: z.number().int().min(1).max(2048).default(256).describe("op:diff — cap the change ranges returned."),
152
+ // gbHeader (path + outputPath reuse the spine fields above)
153
+ cgb: z.boolean().optional().describe("op:gbHeader — if true, sets the CGB flag at $0143 to $80 (CGB-aware + DMG-compatible). If omitted, auto-detects from a .gbc extension; default for plain .gb is false (DMG-only)."),
154
+ title: z.string().optional().describe("op:gbHeader — cartridge title, up to 11 chars at $0134..$013E. Uppercased + zero-padded. Default = zero-fill."),
155
+ cartType: z.number().int().min(0).max(0xFF).optional().describe("op:gbHeader — cart-type byte at $0147. Default $00 (ROM-only). Common: $01=MBC1, $03=MBC1+RAM+BAT, $11=MBC3, $13=MBC3+RAM+BAT, $19=MBC5."),
156
+ romSize: z.number().int().min(0).max(0xFF).optional().describe("op:gbHeader — ROM-size byte at $0148. Default $00 (32 KB / 2 banks). 1=64KB, 2=128KB, 3=256KB, 4=512KB, 5=1MB, 6=2MB, 7=4MB."),
157
+ ramSize: z.number().int().min(0).max(0xFF).optional().describe("op:gbHeader — RAM-size byte at $0149. Default $00 (none). $02=8KB, $03=32KB. Only meaningful with battery-backed MBC."),
158
+ destination: z.number().int().min(0).max(0xFF).optional().describe("op:gbHeader — destination at $014A. Default $01 (non-Japan). $00 = Japan."),
115
159
  },
116
160
  safeTool(async (args) => {
117
161
  switch (args.op) {
@@ -135,6 +179,7 @@ export function registerRomIdTools(server, z, sessionKey) {
135
179
  if (!args.a || !args.b) throw new Error("romPatch({op:'diff'}): `a` and `b` (the two ROM paths) are required.");
136
180
  return jsonContent(await diffRomsCore({ ...args, aPath: args.a, bPath: args.b }));
137
181
  }
182
+ case "gbHeader": return jsonContent(await gbHeaderCore(args));
138
183
  default: throw new Error(`romPatch: unknown op '${args.op}'`);
139
184
  }
140
185
  }),
@@ -254,7 +254,7 @@ export function registerTileInspectTools(server, z, sessionKey) {
254
254
  tilePath: z.string().optional().describe("op:preview — path to a tile dump (raw) or iNES ROM (NES auto-locates CHR)."),
255
255
  fromEmulator: z.boolean().optional().describe("op:preview — read tiles from the running emulator's live VRAM (tileStart/tileCount pick the range). Genesis byte-swap handled. Mutually exclusive with tileBytes/tilePath."),
256
256
  tileStart: z.number().int().min(0).optional().describe("op:preview — starting tile index in the source."),
257
- byteOffset: z.number().int().min(0).optional().describe("op:preview — start at a raw BYTE offset instead of a tile index (pass a dmaTrace / disasm-references source directly). WARNS on misalignment. Takes precedence over tileStart."),
257
+ byteOffset: z.number().int().min(0).optional().describe("op:preview — start at a raw BYTE offset instead of a tile index (pass a watch({on:'dma'}) / disasm-references source directly). WARNS on misalignment. Takes precedence over tileStart."),
258
258
  palette: z.array(z.any()).optional().describe("op:preview — explicit palette (NES: 4 master indices; others: RGB triples or indices)."),
259
259
  palettePath: z.string().optional().describe("op:preview — raw palette dump from disk."),
260
260
  // shared output