romdevtools 0.26.0 → 0.28.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 (144) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +322 -3
  3. package/README.md +1 -1
  4. package/examples/README.md +1 -1
  5. package/examples/atari2600/templates/platformer.asm +18 -9
  6. package/examples/atari2600/templates/racing.asm +25 -4
  7. package/examples/atari2600/templates/shmup.asm +30 -5
  8. package/examples/atari2600/templates/sports.asm +41 -9
  9. package/examples/atari7800/templates/hello_sprite.c +8 -4
  10. package/examples/atari7800/templates/platformer.c +12 -8
  11. package/examples/atari7800/templates/puzzle.c +7 -4
  12. package/examples/atari7800/templates/racing.c +5 -2
  13. package/examples/atari7800/templates/shmup.c +8 -4
  14. package/examples/atari7800/templates/sports.c +6 -3
  15. package/examples/c64/templates/platformer.c +28 -24
  16. package/examples/c64/templates/puzzle.c +77 -16
  17. package/examples/c64/templates/racing.c +9 -0
  18. package/examples/c64/templates/shmup.c +13 -1
  19. package/examples/c64/templates/sports.c +9 -4
  20. package/examples/gb/templates/platformer.c +6 -2
  21. package/examples/gb/templates/puzzle.c +279 -101
  22. package/examples/gb/templates/racing.c +13 -1
  23. package/examples/gb/templates/shmup.c +13 -1
  24. package/examples/gb/templates/sports.c +9 -3
  25. package/examples/gba/templates/platformer.c +7 -13
  26. package/examples/gba/templates/puzzle.c +93 -15
  27. package/examples/gba/templates/racing.c +13 -1
  28. package/examples/gba/templates/shmup.c +13 -1
  29. package/examples/gba/templates/sports.c +17 -5
  30. package/examples/gbc/templates/platformer.c +6 -2
  31. package/examples/gbc/templates/puzzle.c +878 -178
  32. package/examples/gbc/templates/racing.c +13 -1
  33. package/examples/gbc/templates/shmup.c +13 -1
  34. package/examples/gbc/templates/sports.c +9 -3
  35. package/examples/genesis/templates/puzzle.c +76 -15
  36. package/examples/genesis/templates/racing.c +13 -1
  37. package/examples/genesis/templates/shmup_2p.c +13 -1
  38. package/examples/gg/templates/platformer.c +4 -0
  39. package/examples/gg/templates/puzzle.c +80 -14
  40. package/examples/gg/templates/racing.c +17 -1
  41. package/examples/gg/templates/shmup.c +17 -1
  42. package/examples/gg/templates/sports.c +4 -0
  43. package/examples/lynx/templates/platformer.c +25 -6
  44. package/examples/lynx/templates/puzzle.c +77 -14
  45. package/examples/lynx/templates/shmup.c +13 -1
  46. package/examples/lynx/templates/sports.c +5 -2
  47. package/examples/msx/platformer/main.c +2 -0
  48. package/examples/msx/puzzle/main.c +78 -15
  49. package/examples/msx/racing/main.c +1 -0
  50. package/examples/msx/shmup/main.c +1 -0
  51. package/examples/msx/sports/main.c +3 -2
  52. package/examples/nes/templates/platformer.c +11 -3
  53. package/examples/nes/templates/puzzle.c +81 -21
  54. package/examples/nes/templates/racing.c +15 -1
  55. package/examples/nes/templates/shmup.c +1 -0
  56. package/examples/nes/templates/sports.c +1 -0
  57. package/examples/pce/platformer/main.c +3 -1
  58. package/examples/pce/puzzle/main.c +78 -12
  59. package/examples/pce/racing/main.c +1 -0
  60. package/examples/pce/shmup/main.c +5 -4
  61. package/examples/pce/sports/main.c +4 -3
  62. package/examples/sms/templates/platformer.c +4 -0
  63. package/examples/sms/templates/puzzle.c +80 -14
  64. package/examples/sms/templates/racing.c +17 -1
  65. package/examples/sms/templates/shmup.c +17 -1
  66. package/examples/sms/templates/shmup_2p.c +17 -1
  67. package/examples/sms/templates/sports.c +4 -0
  68. package/examples/snes/templates/platformer.c +32 -15
  69. package/examples/snes/templates/puzzle.c +84 -16
  70. package/examples/snes/templates/racing.c +20 -1
  71. package/examples/snes/templates/shmup.c +20 -2
  72. package/examples/snes/templates/sports.c +7 -0
  73. package/package.json +12 -12
  74. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  75. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  76. package/src/cores/wasm/fceumm_libretro.js +1 -1
  77. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  78. package/src/cores/wasm/gambatte_libretro.js +1 -1
  79. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  80. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  81. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  82. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  83. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  84. package/src/cores/wasm/handy_libretro.js +1 -1
  85. package/src/cores/wasm/handy_libretro.wasm +0 -0
  86. package/src/cores/wasm/mgba_libretro.js +1 -1
  87. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  88. package/src/cores/wasm/prosystem_libretro.js +1 -1
  89. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  90. package/src/cores/wasm/snes9x_libretro.js +1 -1
  91. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  92. package/src/cores/wasm/stella2014_libretro.js +1 -1
  93. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  94. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  95. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  96. package/src/host/LibretroHost.js +245 -10
  97. package/src/mcp/server.js +6 -0
  98. package/src/mcp/tools/disasm-rebuild.js +315 -65
  99. package/src/mcp/tools/disasm.js +149 -28
  100. package/src/mcp/tools/find-references.js +216 -51
  101. package/src/mcp/tools/frame.js +11 -4
  102. package/src/mcp/tools/index.js +15 -1
  103. package/src/mcp/tools/input.js +26 -3
  104. package/src/mcp/tools/memory.js +208 -39
  105. package/src/mcp/tools/playtest.js +56 -4
  106. package/src/mcp/tools/project.js +35 -9
  107. package/src/mcp/tools/toolchain.js +43 -10
  108. package/src/mcp/tools/watch-memory.js +172 -25
  109. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +64 -17
  110. package/src/platforms/atari2600/MENTAL_MODEL.md +5 -1
  111. package/src/platforms/atari2600/TROUBLESHOOTING.md +40 -0
  112. package/src/platforms/atari7800/MENTAL_MODEL.md +27 -6
  113. package/src/platforms/gb/MENTAL_MODEL.md +16 -1
  114. package/src/platforms/gb/TROUBLESHOOTING.md +42 -0
  115. package/src/platforms/gb/lib/c/patch-header.js +7 -4
  116. package/src/platforms/gbc/MENTAL_MODEL.md +12 -0
  117. package/src/platforms/gbc/TROUBLESHOOTING.md +21 -0
  118. package/src/platforms/gbc/lib/c/font.h +43 -0
  119. package/src/platforms/gbc/lib/c/patch-header.js +7 -4
  120. package/src/platforms/genesis/MENTAL_MODEL.md +40 -6
  121. package/src/platforms/genesis/lib/c/genesis_sfx.c +37 -0
  122. package/src/platforms/genesis/lib/c/genesis_sfx.h +1 -0
  123. package/src/platforms/gg/TROUBLESHOOTING.md +13 -17
  124. package/src/platforms/gg/lib/c/gg_crt0.s +14 -2
  125. package/src/platforms/lynx/lib/c/lynx_sfx.c +38 -2
  126. package/src/platforms/lynx/lib/c/lynx_sfx.h +1 -0
  127. package/src/platforms/msx/MENTAL_MODEL.md +6 -0
  128. package/src/platforms/msx/TROUBLESHOOTING.md +21 -0
  129. package/src/platforms/msx/lib/c/msx_crt0.s +27 -0
  130. package/src/platforms/msx/lib/c/msx_hw.h +2 -0
  131. package/src/platforms/msx/lib/c/msx_vdp.c +45 -0
  132. package/src/platforms/nes/MENTAL_MODEL.md +10 -3
  133. package/src/platforms/nes/lib/c/nes_runtime.c +41 -0
  134. package/src/platforms/nes/lib/c/nes_runtime.h +2 -0
  135. package/src/platforms/pce/MENTAL_MODEL.md +9 -0
  136. package/src/platforms/pce/TROUBLESHOOTING.md +9 -0
  137. package/src/platforms/pce/lib/c/pce_hw.h +2 -1
  138. package/src/platforms/pce/lib/c/pce_sound.c +22 -0
  139. package/src/platforms/sms/MENTAL_MODEL.md +5 -0
  140. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  141. package/src/platforms/sms/lib/c/sms_crt0.s +14 -2
  142. package/src/platforms/snes/MENTAL_MODEL.md +5 -0
  143. package/src/playtest/playtest.js +73 -3
  144. package/src/toolchains/index.js +37 -8
