romdevtools 0.27.0 → 0.29.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 (199) hide show
  1. package/AGENTS.md +56 -44
  2. package/CHANGELOG.md +355 -0
  3. package/README.md +4 -4
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1227 -325
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +909 -257
  8. package/examples/atari2600/templates/shmup.asm +1035 -218
  9. package/examples/atari2600/templates/sports.asm +1143 -229
  10. package/examples/atari7800/templates/hello_sprite.c +8 -4
  11. package/examples/atari7800/templates/platformer.c +991 -152
  12. package/examples/atari7800/templates/puzzle.c +1091 -145
  13. package/examples/atari7800/templates/racing.c +949 -118
  14. package/examples/atari7800/templates/shmup.c +812 -130
  15. package/examples/atari7800/templates/sports.c +820 -181
  16. package/examples/c64/templates/platformer.c +876 -157
  17. package/examples/c64/templates/puzzle.c +881 -143
  18. package/examples/c64/templates/racing.c +873 -88
  19. package/examples/c64/templates/shmup.c +762 -154
  20. package/examples/c64/templates/sports.c +755 -95
  21. package/examples/gb/templates/platformer.c +841 -175
  22. package/examples/gb/templates/puzzle.c +1094 -176
  23. package/examples/gb/templates/racing.c +761 -169
  24. package/examples/gb/templates/shmup.c +679 -169
  25. package/examples/gb/templates/sports.c +790 -153
  26. package/examples/gba/templates/platformer.c +624 -169
  27. package/examples/gba/templates/puzzle.c +535 -207
  28. package/examples/gba/templates/racing.c +513 -196
  29. package/examples/gba/templates/shmup.c +565 -168
  30. package/examples/gba/templates/sports.c +454 -162
  31. package/examples/gbc/templates/platformer.c +944 -176
  32. package/examples/gbc/templates/puzzle.c +1131 -177
  33. package/examples/gbc/templates/racing.c +891 -175
  34. package/examples/gbc/templates/shmup.c +827 -179
  35. package/examples/gbc/templates/sports.c +870 -156
  36. package/examples/genesis/templates/platformer.c +747 -129
  37. package/examples/genesis/templates/puzzle.c +702 -208
  38. package/examples/genesis/templates/racing.c +728 -193
  39. package/examples/genesis/templates/shmup.c +535 -142
  40. package/examples/genesis/templates/shmup_2p.c +13 -1
  41. package/examples/genesis/templates/sports.c +495 -158
  42. package/examples/gg/templates/platformer.c +883 -214
  43. package/examples/gg/templates/puzzle.c +906 -181
  44. package/examples/gg/templates/racing.c +919 -160
  45. package/examples/gg/templates/shmup.c +716 -177
  46. package/examples/gg/templates/sports.c +735 -128
  47. package/examples/lynx/templates/platformer.c +604 -50
  48. package/examples/lynx/templates/puzzle.c +533 -130
  49. package/examples/lynx/templates/racing.c +538 -102
  50. package/examples/lynx/templates/shmup.c +461 -122
  51. package/examples/lynx/templates/sports.c +496 -69
  52. package/examples/msx/platformer/main.c +648 -159
  53. package/examples/msx/puzzle/main.c +750 -185
  54. package/examples/msx/racing/main.c +669 -177
  55. package/examples/msx/shmup/main.c +460 -177
  56. package/examples/msx/sports/main.c +591 -124
  57. package/examples/nes/templates/platformer.c +586 -160
  58. package/examples/nes/templates/puzzle.c +603 -222
  59. package/examples/nes/templates/racing.c +505 -197
  60. package/examples/nes/templates/shmup.c +339 -144
  61. package/examples/nes/templates/sports.c +341 -182
  62. package/examples/pce/platformer/main.c +875 -204
  63. package/examples/pce/puzzle/main.c +797 -216
  64. package/examples/pce/racing/main.c +782 -206
  65. package/examples/pce/shmup/main.c +638 -211
  66. package/examples/pce/sports/main.c +585 -167
  67. package/examples/porting-across-platforms/README.md +1 -1
  68. package/examples/sms/templates/platformer.c +765 -176
  69. package/examples/sms/templates/puzzle.c +783 -177
  70. package/examples/sms/templates/racing.c +812 -133
  71. package/examples/sms/templates/shmup.c +601 -148
  72. package/examples/sms/templates/shmup_2p.c +17 -1
  73. package/examples/sms/templates/sports.c +633 -121
  74. package/examples/snes/templates/music_demo.c +7 -0
  75. package/examples/snes/templates/platformer-data.asm +123 -24
  76. package/examples/snes/templates/platformer-hdr.asm +57 -0
  77. package/examples/snes/templates/platformer.c +587 -149
  78. package/examples/snes/templates/puzzle-data.asm +116 -21
  79. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  80. package/examples/snes/templates/puzzle.c +632 -185
  81. package/examples/snes/templates/racing-data.asm +390 -32
  82. package/examples/snes/templates/racing-hdr.asm +57 -0
  83. package/examples/snes/templates/racing.c +807 -177
  84. package/examples/snes/templates/shmup-data.asm +87 -29
  85. package/examples/snes/templates/shmup-hdr.asm +57 -0
  86. package/examples/snes/templates/shmup.c +459 -180
  87. package/examples/snes/templates/sports-data.asm +48 -2
  88. package/examples/snes/templates/sports-hdr.asm +57 -0
  89. package/examples/snes/templates/sports.c +414 -156
  90. package/package.json +12 -12
  91. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  92. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  93. package/src/cores/wasm/fceumm_libretro.js +1 -1
  94. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  95. package/src/cores/wasm/gambatte_libretro.js +1 -1
  96. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  97. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  98. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  99. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  100. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  101. package/src/cores/wasm/handy_libretro.js +1 -1
  102. package/src/cores/wasm/handy_libretro.wasm +0 -0
  103. package/src/cores/wasm/mgba_libretro.js +1 -1
  104. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  105. package/src/cores/wasm/prosystem_libretro.js +1 -1
  106. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  107. package/src/cores/wasm/snes9x_libretro.js +1 -1
  108. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  109. package/src/cores/wasm/stella2014_libretro.js +1 -1
  110. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  111. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  112. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  113. package/src/host/LibretroHost.js +304 -11
  114. package/src/http/tool-registry.js +11 -11
  115. package/src/mcp/server.js +6 -0
  116. package/src/mcp/tools/cheats.js +2 -1
  117. package/src/mcp/tools/disasm-rebuild.js +315 -65
  118. package/src/mcp/tools/disasm.js +149 -28
  119. package/src/mcp/tools/find-references.js +216 -51
  120. package/src/mcp/tools/frame.js +14 -6
  121. package/src/mcp/tools/index.js +18 -4
  122. package/src/mcp/tools/input.js +31 -7
  123. package/src/mcp/tools/lifecycle.js +6 -4
  124. package/src/mcp/tools/memory.js +208 -39
  125. package/src/mcp/tools/platform-docs.js +1 -1
  126. package/src/mcp/tools/playtest.js +56 -4
  127. package/src/mcp/tools/preview-tile.js +6 -2
  128. package/src/mcp/tools/project.js +1114 -120
  129. package/src/mcp/tools/rom-id.js +5 -1
  130. package/src/mcp/tools/run-until.js +4 -2
  131. package/src/mcp/tools/snippets.js +6 -6
  132. package/src/mcp/tools/sprite-pipeline.js +14 -2
  133. package/src/mcp/tools/state.js +2 -1
  134. package/src/mcp/tools/tile-inspect.js +8 -1
  135. package/src/mcp/tools/toolchain.js +55 -11
  136. package/src/mcp/tools/watch-memory.js +145 -27
  137. package/src/observer/bus.js +73 -0
  138. package/src/observer/livestream.html +4 -2
  139. package/src/observer/tool-wrap.js +17 -14
  140. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  141. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  142. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  143. package/src/platforms/atari7800/MENTAL_MODEL.md +32 -11
  144. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  145. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  146. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  147. package/src/platforms/gb/MENTAL_MODEL.md +19 -4
  148. package/src/platforms/gb/TROUBLESHOOTING.md +101 -6
  149. package/src/platforms/gb/lib/c/README.md +10 -11
  150. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  151. package/src/platforms/gb/lib/c/patch-header.js +19 -6
  152. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  153. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  154. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  155. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  156. package/src/platforms/gbc/MENTAL_MODEL.md +16 -4
  157. package/src/platforms/gbc/TROUBLESHOOTING.md +24 -3
  158. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  159. package/src/platforms/gbc/lib/c/README.md +10 -11
  160. package/src/platforms/gbc/lib/c/font.h +43 -0
  161. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  162. package/src/platforms/gbc/lib/c/patch-header.js +19 -6
  163. package/src/platforms/genesis/MENTAL_MODEL.md +43 -9
  164. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  165. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  166. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  167. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  168. package/src/platforms/gg/TROUBLESHOOTING.md +14 -18
  169. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  170. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  171. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  172. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  173. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  174. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  175. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  176. package/src/platforms/msx/MENTAL_MODEL.md +11 -5
  177. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  178. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  179. package/src/platforms/msx/lib/c/msx_hw.h +3 -0
  180. package/src/platforms/msx/lib/c/msx_vdp.c +70 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +12 -5
  182. package/src/platforms/nes/lib/c/nes_runtime.c +190 -34
  183. package/src/platforms/nes/lib/c/nes_runtime.h +35 -0
  184. package/src/platforms/pce/MENTAL_MODEL.md +14 -5
  185. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  186. package/src/platforms/pce/lib/c/pce_hw.h +13 -1
  187. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  188. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  189. package/src/platforms/sms/MENTAL_MODEL.md +11 -6
  190. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  191. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  192. package/src/platforms/snes/MENTAL_MODEL.md +7 -2
  193. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  194. package/src/playtest/playtest.js +73 -3
  195. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  197. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  198. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  199. package/src/toolchains/index.js +64 -19
@@ -305,13 +305,17 @@ export async function extractSpriteSheetCore({ platform, path: romPath, offset,
305
305
  const path = await import("node:path");
306
306
  await mkdir(path.dirname(outputPath), { recursive: true });
307
307
  await writeFile(outputPath, png);
308
- return jsonContent({
308
+ // Livestream sideband: show the rendered sheet even though the agent
309
+ // only gets the path.
310
+ const out = jsonContent({
309
311
  path: outputPath,
310
312
  intent: d.intent,
311
313
  bytes: png.length,
312
314
  paletteSource,
313
315
  note,
314
316
  });
317
+ out._observerImages = [{ kind: "image", mimeType: "image/png", base64: png.toString("base64") }];
318
+ return out;
315
319
  }
316
320
  return {
317
321
  content: [
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { getHost } from "../state.js";
12
12
  import { jsonContent, safeTool } from "../util.js";
13
+ import { attachObserverFrame } from "./watch-memory.js";
13
14
 
14
15
  export function registerRunUntilTools(server, z, sessionKey) {
15
16
  const memoryCondition = z.object({
@@ -75,11 +76,12 @@ export function registerRunUntilTools(server, z, sessionKey) {
75
76
  }
76
77
  }
77
78
 
78
- return jsonContent({
79
+ // Livestream: the frame where the condition was met (or where we gave up).
80
+ return attachObserverFrame(jsonContent({
79
81
  conditionMet: met,
80
82
  framesStepped,
81
83
  finalValue,
82
- });
84
+ }), host, met ? "runUntil: condition met" : "runUntil: gave up");
83
85
  }),
84
86
  );
85
87
  }
@@ -97,11 +97,11 @@ export function registerSnippetTools(_server, _z) {
97
97
  platform, languages, snippets: filtered,
98
98
  note: filtered.length === 0
99
99
  ? `No snippets matched for '${platform}'${language ? ` (language=${language})` : ""}.`
100
- : `Fetch one with starterSnippets({ platform, mode:'get', name${language ? ", language" : ""} }), or all with mode:'getAll'.`,
100
+ : `Fetch one with examples({ op:'snippets', platform, mode:'get', snippetName${language ? ", language" : ""} }), or all with mode:'getAll'.`,
101
101
  });
102
102
  }
103
103
  async function snippetsGet(platform, name, language) {
104
- if (!name) throw new Error("starterSnippets mode:'get' requires `name`.");
104
+ if (!name) throw new Error("examples({op:'snippets'}) mode:'get' requires `snippetName`.");
105
105
  if (name.includes("..") || (name.includes("/") && !/^[a-z]+\/[\w.-]+$/i.test(name))) {
106
106
  throw new Error("snippet name must not contain '..' or arbitrary path separators");
107
107
  }
@@ -124,7 +124,7 @@ export function registerSnippetTools(_server, _z) {
124
124
  return textContent(`No snippets available for '${platform}'${language ? ` (language=${language})` : ""}.`);
125
125
  }
126
126
  if (!inline && !outputPath) {
127
- throw new Error("starterSnippets mode:'getAll': pass outputPath (write the joined snippets to disk, returns {path}) or inline:true (return `combined` in the response). Or use copyStarterSnippets to write each snippet as its own file.");
127
+ throw new Error("examples({op:'snippets'}) mode:'getAll': pass outputPath (write the joined snippets to disk, returns {path}) or inline:true (return `combined` in the response). Or use examples({op:'copySnippets'}) to write each snippet as its own file.");
128
128
  }
129
129
  const parts = [];
130
130
  for (const s of filtered) {
@@ -161,7 +161,7 @@ export function registerSnippetTools(_server, _z) {
161
161
  if (filtered.length === 0) {
162
162
  const langPart = language ? ` (language=${language})` : "";
163
163
  const includePart = include ? ` (include=${JSON.stringify(include)})` : "";
164
- throw new Error(`copyStarterSnippets: no snippets matched for platform '${platform}'${langPart}${includePart}.`);
164
+ throw new Error(`examples({op:'copySnippets'}): no snippets matched for platform '${platform}'${langPart}${includePart}.`);
165
165
  }
166
166
  await mkdir(destinationDir, { recursive: true });
167
167
  const written = [];
@@ -201,9 +201,9 @@ export function registerSnippetTools(_server, _z) {
201
201
  };
202
202
  }
203
203
 
204
- // starterSnippets/copyStarterSnippets folded into the `scaffold` tool. The cores
204
+ // starterSnippets/copyStarterSnippets folded into the `examples` tool. The cores
205
205
  // are assigned inside registerSnippetTools (they close over the local helpers);
206
- // scaffold imports these and calls them. registerSnippetTools registers NO tools
206
+ // examples imports these and calls them. registerSnippetTools registers NO tools
207
207
  // now — it just wires the cores.
208
208
  export let starterSnippetsCore = async () => { throw new Error("snippet cores not initialized — registerSnippetTools must run first"); };
209
209
  export let copyStarterSnippetsCore = async () => { throw new Error("snippet cores not initialized — registerSnippetTools must run first"); };
@@ -143,6 +143,7 @@ async function cropSpriteSheetImpl({ path, tileX, tileY, tileW, tileH, tileSize
143
143
  }
144
144
  await writeFile(outputPath, outBuf);
145
145
  return {
146
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: outBuf.toString("base64") }],
146
147
  path: outputPath,
147
148
  intent: d.intent,
148
149
  width: pxW,
@@ -157,6 +158,16 @@ async function cropSpriteSheetImpl({ path, tileX, tileY, tileW, tileH, tileSize
157
158
  };
158
159
  }
159
160
 
161
+ // Lift a plain core result's livestream sideband OUT of the JSON body and
162
+ // onto the MCP result object (it must never serialize into agent-visible text).
163
+ function liftObserverImages(r) {
164
+ const sideband = r._observerImages;
165
+ delete r._observerImages;
166
+ const out = jsonContent(r);
167
+ if (sideband) out._observerImages = sideband;
168
+ return out;
169
+ }
170
+
160
171
  // ── quantizePngForPlatform ──────────────────────────────────────────
161
172
 
162
173
  /**
@@ -247,6 +258,7 @@ async function quantizePngForPlatformImpl({ path, platform, outputPath, intent,
247
258
  const outBuf = writeIndexedPng(png.width, png.height, indices, palette);
248
259
  await writeFile(outputPath, outBuf);
249
260
  return {
261
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: outBuf.toString("base64") }],
250
262
  path: outputPath,
251
263
  intent: d.intent,
252
264
  width: png.width,
@@ -596,12 +608,12 @@ export function registerSpritePipelineTools(server, z, _sessionKey) {
596
608
  case "quantize": {
597
609
  if (!args.platform) throw new Error("encodeArt({stage:'quantize'}): `platform` is required.");
598
610
  if (!args.path || !args.outputPath) throw new Error("encodeArt({stage:'quantize'}): `path` and `outputPath` are required.");
599
- return jsonContent(await quantizePngForPlatformImpl(args));
611
+ return liftObserverImages(await quantizePngForPlatformImpl(args));
600
612
  }
601
613
  case "crop": {
602
614
  if (!args.path || !args.outputPath) throw new Error("encodeArt({stage:'crop'}): `path` and `outputPath` are required.");
603
615
  if (args.tileX == null || args.tileY == null || args.tileW == null || args.tileH == null) throw new Error("encodeArt({stage:'crop'}): `tileX`, `tileY`, `tileW`, `tileH` are required.");
604
- return jsonContent(await cropSpriteSheetImpl(args));
616
+ return liftObserverImages(await cropSpriteSheetImpl(args));
605
617
  }
606
618
  case "tiles": {
607
619
  if (!args.platform) throw new Error("encodeArt({stage:'tiles'}): `platform` is required.");
@@ -1,6 +1,7 @@
1
1
  import { mkdir, writeFile, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { getHost } from "../state.js";
4
+ import { attachObserverFrame } from "./watch-memory.js";
4
5
  import { jsonContent, safeTool } from "../util.js";
5
6
 
6
7
  // Resolve a state-file `path`. An ABSOLUTE path is used as-is. A RELATIVE path
@@ -342,7 +343,7 @@ export function registerStateTools(server, z, sessionKey) {
342
343
  safeTool(async (args) => {
343
344
  switch (args.op) {
344
345
  case "save": return jsonContent(await saveStateCore(args, sessionKey));
345
- case "load": return jsonContent(await loadStateCore(args, sessionKey));
346
+ case "load": return attachObserverFrame(jsonContent(await loadStateCore(args, sessionKey)), getHost(sessionKey), `state load ${args.name ?? args.path ?? ""}`.trim());
346
347
  case "list": return jsonContent(listStatesCore(args, sessionKey));
347
348
  case "export": {
348
349
  if (!args.fromSlot) throw new Error("state({op:'export'}): `fromSlot` is required.");
@@ -284,7 +284,14 @@ export function registerTileInspectTools(server, z, sessionKey) {
284
284
  if (r.pngBase64) {
285
285
  return { content: [imageContent(r.pngBase64), textContent(JSON.stringify({ ...r, pngBase64: undefined }))] };
286
286
  }
287
- return jsonContent(r);
287
+ // Lift the livestream sideband OUT of the core's plain result before
288
+ // it's serialized — it must ride on the MCP result object, never in
289
+ // the agent-visible JSON text.
290
+ const sideband = r._observerImages;
291
+ delete r._observerImages;
292
+ const out = jsonContent(r);
293
+ if (sideband) out._observerImages = sideband;
294
+ return out;
288
295
  }
289
296
  default: throw new Error(`tiles: unknown op '${args.op}'`);
290
297
  }
@@ -266,7 +266,7 @@ export function installToolchainCore({ id }) {
266
266
  }
267
267
 
268
268
  export function registerToolchainTools(server, z, sessionKey) {
269
- async function buildSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, crt0, crt0Path, codeLoc, dataLoc, options, linkerConfig, inesHeader, outputPath, inline = false, includeSymbols = false, lint = "advisory", runtime, maxmod, rebuildSdk }) {
269
+ async function buildSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, crt0, crt0Path, codeLoc, dataLoc, options, linkerConfig, linkerConfigPath, inesHeader, outputPath, inline = false, includeSymbols = false, lint = "advisory", runtime, maxmod, rebuildSdk }) {
270
270
  // Reject conflicting inline vs path args — fail loud, not silent.
271
271
  if (source != null && sourcePath != null) {
272
272
  throw new Error("build({output:'rom'}): pass either `source` OR `sourcePath`, not both.");
@@ -281,6 +281,12 @@ export function registerToolchainTools(server, z, sessionKey) {
281
281
  if (crt0Path) {
282
282
  crt0 = await readFile(crt0Path, "utf-8");
283
283
  }
284
+ // linkerConfigPath: read the .cfg from disk so a large multi-bank
285
+ // config (e.g. a disasm'd mapper-2 rebuild) isn't re-streamed through
286
+ // context on every build (0.27.0 feedback #2).
287
+ if (linkerConfigPath && linkerConfig == null) {
288
+ linkerConfig = await readFile(linkerConfigPath, "utf-8");
289
+ }
284
290
  // Auto-inject the bundled crt0 for SMS/GG when caller didn't pass
285
291
  // one. Stock SDCC crt0 doesn't boot these targets; without this,
286
292
  // user main() is never called → black screen. See AUTO_CRT0_PLATFORMS.
@@ -464,7 +470,7 @@ export function registerToolchainTools(server, z, sessionKey) {
464
470
  return jsonContent(payload);
465
471
  }
466
472
 
467
- async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, inesHeader, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
473
+ async function runSourceImpl({ platform, language, source, sourcePath, sources, sourcesPaths, includes, binaryIncludes, binaryIncludePaths, includePaths, runtime, maxmod, rebuildSdk, crt0, crt0Path, codeLoc, dataLoc, linkerConfig, linkerConfigPath, inesHeader, path: projPath, frames = 60, holdInputs, screenshotPath, projectName }) {
468
474
  const { buildForPlatform } = await import("../../toolchains/index.js");
469
475
  const resolved = resolveCore(platform);
470
476
  if (!resolved) throw new Error(`no core available for platform '${platform}'`);
@@ -480,6 +486,7 @@ export function registerToolchainTools(server, z, sessionKey) {
480
486
  binaryIncludes = { ...(binaryIncludes ?? {}), ...r.binaryIncludes };
481
487
  if (r.crt0 != null) crt0 = r.crt0;
482
488
  if (r.codeLoc != null) codeLoc = r.codeLoc;
489
+ if (r.dataLoc != null && dataLoc == null) dataLoc = r.dataLoc;
483
490
  if (r.linkerConfig != null && linkerConfig == null) linkerConfig = r.linkerConfig;
484
491
  if (r.runtime != null && runtime == null) runtime = r.runtime;
485
492
  if (r.maxmod != null && maxmod == null) maxmod = r.maxmod;
@@ -510,6 +517,9 @@ export function registerToolchainTools(server, z, sessionKey) {
510
517
  if (crt0Path) {
511
518
  crt0 = await readFile(crt0Path, "utf-8");
512
519
  }
520
+ if (linkerConfigPath && linkerConfig == null) {
521
+ linkerConfig = await readFile(linkerConfigPath, "utf-8");
522
+ }
513
523
  // Auto-inject bundled crt0 for SMS/GG when caller didn't pass one
514
524
  // (stock SDCC crt0 doesn't boot these targets — see buildSource).
515
525
  if (crt0 == null) {
@@ -693,7 +703,7 @@ export function registerToolchainTools(server, z, sessionKey) {
693
703
  "• output:'rom' (default) — assemble or compile `source` (single) / `sources` ({name:contents}) / `sourcePath` / `sourcesPaths`. Returns the ROM (path by default; `inline:true` for binaryBase64) + build log. **`binaryIncludes`/`binaryIncludePaths` (base64/path CHR-ROM, music blobs for `.incbin`) — WITHOUT them no game with external assets builds.** `includes`/`includePaths` for `.include`d text. `linkerConfig` (cc65; NES preset 'chr-ram-runtime' RECOMMENDED). `crt0`/`crt0Path`/`codeLoc`/`dataLoc` (SDCC). `runtime`/`maxmod`/`rebuildSdk` (GBA/Genesis SDK). **`lint:'strict'` fails the build (stage:'lint', no binary) if the pre-flight SDCC crash-pattern scan flags anything (e.g. the uint8 loop-bound trap); 'advisory' (default) just lists hits in issues[].** **`includeSymbols:true` returns the .map text inline on a PLAIN rom build — distinct from output:'romWithDebug' which writes .dbg/.map FILES.** Language is inferred from extension/content — usually OMIT `language`.\n" +
694
704
  "• output:'romWithDebug' — like 'rom' but also emits linker debug info for the `symbols` tool: cc65 → `.dbg`, SDCC → sdld `.map`, Genesis m68k → GNU ld map (find where a RAM var landed). DEFAULT writes ROM + debug file + log to disk (`outputPath` required unless `inline:true`). **`resolveSymbols:['grid','score']` folds those names' addresses ({resolvedSymbols:{grid:{address,hex,region?,ramOffset?}}}) straight into the result — the cheap way to a WRAM variable's address without loading the whole map (or round-tripping it through `symbols`).**\n" +
695
705
  "• output:'run' — BUILD + LOAD + RUN + SCREENSHOT in one round trip — the fastest iteration loop. Same build args; runs `frames` frames and returns the screenshot INLINE. `holdInputs` holds controller state; `screenshotPath` writes the PNG to disk instead; `projectName` titles the playtest window.\n" +
696
- "• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for a scaffold({op:'project'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a scaffolded project.**",
706
+ "• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for an examples({op:'fork'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a forked project.**",
697
707
  {
698
708
  output: z.enum(["rom", "romWithDebug", "run", "project"])
699
709
  .describe("rom=produce a ROM (default); romWithDebug=ROM + .dbg/.map debug files; run=build+load+run+screenshot; project=build a project directory."),
@@ -714,6 +724,7 @@ export function registerToolchainTools(server, z, sessionKey) {
714
724
  dataLoc: z.coerce.number().int().optional().describe("SDCC — _DATA (WRAM) load address (default $C000 on Z80). NOT read by output:'romWithDebug'."),
715
725
  options: z.array(z.string()).optional().describe("output:'rom' — extra toolchain CLI options."),
716
726
  linkerConfig: z.string().optional().describe("ld65 linker config (cc65). NES presets: 'chr-ram-runtime' (RECOMMENDED for homebrew C — full crt0 + iNES header + NMI w/ OAM DMA + `_shadow_oam` at $0200), 'chr-ram' (bare nmi:rti stub), 'chr-rom' (cc65-C with FIXED CHR-ROM art — segment split + CHARS segment; supply CHR via binaryIncludePaths into a CHARS source + the header via `inesHeader`). Or full .cfg contents. Preset NAMES only resolve on output:'rom'/'run'; output:'romWithDebug' takes raw .cfg contents only. **For rebuilding a commercial NROM game from its disassembly, prefer `inesHeader` over a raw .cfg.**"),
727
+ linkerConfigPath: z.string().optional().describe("Path-based `linkerConfig`: absolute path to a .cfg file on disk (the server reads it — the cfg never enters your context; e.g. the multi-bank cfg a banked-NES disasm project ships). Ignored when `linkerConfig` is passed inline."),
717
728
  inesHeader: z.object({
718
729
  prgBanks: z.coerce.number().int().min(1).max(255).describe("16KB PRG-ROM banks (1 = NROM-128, 2 = NROM-256)."),
719
730
  chrBanks: z.coerce.number().int().min(0).max(255).optional().describe("8KB CHR-ROM banks (0 = CHR-RAM, no CHARS segment). Default 0."),
@@ -785,8 +796,8 @@ export function registerToolchainTools(server, z, sessionKey) {
785
796
  */
786
797
  export function projectBuildRecipe(platform, names) {
787
798
  const has = (n) => names.includes(n);
788
- /** @type {{crt0File:string|null, codeLoc:number|undefined, linkerConfig:string|undefined, runtime:string|undefined, maxmod:boolean|undefined, skip:Set<string>, includeAsC:Set<string>}} */
789
- const r = { crt0File: null, codeLoc: undefined, linkerConfig: undefined, runtime: undefined, maxmod: undefined, skip: new Set(), includeAsC: new Set() };
799
+ /** @type {{crt0File:string|null, codeLoc:number|undefined, dataLoc:number|undefined, linkerConfig:string|undefined, runtime:string|undefined, maxmod:boolean|undefined, skip:Set<string>, includeAsC:Set<string>}} */
800
+ const r = { crt0File: null, codeLoc: undefined, dataLoc: undefined, linkerConfig: undefined, runtime: undefined, maxmod: undefined, skip: new Set(), includeAsC: new Set() };
790
801
 
791
802
  // Reference/upstream sources ship for grepping, not compiling (e.g. GB
792
803
  // music_demo's hUGEDriver.upstream.asm — the .c port is what builds). Skip
@@ -796,7 +807,11 @@ export function projectBuildRecipe(platform, names) {
796
807
  if (platform === "gb" || platform === "gbc") {
797
808
  // GB/GBC ship gb_crt0.s — it MUST go via crt0+codeLoc:0x150, never as a
798
809
  // source (SDCC emits its own gsinit → "Multiple definition of gsinit").
799
- if (has("gb_crt0.s")) { r.crt0File = "gb_crt0.s"; r.codeLoc = 0x150; }
810
+ // dataLoc 0xC200: statics start ABOVE shadow_oam ($C100-$C19F, fixed by
811
+ // the runtime). The sdld default of $C000 let any project with >256 bytes
812
+ // of statics silently overlap the OAM shadow — oam_clear() then zeroed
813
+ // game state (grid/RNG seed). 512 bytes of 8KB WRAM is cheap insurance.
814
+ if (has("gb_crt0.s")) { r.crt0File = "gb_crt0.s"; r.codeLoc = 0x150; r.dataLoc = 0xC200; }
800
815
  } else if (platform === "nes") {
801
816
  // A SCAFFOLDED NES project ships nes_runtime.c + a crt0 + a .cfg and needs
802
817
  // the chr-ram-runtime preset (it defines the OAM/CHARS segments + a NMI with
@@ -823,10 +838,29 @@ export function projectBuildRecipe(platform, names) {
823
838
  // commercial ROMs boot in the same host; only our scaffolds failed). Routing
824
839
  // msx_crt0.s through crt0 makes ITS header + init the cartridge entry.
825
840
  if (has("msx_crt0.s")) { r.crt0File = "msx_crt0.s"; r.codeLoc = 0x4010; }
841
+ } else if (platform === "pce") {
842
+ // PCE example projects (they ship pce_hw.h) build on the 'rom32k' preset:
843
+ // a 32KB HuCard with bank 0 (STARTUP/VECTORS) FIRST in the file at $E000
844
+ // and banks 1-3 (CODE/RODATA) at $8000-$DFFF, where cc65's pce crt0 TAMs
845
+ // them before main(). cc65's stock pce.cfg is an 8KB boot bank — too small
846
+ // for a complete example game — and its documented 32K variant places the
847
+ // vectors in the LAST file bank, which a HuCard never maps at reset
848
+ // (verified black screen on geargrafx). An 8KB-sized program still links
849
+ // and boots identically under this preset, so it's safe for every
850
+ // pce_hw.h-style project. Bare hand-rolled dirs are left alone.
851
+ if (has("pce_hw.h")) r.linkerConfig = "rom32k";
826
852
  } else if (platform === "sms" || platform === "gg") {
827
- // SMS/GG auto-inject their bundled crt0 inside buildForPlatform so the
828
- // scaffold's own *_crt0.s would be a DUPLICATE. Skip it.
829
- for (const n of names) if (/_crt0\.s$/i.test(n)) r.skip.add(n);
853
+ // SMS/GG: route the project's *_crt0.s through the crt0 channel (like
854
+ // GB/MSX), NOT as a plain source TU. The OLD recipe skipped it on the
855
+ // belief that "buildForPlatform auto-injects the bundled crt0" — IT DOES
856
+ // NOT (only the output:'rom'/'run' MCP handlers auto-inject). So every
857
+ // output:'project' SMS/GG build linked SDCC's STOCK z80 crt0, whose boot
858
+ // is `ld a,#2 / rst $08 / halt` — main() never ran and every scaffold
859
+ // booted to a BLACK SCREEN (the RetroDECK "all broken" report; our
860
+ // output:'run' verifications were false-green via the other path).
861
+ // readProjectDir falls back to the bundled crt0 when the dir has none.
862
+ const crt0Name = names.find((n) => /_crt0\.s$/i.test(n));
863
+ if (crt0Name) r.crt0File = crt0Name;
830
864
  } else if (platform === "genesis" || platform === "megadrive" || platform === "md") {
831
865
  // SGDK supplies sega startup + rom header. The scaffold dir may contain
832
866
  // generated intermediates (sega.s, sega.preprocessed.s, rom_header.*, and an
@@ -919,6 +953,15 @@ export async function readProjectDir(projPath, platform) {
919
953
  }
920
954
  }
921
955
 
956
+ // SMS/GG with no crt0 file in the dir → fall back to the bundled crt0,
957
+ // exactly like the output:'rom'/'run' handlers do. Without this the link
958
+ // silently uses SDCC's stock z80 crt0, which never calls main() (black
959
+ // screen at boot). The SMS scaffold historically shipped without a crt0
960
+ // file, so this fallback is load-bearing for existing project dirs.
961
+ if (crt0 == null && (platform === "sms" || platform === "gg")) {
962
+ crt0 = await resolveAutoCrt0(platform);
963
+ }
964
+
922
965
  // GBA runtime refinement: libgba if the entry includes <gba.h>, else the
923
966
  // libtonc default the recipe set.
924
967
  let runtime = recipe.runtime;
@@ -926,11 +969,11 @@ export async function readProjectDir(projPath, platform) {
926
969
  runtime = "libgba";
927
970
  }
928
971
 
929
- return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
972
+ return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, dataLoc: recipe.dataLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
930
973
  }
931
974
 
932
975
  export async function buildProjectCore({ path: projPath, platform, outputPath }) {
933
- const { sources, includes, binaryIncludes, crt0, codeLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
976
+ const { sources, includes, binaryIncludes, crt0, codeLoc, dataLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
934
977
 
935
978
  // Linker preset: the recipe names it (e.g. NES 'chr-ram-runtime', which ships
936
979
  // the OAM/CHARS segments + its own crt0). resolveLinkerConfig also returns any
@@ -968,6 +1011,7 @@ export async function buildProjectCore({ path: projPath, platform, outputPath })
968
1011
  linkerConfig: resolvedLinkerConfig,
969
1012
  crt0: crt0Rel,
970
1013
  codeLoc,
1014
+ dataLoc,
971
1015
  });
972
1016
  if (outputPath && result.binary) {
973
1017
  await mkdir(path.dirname(outputPath), { recursive: true });