@@ -92,7 +92,7 @@ const TEMPLATES = {
92
92
  linkerConfig: { presetSrc: "presets/nes/chr-ram-runtime.cfg", dst: "chr-ram-runtime.cfg" },
93
93
  lang: "C (cc65)",
94
94
  ext: ".nes",
95
- describe: "Match-3 falling-block puzzle. 6×12 grid, 1×3 active piece (3 colors), rotate via A, soft-drop on DOWN, horizontal-triple clear.",
95
+ describe: "Match-3 falling-block puzzle. 6×12 grid, 1×3 active piece (3 colors), rotate via A, soft-drop on DOWN, 3+-in-a-row clears in all 4 directions (H/V/diagonals) with gravity + cascade chains.",
96
96
  },
97
97
  sports: {
98
98
  main: "templates/sports.c",
@@ -214,7 +214,7 @@ const TEMPLATES = {
214
214
  ],
215
215
  lang: "C (SDCC sm83)",
216
216
  ext: ".gb",
217
- describe: "Match-3 falling-block puzzle scaffold for GB. 6×12 grid rendered via BG tilemap, 1×3 active piece (3 colours via 3 BG tile shapes), rotate via A, hard-drop on START, horizontal-triple clear.",
217
+ describe: "Match-3 falling-block puzzle scaffold for GB. 6×12 grid rendered via BG tilemap, 1×3 active piece (3 colours via 3 BG tile shapes), rotate via A, hard-drop on START, 3+-in-a-row clears in all 4 directions (H/V/diagonals) with gravity + cascade chains.",
218
218
  },
219
219
  sports: {
220
220
  main: "templates/sports.c",
@@ -284,7 +284,7 @@ const TEMPLATES = {
284
284
  catch_game: mk("catch_game", "A complete tiny game: a paddle catches a falling object with the d-pad; full game loop with waitvsync(), two sprites, collision, scoring."),
285
285
  shmup: mk("shmup", "Vertical shoot-'em-up for PC Engine. Player ship + bullet/enemy object pools, a wave spawner, AABB collisions, score HUD, scrolling-band starfield BG. d-pad flies, button I fires. The base for any action shooter."),
286
286
  platformer: mk("platformer", "Side-scrolling platformer for PC Engine. Gravity + jump + land-on-top platform collision, a multi-screen world streamed via BG X-scroll (BXR), solid platform tiles, sub-pixel physics. d-pad moves, button I jumps."),
287
- puzzle: mk("puzzle", "Match-3 / falling-block puzzle for PC Engine. A 6x12 well drawn with BG tiles, a 1x3 active piece you move/rotate/soft-drop/hard-drop, horizontal-triple clears, score. d-pad moves, I rotates, II hard-drops."),
287
+ puzzle: mk("puzzle", "Match-3 / falling-block puzzle for PC Engine. A 6x12 well drawn with BG tiles, a 1x3 active piece you move/rotate/soft-drop/hard-drop, 3+-in-a-row clears (H+V) with gravity + cascade chains, score. d-pad moves, I rotates, II hard-drops."),
288
288
  sports: mk("sports", "Pong-style sports game for PC Engine. Two paddles + a bouncing ball on a netted court, score to 9, paddle-deflect physics; player 2 falls back to chase-AI when no input. d-pad moves P1."),
289
289
  racing: mk("racing", "Top-down lane racer for PC Engine. Player car at the bottom, obstacle cars spawn from the top and slide down, LEFT/RIGHT switches lanes, speed grows with score, crash freeze + auto-reset. Scrolling road BG."),
290
290
  };
@@ -305,7 +305,7 @@ const TEMPLATES = {
305
305
  catch_game: mk("catch_game", "A complete tiny game: a paddle catches falling fruit with the joystick; full game loop with vblank sync, two sprites, collision, scoring."),
306
306
  shmup: mk("shmup", "Vertical-shmup scaffold for MSX (screen 2). Player ship (sprite plane 0) + 4 bullet + 4 enemy object pools, a wave spawner, AABB collision, on-screen SCORE tiles, over a banded starfield filling the whole 32x24 name table. Joystick PORT 1 moves the ship (UP/DOWN/LEFT/RIGHT), trigger A (GTTRIG) fires; PSG blip on fire, noise-ish tone on a kill. Interrupt-free vsync via VDP status S#0. Extend with enemy fire, lives, scrolling stars."),
307
307
  platformer: mk("platformer", "Side-scrolling platformer for MSX (screen 2). Subpixel gravity/jump/land-on-top collision against a table of platforms across a 512-px (64-cell) world, drawn by COLUMN STREAMING into the wrapping screen-2 name table as the camera follows the player; the player sprite draws in screen space. Joystick LEFT/RIGHT walks, trigger A jumps (only when grounded); PSG jump blip. Interrupt-free vsync. Extend with enemies, pickups, goal."),
308
- puzzle: mk("puzzle", "Match-3 / falling-block puzzle for MSX (screen 2). A 6-wide x 12-tall well drawn with the BG tilemap (distinct R/G/B cell tiles + grey border + dim field interior so the playfield is always visible). A 1x3 active piece: joystick LEFT/RIGHT shifts, trigger A rotates the colour order, DOWN soft-drops, trigger B hard-drops; horizontal-triple clears score with a PSG chime. Interrupt-free vsync. Extend with vertical/diagonal matches, gravity-collapse, levels."),
308
+ puzzle: mk("puzzle", "Match-3 / falling-block puzzle for MSX (screen 2). A 6-wide x 12-tall well drawn with the BG tilemap (distinct R/G/B cell tiles + grey border + dim field interior so the playfield is always visible). A 1x3 active piece: joystick LEFT/RIGHT shifts, trigger A rotates the colour order, DOWN soft-drops, trigger B hard-drops; 3+-in-a-row clears in all 4 directions with gravity + cascade chains; PSG chime per clear. Interrupt-free vsync. Extend with levels/next-piece preview."),
309
309
  sports: mk("sports", "Pong-style 2-player sports for MSX (screen 2). Court (green field + white sidelines + dashed centre net) fills the 32x24 name table; two paddles (stacked sprites) + a ball. Player 1 = joystick PORT 1 UP/DOWN; Player 2 = joystick PORT 2 UP/DOWN, falling back to chase-the-ball AI when no second pad is present so it is playable solo. Wall/paddle bounces + scoring with PSG bonks. Interrupt-free vsync. Extend with serve angles, score display, win condition."),
310
310
  racing: mk("racing", "Top-down 3-lane racing for MSX (screen 2). Grey road + green-grass shoulders fill the name table; player car at the bottom, obstacle cars (object pool) spawn at the top and slide down. Joystick LEFT/RIGHT (edge-detected) switches lanes; obstacle speed grows with score; an AABB crash triggers a ~60-frame freeze then auto-reset, with a PSG crash tone. SCORE drawn as tiles. Interrupt-free vsync. Extend with pseudo-3D road, fuel, multiple cars."),
311
311
  };
@@ -351,9 +351,19 @@ TEMPLATES.gbc = {
351
351
  describe: "SIDE-SCROLLING platformer for GBC. Full CGB color palette (BG + sprite via BCPS/OCPS) over the GB side-scroller core: subpixel gravity + jump + land-on-top collision against platforms across a 256-px world (the wrapping BG map). The camera follows the player and scrolls the BG via SCX; the player sprite draws in screen space. A=jump, d-pad=move. One BG map wide (no streaming) — for a wider world, stream a new BG-map column on each 8px camera step (window for a fixed HUD). See the GBC MENTAL_MODEL.md 'Horizontal scrolling'. Extend with enemies, goals, pickups.",
352
352
  },
353
353
  puzzle: {
354
- main: "templates/puzzle.c", runtime: GBC_RUNTIME,
354
+ main: "templates/puzzle.c",
355
+ runtime: [
356
+ ...GBC_RUNTIME,
357
+ { src: "lib/c/font.h", dst: "font.h" }, /* digits+A-Z 2bpp glyphs for the HUD */
358
+ ],
355
359
  lang: GBC_LANG, ext: ".gbc",
356
- describe: "Match-3 puzzle for GBC. Three colored cells (BG palette via BCPS/BCPD), rotate + soft-drop + hard-drop + triple-clear chime.",
360
+ describe: "Falling-jewel matcher for GBC (the polished reference puzzle). 8x17 well, 6 jewel colors with " +
361
+ "real CGB palettes, matches in all 4 directions (H/V/both diagonals), gravity + cascade chains, magic " +
362
+ "jewel every 18th piece, level speedup, 6-digit score, title + game-over screens, SFX + toggleable " +
363
+ "music. Rendering: falling column + NEXT preview are OAM sprites; the locked well is BG tiles via a " +
364
+ "COLLECT/FLUSH vblank queue with an idle scrub (writes outside vblank silently drop on this core — " +
365
+ "never bypass the queue). Statics need dataLoc 0xC200 (above shadow_oam at $C100) — the project build " +
366
+ "recipe sets that automatically.",
357
367
  },
358
368
  sports: {
359
369
  main: "templates/sports.c", runtime: GBC_RUNTIME,
@@ -398,6 +408,11 @@ TEMPLATES.gbc = {
398
408
  // against. Factored to a constant so adding a new template is a one-line
399
409
  // change at the bottom.
400
410
  const SMS_RUNTIME = [
411
+ // The crt0 ships IN the project (like GG/MSX) so the dir is genuinely
412
+ // self-contained: build({output:'project'}) routes it via the crt0 channel
413
+ // (projectBuildRecipe), and an external stock-SDCC rebuild has the real
414
+ // boot stub on disk instead of silently linking SDCC's non-booting one.
415
+ { src: "lib/c/sms_crt0.s", dst: "sms_crt0.s" },
401
416
  { src: "lib/c/sms_hw.h", dst: "sms_hw.h" },
402
417
  { src: "lib/c/vdp_init.c", dst: "vdp_init.c" },
403
418
  { src: "lib/c/load_palette.c", dst: "load_palette.c" },
@@ -764,7 +779,7 @@ TEMPLATES.snes = {
764
779
  runtimeDirs: SNES_PVSNESLIB_VENDOR_DIRS,
765
780
  lang: "C (tcc-65816 + PVSnesLib)",
766
781
  ext: ".sfc",
767
- describe: "Match-3 falling-block puzzle for SNES. 6×12 grid (text mode), rotate/soft-drop/hard-drop, horizontal-triple clear. Rotate click + clear chime via bundled SPC700 sfx.",
782
+ describe: "Match-3 falling-block puzzle for SNES. 6×12 grid (text mode), rotate/soft-drop/hard-drop, 3+-in-a-row clears in all 4 directions with gravity + cascade chains. Rotate click + clear chime via bundled SPC700 sfx.",
768
783
  },
769
784
  sports: {
770
785
  main: "templates/sports.c",
@@ -939,7 +954,7 @@ TEMPLATES.genesis = {
939
954
  runtimeDirs: SGDK_RUNTIME_DIRS,
940
955
  lang: SGDK_LANG,
941
956
  ext: ".bin",
942
- describe: "Match-3 falling-block puzzle genre scaffold. 6×12 grid, 1×3 active piece (3 colours), rotate via A, soft-drop on DOWN, hard-drop on START, horizontal-triple clear. xorshift RNG so cell colours actually vary.",
957
+ describe: "Match-3 falling-block puzzle genre scaffold. 6×12 grid, 1×3 active piece (3 colours), rotate via A, soft-drop on DOWN, hard-drop on START, 3+-in-a-row clears in all 4 directions with gravity + cascade chains. xorshift RNG so cell colours actually vary.",
943
958
  },
944
959
  sports: {
945
960
  main: "templates/sports.c",
@@ -1757,7 +1772,18 @@ Compiles **C89**, not C99/C11. Stick to:
1757
1772
  let filesSection = `## Files\n\n- \`${mainFilename}\` — your game's entry point.\n`;
1758
1773
  if (tmpl?.runtime) {
1759
1774
  for (const { dst } of tmpl.runtime) {
1760
- filesSection += `- \`${dst}\` runtime helper. **You own this** — edit or replace at will.\n`;
1775
+ if (dst === "patch-header.js") {
1776
+ // NOT game code — calling it a "runtime helper" implied it compiles
1777
+ // into the ROM and confused readers. It's a standalone sidecar tool.
1778
+ filesSection += `- \`${dst}\` — sidecar TOOL, not game code (never compiled into the ROM). ` +
1779
+ `Stamps the Nintendo logo + header/global checksums a GB ROM needs to boot ` +
1780
+ `(\`node patch-header.js game.gb\`) — a zero-install stand-in for RGBDS's rgbfix when you ` +
1781
+ `rebuild OUTSIDE romdev with stock SDCC. romdev's own builds fix the header automatically.\n`;
1782
+ } else if (dst.endsWith("_crt0.s")) {
1783
+ filesSection += `- \`${dst}\` — startup assembly (reset/interrupt vectors, RAM init; routed as the crt0 by the project build). **You own this.**\n`;
1784
+ } else {
1785
+ filesSection += `- \`${dst}\` — runtime helper. **You own this** — edit or replace at will.\n`;
1786
+ }
1761
1787
  }
1762
1788
  }
1763
1789
  if (tmpl?.crt0) {
@@ -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) {
@@ -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
@@ -824,9 +839,17 @@ export function projectBuildRecipe(platform, names) {
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; }
826
841
  } 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);
842
+ // SMS/GG: route the project's *_crt0.s through the crt0 channel (like
843
+ // GB/MSX), NOT as a plain source TU. The OLD recipe skipped it on the
844
+ // belief that "buildForPlatform auto-injects the bundled crt0" — IT DOES
845
+ // NOT (only the output:'rom'/'run' MCP handlers auto-inject). So every
846
+ // output:'project' SMS/GG build linked SDCC's STOCK z80 crt0, whose boot
847
+ // is `ld a,#2 / rst $08 / halt` — main() never ran and every scaffold
848
+ // booted to a BLACK SCREEN (the RetroDECK "all broken" report; our
849
+ // output:'run' verifications were false-green via the other path).
850
+ // readProjectDir falls back to the bundled crt0 when the dir has none.
851
+ const crt0Name = names.find((n) => /_crt0\.s$/i.test(n));
852
+ if (crt0Name) r.crt0File = crt0Name;
830
853
  } else if (platform === "genesis" || platform === "megadrive" || platform === "md") {
831
854
  // SGDK supplies sega startup + rom header. The scaffold dir may contain
832
855
  // generated intermediates (sega.s, sega.preprocessed.s, rom_header.*, and an
@@ -919,6 +942,15 @@ export async function readProjectDir(projPath, platform) {
919
942
  }
920
943
  }
921
944
 
945
+ // SMS/GG with no crt0 file in the dir → fall back to the bundled crt0,
946
+ // exactly like the output:'rom'/'run' handlers do. Without this the link
947
+ // silently uses SDCC's stock z80 crt0, which never calls main() (black
948
+ // screen at boot). The SMS scaffold historically shipped without a crt0
949
+ // file, so this fallback is load-bearing for existing project dirs.
950
+ if (crt0 == null && (platform === "sms" || platform === "gg")) {
951
+ crt0 = await resolveAutoCrt0(platform);
952
+ }
953
+
922
954
  // GBA runtime refinement: libgba if the entry includes <gba.h>, else the
923
955
  // libtonc default the recipe set.
924
956
  let runtime = recipe.runtime;
@@ -926,11 +958,11 @@ export async function readProjectDir(projPath, platform) {
926
958
  runtime = "libgba";
927
959
  }
928
960
 
929
- return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
961
+ return { sources, includes, binaryIncludes, crt0, codeLoc: recipe.codeLoc, dataLoc: recipe.dataLoc, linkerConfig: recipe.linkerConfig, runtime, maxmod: recipe.maxmod };
930
962
  }
931
963
 
932
964
  export async function buildProjectCore({ path: projPath, platform, outputPath }) {
933
- const { sources, includes, binaryIncludes, crt0, codeLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
965
+ const { sources, includes, binaryIncludes, crt0, codeLoc, dataLoc, linkerConfig, runtime, maxmod } = await readProjectDir(projPath, platform);
934
966
 
935
967
  // Linker preset: the recipe names it (e.g. NES 'chr-ram-runtime', which ships
936
968
  // the OAM/CHARS segments + its own crt0). resolveLinkerConfig also returns any
@@ -968,6 +1000,7 @@ export async function buildProjectCore({ path: projPath, platform, outputPath })
968
1000
  linkerConfig: resolvedLinkerConfig,
969
1001
  crt0: crt0Rel,
970
1002
  codeLoc,
1003
+ dataLoc,
971
1004
  });
972
1005
  if (outputPath && result.binary) {
973
1006
  await mkdir(path.dirname(outputPath), { recursive: true });