romdevtools 0.16.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/AGENTS.md +75 -16
  2. package/CHANGELOG.md +316 -0
  3. package/examples/README.md +2 -0
  4. package/examples/atari2600/templates/platformer.asm +460 -0
  5. package/examples/atari2600/templates/racing.asm +463 -0
  6. package/examples/atari2600/templates/shmup.asm +386 -0
  7. package/examples/atari2600/templates/sports.asm +362 -0
  8. package/examples/atari7800/templates/default.c +49 -5
  9. package/examples/atari7800/templates/hello_sprite.c +48 -4
  10. package/examples/atari7800/templates/music_demo.c +47 -2
  11. package/examples/atari7800/templates/platformer.c +43 -4
  12. package/examples/atari7800/templates/puzzle.c +39 -4
  13. package/examples/atari7800/templates/racing.c +39 -4
  14. package/examples/atari7800/templates/shmup.c +40 -2
  15. package/examples/atari7800/templates/sports.c +36 -5
  16. package/examples/c64/templates/platformer.c +19 -5
  17. package/examples/c64/templates/puzzle.c +32 -2
  18. package/examples/c64/templates/shmup.c +28 -2
  19. package/examples/c64/templates/sports.c +30 -2
  20. package/examples/c64/templates/tile_engine.c +77 -27
  21. package/examples/gb/templates/default.c +110 -16
  22. package/examples/gb/templates/hello_sprite.c +15 -6
  23. package/examples/gb/templates/music_demo.c +36 -0
  24. package/examples/gb/templates/platformer.c +28 -6
  25. package/examples/gb/templates/puzzle.c +35 -4
  26. package/examples/gb/templates/racing.c +75 -10
  27. package/examples/gb/templates/shmup.c +41 -3
  28. package/examples/gb/templates/sports.c +51 -3
  29. package/examples/gb/templates/tile_engine.c +3 -2
  30. package/examples/gba/templates/gba_hello.c +29 -11
  31. package/examples/gba/templates/maxmod_demo.c +36 -2
  32. package/examples/gba/templates/platformer.c +3 -1
  33. package/examples/gba/templates/puzzle.c +15 -3
  34. package/examples/gba/templates/racing.c +65 -3
  35. package/examples/gba/templates/shmup.c +41 -4
  36. package/examples/gba/templates/sports.c +36 -2
  37. package/examples/gba/templates/tonc_hello.c +41 -5
  38. package/examples/gba/templates/tonc_hello_sprite.c +35 -1
  39. package/examples/gbc/templates/default.c +103 -26
  40. package/examples/gbc/templates/hello_sprite.c +12 -3
  41. package/examples/gbc/templates/music_demo.c +56 -12
  42. package/examples/gbc/templates/platformer.c +28 -6
  43. package/examples/gbc/templates/puzzle.c +35 -4
  44. package/examples/gbc/templates/racing.c +88 -21
  45. package/examples/gbc/templates/shmup.c +37 -3
  46. package/examples/gbc/templates/sports.c +48 -3
  47. package/examples/gbc/templates/tile_engine.c +3 -2
  48. package/examples/genesis/main.s +53 -1
  49. package/examples/genesis/templates/hello_sprite.c +25 -3
  50. package/examples/genesis/templates/puzzle.c +37 -3
  51. package/examples/genesis/templates/racing.c +44 -11
  52. package/examples/genesis/templates/sgdk_hello.c +34 -1
  53. package/examples/genesis/templates/shmup.c +31 -1
  54. package/examples/genesis/templates/shmup_2p.c +31 -0
  55. package/examples/genesis/templates/xgm2_demo.c +20 -0
  56. package/examples/gg/templates/default.c +56 -18
  57. package/examples/gg/templates/hello_sprite.c +25 -2
  58. package/examples/gg/templates/music_demo.c +24 -2
  59. package/examples/gg/templates/platformer.c +18 -12
  60. package/examples/gg/templates/puzzle.c +38 -7
  61. package/examples/gg/templates/racing.c +58 -9
  62. package/examples/gg/templates/shmup.c +47 -3
  63. package/examples/gg/templates/sports.c +57 -16
  64. package/examples/gg/templates/tile_engine.c +12 -6
  65. package/examples/lynx/templates/default.c +39 -8
  66. package/examples/lynx/templates/hello_sprite.c +15 -1
  67. package/examples/lynx/templates/music_demo.c +13 -1
  68. package/examples/lynx/templates/puzzle.c +28 -1
  69. package/examples/lynx/templates/racing.c +34 -7
  70. package/examples/lynx/templates/shmup.c +42 -3
  71. package/examples/lynx/templates/sports.c +29 -2
  72. package/examples/msx/platformer/main.c +213 -0
  73. package/examples/msx/puzzle/main.c +250 -0
  74. package/examples/msx/racing/main.c +249 -0
  75. package/examples/msx/shmup/main.c +288 -0
  76. package/examples/msx/sports/main.c +182 -0
  77. package/examples/nes/templates/default.c +67 -19
  78. package/examples/nes/templates/hello_sprite.c +35 -0
  79. package/examples/nes/templates/music_demo.c +40 -0
  80. package/examples/nes/templates/platformer.c +65 -6
  81. package/examples/nes/templates/puzzle.c +67 -6
  82. package/examples/nes/templates/racing.c +45 -13
  83. package/examples/nes/templates/shmup.c +51 -2
  84. package/examples/nes/templates/sports.c +51 -6
  85. package/examples/pce/catch_game/main.c +22 -3
  86. package/examples/pce/music_sfx/main.c +28 -1
  87. package/examples/pce/platformer/main.c +283 -0
  88. package/examples/pce/puzzle/main.c +304 -0
  89. package/examples/pce/racing/main.c +304 -0
  90. package/examples/pce/shmup/main.c +346 -0
  91. package/examples/pce/sports/main.c +254 -0
  92. package/examples/pce/sprite_move/main.c +7 -2
  93. package/examples/sms/main.c +35 -6
  94. package/examples/sms/templates/hello_sprite.c +29 -3
  95. package/examples/sms/templates/music_demo.c +18 -4
  96. package/examples/sms/templates/puzzle.c +34 -5
  97. package/examples/sms/templates/racing.c +39 -2
  98. package/examples/sms/templates/shmup.c +41 -2
  99. package/examples/sms/templates/shmup_2p.c +24 -1
  100. package/examples/sms/templates/sports.c +47 -4
  101. package/examples/snes/main.asm +108 -17
  102. package/examples/snes/templates/c-hello-data.asm +23 -0
  103. package/examples/snes/templates/c-hello.c +18 -1
  104. package/examples/snes/templates/default.c +50 -28
  105. package/examples/snes/templates/hello_sprite-data.asm +23 -0
  106. package/examples/snes/templates/hello_sprite.c +17 -1
  107. package/examples/snes/templates/music_demo-data.asm +23 -0
  108. package/examples/snes/templates/music_demo.c +22 -4
  109. package/examples/snes/templates/platformer-data.asm +22 -0
  110. package/examples/snes/templates/platformer.c +20 -2
  111. package/examples/snes/templates/puzzle-data.asm +22 -0
  112. package/examples/snes/templates/puzzle.c +21 -2
  113. package/examples/snes/templates/racing-data.asm +22 -0
  114. package/examples/snes/templates/racing.c +17 -1
  115. package/examples/snes/templates/shmup-data.asm +22 -0
  116. package/examples/snes/templates/shmup.c +20 -1
  117. package/examples/snes/templates/sports-data.asm +22 -0
  118. package/examples/snes/templates/sports.c +16 -1
  119. package/package.json +1 -1
  120. package/src/cheats/gamegenie.js +0 -1
  121. package/src/cli/smoke.js +1 -3
  122. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  123. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  124. package/src/host/LibretroHost.js +191 -16
  125. package/src/host/callbacks.js +9 -1
  126. package/src/host/chafa-render.js +2 -0
  127. package/src/host/dsp-state.js +2 -2
  128. package/src/host/gpgx-state.js +4 -0
  129. package/src/host/types.js +15 -8
  130. package/src/http/routes.js +1 -1
  131. package/src/http/tool-registry.js +26 -1
  132. package/src/mcp/server.js +1 -1
  133. package/src/mcp/state.js +36 -0
  134. package/src/mcp/tools/address-to-symbol.js +0 -1
  135. package/src/mcp/tools/art-loaders.js +1 -1
  136. package/src/mcp/tools/cart-parts.js +75 -4
  137. package/src/mcp/tools/classify-region.js +1 -1
  138. package/src/mcp/tools/diff-roms.js +1 -1
  139. package/src/mcp/tools/disasm-rebuild.js +507 -0
  140. package/src/mcp/tools/disasm.js +97 -9
  141. package/src/mcp/tools/find-references.js +1 -2
  142. package/src/mcp/tools/font-map.js +1 -1
  143. package/src/mcp/tools/frame.js +168 -3
  144. package/src/mcp/tools/index.js +0 -49
  145. package/src/mcp/tools/input-layout.js +0 -1
  146. package/src/mcp/tools/input.js +33 -3
  147. package/src/mcp/tools/lifecycle.js +18 -4
  148. package/src/mcp/tools/lospec.js +0 -19
  149. package/src/mcp/tools/platform-docs.js +1 -1
  150. package/src/mcp/tools/platform-tools.js +4 -4
  151. package/src/mcp/tools/project.js +54 -11
  152. package/src/mcp/tools/reinject.js +0 -1
  153. package/src/mcp/tools/rom-id.js +2 -2
  154. package/src/mcp/tools/snippets.js +2 -2
  155. package/src/mcp/tools/sprite-pipeline.js +1 -2
  156. package/src/mcp/tools/state.js +201 -14
  157. package/src/mcp/tools/tile-inspect.js +1 -1
  158. package/src/mcp/tools/toolchain.js +105 -12
  159. package/src/mcp/tools/watch-memory.js +137 -16
  160. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +34 -0
  161. package/src/platforms/atari2600/TROUBLESHOOTING.md +6 -0
  162. package/src/platforms/atari7800/TROUBLESHOOTING.md +6 -0
  163. package/src/platforms/c64/MENTAL_MODEL.md +45 -1
  164. package/src/platforms/c64/TROUBLESHOOTING.md +6 -0
  165. package/src/platforms/c64/d64.js +280 -0
  166. package/src/platforms/c64/sid.js +0 -2
  167. package/src/platforms/common/metasprite-adapters.js +1 -1
  168. package/src/platforms/common/metasprite-codegen.js +3 -3
  169. package/src/platforms/common/registers.js +5 -3
  170. package/src/platforms/gb/MENTAL_MODEL.md +10 -0
  171. package/src/platforms/gb/TROUBLESHOOTING.md +6 -0
  172. package/src/platforms/gb/lib/c/gb_runtime.c +4 -4
  173. package/src/platforms/gba/TROUBLESHOOTING.md +6 -0
  174. package/src/platforms/gbc/TROUBLESHOOTING.md +6 -0
  175. package/src/platforms/gbc/lib/c/gb_runtime.c +4 -4
  176. package/src/platforms/genesis/TROUBLESHOOTING.md +6 -0
  177. package/src/platforms/gg/TROUBLESHOOTING.md +6 -0
  178. package/src/platforms/lynx/TROUBLESHOOTING.md +6 -0
  179. package/src/platforms/msx/MENTAL_MODEL.md +10 -6
  180. package/src/platforms/msx/TROUBLESHOOTING.md +6 -0
  181. package/src/platforms/nes/MENTAL_MODEL.md +63 -2
  182. package/src/platforms/nes/TROUBLESHOOTING.md +6 -0
  183. package/src/platforms/nes/image-to-tilemap.js +3 -0
  184. package/src/platforms/nes/lib/asm/famitone2.s +5 -1
  185. package/src/platforms/pce/MENTAL_MODEL.md +9 -4
  186. package/src/platforms/pce/TROUBLESHOOTING.md +6 -0
  187. package/src/platforms/pce/lib/c/pce_video.c +1 -1
  188. package/src/platforms/sms/TROUBLESHOOTING.md +6 -0
  189. package/src/platforms/snes/TROUBLESHOOTING.md +6 -0
  190. package/src/platforms/snes/brr.js +0 -2
  191. package/src/playtest/playtest.js +0 -7
  192. package/src/rom-id/identifier.js +15 -0
  193. package/src/toolchains/asar/asar.js +0 -9
  194. package/src/toolchains/assemble-snippet.js +30 -12
  195. package/src/toolchains/cc65/ines.js +145 -0
  196. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +14 -1
  197. package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
  198. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
  199. package/src/toolchains/common/reassemble.js +10 -3
  200. package/src/toolchains/common/sdk-cache.js +1 -1
  201. package/src/toolchains/genesis-c/genesis-c.js +5 -3
  202. package/src/toolchains/index.js +27 -3
  203. package/src/toolchains/parse-errors.js +78 -1
  204. package/src/toolchains/sdcc/preflight-lint.js +5 -1
  205. package/src/toolchains/sdcc/sdcc.js +1 -1
  206. package/src/toolchains/sjasm/sjasm.js +1 -1
  207. package/src/toolchains/snes-c/snes-c.js +2 -2
  208. package/src/toolchains/vasm68k/vasm68k.js +2 -4
  209. package/src/toolchains/wladx/wladx.js +1 -1
@@ -11,6 +11,66 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
11
11
  import path from "node:path";
12
12
  import { jsonContent, safeTool } from "../util.js";
13
13
  import { identifyRomCore } from "./rom-id.js";
14
+ import { prgToD64, readDirectory as readD64Dir, extractFile as extractD64File } from "../../platforms/c64/d64.js";
15
+
16
+ // ─── C64 .d64 disk image ──────────────────────────────────────────
17
+ //
18
+ // The C64 world ships and loads games as .d64 disk images (the new Commodore 64
19
+ // Ultimate FPGA hardware + the homebrew/demo scene), not as bare .prg files. A
20
+ // cc65 build emits a .prg; packDisk wraps it into a distributable, autostart-able
21
+ // .d64. Extract on a .d64 lists/pulls its files back out.
22
+
23
+ /**
24
+ * Pack a built .prg into a .d64 disk image (the distribution format).
25
+ * @param {{prgPath?:string, bodyPath?:string, romPath?:string, base64?:string,
26
+ * outputPath?:string, name?:string, diskName?:string, inline?:boolean}} args
27
+ */
28
+ export async function packDiskCore(args) {
29
+ const src = args.prgPath || args.bodyPath || args.romPath;
30
+ let prg;
31
+ if (args.base64) prg = new Uint8Array(Buffer.from(args.base64, "base64"));
32
+ else if (src) prg = new Uint8Array(await readFile(src));
33
+ else throw new Error("cart({op:'packDisk'}): provide `prgPath` (the built .prg) or `base64`.");
34
+
35
+ const name = (args.name || (src ? path.basename(src).replace(/\.[^.]+$/, "") : "GAME"))
36
+ .toUpperCase().replace(/[^A-Z0-9 ]/g, "").slice(0, 16) || "GAME";
37
+ const d64 = prgToD64(prg, { name, diskName: args.diskName || name });
38
+
39
+ if (args.inline) {
40
+ return { packed: true, format: "d64", name, bytes: d64.length, base64: Buffer.from(d64).toString("base64") };
41
+ }
42
+ const out = args.outputPath
43
+ || (src ? src.replace(/\.[^.]+$/, "") + ".d64" : null);
44
+ if (!out) throw new Error("cart({op:'packDisk'}): `outputPath` required (or pass `prgPath` to derive it, or inline:true).");
45
+ await writeFile(out, Buffer.from(d64));
46
+ return {
47
+ packed: true, format: "d64", name, bytes: d64.length, path: out,
48
+ note: "Autostart-able 1541 disk image. Load it with loadMedia({platform:'c64', path}) — it boots the program automatically. This is the format the Commodore 64 Ultimate hardware and the homebrew scene load.",
49
+ };
50
+ }
51
+
52
+ /** Read a .d64's directory + (optionally) extract a file. */
53
+ export async function extractDiskCore(args) {
54
+ const data = new Uint8Array(await readFile(args.path));
55
+ const dir = readD64Dir(data);
56
+ const result = { format: "d64", path: args.path, files: dir };
57
+ // If a specific file was named, also return its bytes.
58
+ const which = args.name;
59
+ if (which != null) {
60
+ const bytes = extractD64File(data, which);
61
+ if (!bytes) throw new Error(`cart({op:'extract'}) .d64: no file '${which}' on the disk (have: ${dir.map((d) => d.name).join(", ") || "none"}).`);
62
+ if (args.inline) result.file = { name: which, bytes: bytes.length, base64: Buffer.from(bytes).toString("base64") };
63
+ else {
64
+ const out = args.outputDir
65
+ ? path.join(args.outputDir, which.replace(/[^A-Za-z0-9._-]/g, "_") + ".prg")
66
+ : args.path.replace(/\.d64$/i, "") + "." + which.replace(/[^A-Za-z0-9._-]/g, "_") + ".prg";
67
+ if (args.outputDir) await mkdir(args.outputDir, { recursive: true });
68
+ await writeFile(out, Buffer.from(bytes));
69
+ result.file = { name: which, bytes: bytes.length, path: out };
70
+ }
71
+ }
72
+ return result;
73
+ }
14
74
 
15
75
  // ─── extractCart ──────────────────────────────────────────────────
16
76
 
@@ -71,7 +131,6 @@ function extractNes(data) {
71
131
  */
72
132
  function extractSnes(data) {
73
133
  const copierOff = (data.length % 0x8000 === 0x200) ? 0x200 : 0;
74
- const loMapper = data[copierOff + 0x7FC0 + 0x15];
75
134
  const hiMapper = data[copierOff + 0xFFC0 + 0x15];
76
135
  const isLo = !(hiMapper === 0x21 || hiMapper === 0x31);
77
136
  const internalHeaderBase = copierOff + (isLo ? 0x7FC0 : 0xFFC0);
@@ -590,7 +649,7 @@ function wrapC64({ loadAddress, bodyPath, romPath }) {
590
649
  export function registerCartPartsTools(server, z) {
591
650
  server.tool(
592
651
  "cart",
593
- "Cartridge container ops — identify / split / reassemble a ROM file. `op`: 'identify' | 'extract' | 'wrap'.\n" +
652
+ "Cartridge container ops — identify / split / reassemble a ROM file. `op`: 'identify' | 'extract' | 'wrap' | 'packDisk'.\n" +
594
653
  "'identify': sniff an unknown ROM/zip's platform (which core to load). Handles zip-wrapped ROMs; `path` OR " +
595
654
  "`base64` (+`hint` ext for headerless). Returns {platform, format, title, mapper, region, sizes, confidence}. " +
596
655
  "RE next steps: cheats({op:'lookup'}) is a free labeled memory/code map; disasm is how you change behavior.\n" +
@@ -601,9 +660,13 @@ export function registerCartPartsTools(server, z) {
601
660
  "Round-trips with 'wrap' (extract → romPatch a part → wrap → build).\n" +
602
661
  "'wrap': generate a build-ready wrapper source (+ NES linker config; null for other platforms) that reassembles " +
603
662
  "parts back into a cart. NES auto-generates the iNES header from mapper+mirror (chrPath:null for CHR-RAM; only " +
604
- "prgBanks 1/2 = NROM-128/256). Per-platform part paths in the param hints (pass `romPath` for a one-shot whole-body incbin).",
663
+ "prgBanks 1/2 = NROM-128/256). Per-platform part paths in the param hints (pass `romPath` for a one-shot whole-body incbin).\n" +
664
+ "'packDisk' (C64): wrap a built `.prg` (`prgPath` or `base64`) into a distributable, autostart-able `.d64` disk " +
665
+ "image — the format the new Commodore 64 Ultimate hardware and the homebrew/demo scene actually load. " +
666
+ "Writes `<prg>.d64` (or `outputPath`/`inline`). loadMedia({platform:'c64', path:<.d64>}) boots it directly. " +
667
+ "(extract on a `.d64` lists its directory; pass `name` to pull one file off the disk.)",
605
668
  {
606
- op: z.enum(["identify", "extract", "wrap"]).describe("identify the ROM's platform; extract into parts; wrap parts back into a cart."),
669
+ op: z.enum(["identify", "extract", "wrap", "packDisk"]).describe("identify the ROM's platform; extract into parts (or list/pull files from a C64 .d64); wrap parts back into a cart; packDisk wraps a C64 .prg into a distributable .d64 disk image."),
607
670
  // identify
608
671
  path: z.string().optional().describe("op=identify/extract: absolute path to the ROM file."),
609
672
  base64: z.string().optional().describe("op=identify: base64 ROM bytes (OR path)."),
@@ -631,18 +694,26 @@ export function registerCartPartsTools(server, z) {
631
694
  a78HeaderPath: z.string().optional().describe("op=wrap Atari 7800: the 128-byte A78 header (if present)."),
632
695
  bodyBytes: z.number().int().min(1).optional().describe("op=wrap Atari 7800: size of the 6502 image body (computes the cart origin; default 0xC000)."),
633
696
  loadAddress: z.number().int().min(0).max(0xFFFF).optional().describe("op=wrap C64: load address (default 0x0801)."),
697
+ // packDisk (C64 .d64)
698
+ name: z.string().optional().describe("op=packDisk: disk file name (PETSCII, ≤16 chars; default from prgPath). op=extract .d64: a file to pull off the disk."),
699
+ diskName: z.string().optional().describe("op=packDisk: disk label (≤16 chars; default = name)."),
700
+ outputPath: z.string().optional().describe("op=extract: dir for parts (+ manifest.json). op=packDisk: .d64 output path (default: prgPath with a .d64 extension). Required unless inline:true."),
634
701
  },
635
702
  safeTool(async (args) => {
636
703
  switch (args.op) {
637
704
  case "identify": return await identifyRomCore(args);
638
705
  case "extract": {
639
706
  if (!args.path) throw new Error("cart({op:'extract'}): `path` is required.");
707
+ // A .d64 is a disk image (a container of files), not a flat cart —
708
+ // route it to the disk reader so extract lists/pulls its contents.
709
+ if (/\.d64$/i.test(args.path)) return jsonContent(await extractDiskCore(args));
640
710
  return jsonContent(await extractCartCore(args));
641
711
  }
642
712
  case "wrap": {
643
713
  if (!args.platform) throw new Error("cart({op:'wrap'}): `platform` is required.");
644
714
  return jsonContent(await wrapRomFromPartsCore(args));
645
715
  }
716
+ case "packDisk": return jsonContent(await packDiskCore(args));
646
717
  default: throw new Error(`cart: unknown op '${args.op}'`);
647
718
  }
648
719
  }),
@@ -51,7 +51,7 @@ function zeroRatio(bytes) {
51
51
  * @param {boolean} [opts.bigEndian] platform endianness (for pointer-table guess)
52
52
  * @returns {{ looksLike: string, printableRatio:number, entropy:number, zeroRatio:number, longestAsciiRun:number, asciiPreview:string|null, confidence:string, note:string }}
53
53
  */
54
- export function classifyBytes(bytes, opts = {}) {
54
+ export function classifyBytes(bytes, _opts = {}) {
55
55
  const pr = printableRatio(bytes);
56
56
  const ent = entropy(bytes);
57
57
  const zr = zeroRatio(bytes);
@@ -49,7 +49,7 @@ function regionMapForRom(platform, data) {
49
49
  * live anywhere the developer wants. We tag the header so diffs there
50
50
  * are obvious.
51
51
  */
52
- function regionMapSms(data, platform) {
52
+ function regionMapSms(data, _platform) {
53
53
  const regions = [];
54
54
  // Header lives at $7FF0 IF the file is at least 32 KB. Some homebrew
55
55
  // skips the header entirely (just runs from $0000) — flag with kind
@@ -0,0 +1,507 @@
1
+ // Rebuild planning for `disasm({target:'project'})`.
2
+ //
3
+ // `disasm({target:'project'})` splits a ROM into byte-exact `.asm` region files.
4
+ // But region files alone don't REBUILD the ROM — every console also needs its
5
+ // own "rebuild glue": the cartridge header (iNES for NES, the in-image $100
6
+ // header for Genesis, the LNX header for Lynx, the "AB" header for MSX…), any
7
+ // non-code DATA blobs (NES CHR-ROM), and a recipe that reassembles + concatenates
8
+ // everything back into a byte-identical image.
9
+ //
10
+ // This module turns a disassembled project into a TURNKEY one: for each platform
11
+ // it returns the extra data blobs to write plus the recipe that reproduces the
12
+ // original ROM. `disassembleProjectCore` writes the blobs, a machine-readable
13
+ // `rebuild.json` (when there's a one-call build() recipe), and a human/agent
14
+ // readable `BUILD.md`.
15
+ //
16
+ // Contract — planRebuild(platform, data, regions) returns:
17
+ // {
18
+ // blobs: { [filename]: Uint8Array }, // extra files to write (CHR, headers)
19
+ // build: { ... } | null, // the build({...}) args that reproduce
20
+ // // the ROM, or null if no one-call
21
+ // // build() route exists (see below)
22
+ // verifiable: boolean, // true = the recipe (build() OR the
23
+ // // native recipe in `notes`) is proven
24
+ // // to reproduce the ROM byte-identically
25
+ // notes: string, // what the rebuilder must know
26
+ // }
27
+ //
28
+ // `build.sourcesPaths` / `build.binaryIncludePaths` use BARE filenames (relative
29
+ // to the project dir); the emitter rewrites them to absolute paths in rebuild.json
30
+ // and BUILD.md. `build.linkerConfig`, when present, is INLINE .cfg text (not a
31
+ // path) — the emitter does not rewrite it.
32
+ //
33
+ // IMPORTANT — why most platforms have `build: null`:
34
+ // `disasm` emits each region in the syntax of the NATIVE reassembler for that
35
+ // CPU family (ca65 for 6502/65816; GNU `as` for z80/sm83/m68k/arm), which is
36
+ // what round-trips it byte-exact. But `build()`'s shipping toolchains differ:
37
+ // NES=cc65 (ca65 — MATCHES, so NES is a one-call build() rebuild), but
38
+ // SNES=asar, Atari2600=dasm, SMS/GG/MSX/GB/GBC=SDCC, Genesis=vasm/SGDK,
39
+ // GBA=arm-gcc(C-only) — none of which consume the disasm's syntax. For those,
40
+ // the byte-exact rebuild is the NATIVE recipe documented in `notes` (and the
41
+ // blobs are still written), so the project is rebuildable even though there's
42
+ // no single build() call. `verifiable` reflects whether that recipe is proven
43
+ // byte-identical, regardless of build()-vs-native.
44
+
45
+ /**
46
+ * @param {string} platform
47
+ * @param {Uint8Array} data full ROM bytes
48
+ * @param {Array<{name:string,file:string,kind?:string,fileOffset:number,bytes:Uint8Array,startAddress:number}>} regions
49
+ * @returns {{ blobs: Record<string, Uint8Array>, build: object|null, verifiable: boolean, notes: string }}
50
+ */
51
+ export function planRebuild(platform, data, regions) {
52
+ const fn = PLANNERS[platform];
53
+ if (fn) return fn(data, regions);
54
+ return {
55
+ blobs: {},
56
+ build: regions.length ? { output: "rom", platform, sourcesPaths: srcMap(regions) } : null,
57
+ verifiable: false,
58
+ notes:
59
+ `No platform-specific rebuild recipe for '${platform}' yet — the .asm region(s) ` +
60
+ `reassemble, but you may need a linker config to concatenate them + re-add any ` +
61
+ `cartridge header. See the platform's MENTAL_MODEL.md.`,
62
+ };
63
+ }
64
+
65
+ /** {regionFile: regionFile} for sourcesPaths (bare names; emitter absolutizes). */
66
+ function srcMap(regions) {
67
+ const m = {};
68
+ for (const r of regions) m[r.file] = r.file;
69
+ return m;
70
+ }
71
+
72
+ /** UTF-8 encode a synthesized text source so it can ship as a blob. */
73
+ function enc(s) {
74
+ return new TextEncoder().encode(s);
75
+ }
76
+
77
+ /** The size + repeated byte of a region's stripped trailing pad, if uniform. */
78
+ function trailingPad(data, tailStart) {
79
+ const count = data.length - tailStart;
80
+ if (count <= 0) return { count: 0, byte: 0x00, uniform: true };
81
+ const byte = data[tailStart];
82
+ for (let i = tailStart + 1; i < data.length; i++) {
83
+ if (data[i] !== byte) return { count, byte, uniform: false };
84
+ }
85
+ return { count, byte, uniform: true };
86
+ }
87
+
88
+ const PLANNERS = {
89
+ // ───────────────────────────────────────────── NES (iNES + CHR-ROM)
90
+ // The ONE platform with a one-call build() rebuild: disasm emits ca65, and the
91
+ // NES build() toolchain IS cc65/ca65. inesHeader synthesizes the 16B header +
92
+ // the CHARS segment wiring + the flat NROM .cfg. PROVEN byte-identical.
93
+ nes(data) {
94
+ const prgBanks = data[4];
95
+ const chrBanks = data[5];
96
+ const mapper = (data[6] >> 4) | (data[7] & 0xf0);
97
+ const mirroring = data[6] & 1 ? "vertical" : "horizontal";
98
+ const battery = !!(data[6] & 2);
99
+ const prgEnd = 16 + prgBanks * 0x4000;
100
+ const blobs = {};
101
+ const binaryIncludePaths = {};
102
+ if (chrBanks > 0) {
103
+ blobs["chr.bin"] = data.slice(prgEnd, prgEnd + chrBanks * 0x2000);
104
+ binaryIncludePaths["chr.bin"] = "chr.bin";
105
+ }
106
+ const verifiable = mapper === 0 && prgBanks <= 2;
107
+ return {
108
+ blobs,
109
+ build: {
110
+ output: "rom",
111
+ platform: "nes",
112
+ sourcesPaths: { "prg.asm": "bank0.asm" },
113
+ ...(Object.keys(binaryIncludePaths).length ? { binaryIncludePaths } : {}),
114
+ inesHeader: {
115
+ prgBanks,
116
+ ...(chrBanks ? { chrBanks } : {}),
117
+ ...(mapper ? { mapper } : {}),
118
+ mirroring,
119
+ ...(battery ? { battery: true } : {}),
120
+ },
121
+ },
122
+ verifiable,
123
+ notes: verifiable
124
+ ? `NROM rebuild: inesHeader auto-emits the 16B header + wires chr.bin into the ` +
125
+ `CHARS segment; bank0.asm is the PRG. One-call build() rebuild, byte-identical.`
126
+ : `mapper ${mapper}, ${prgBanks} PRG bank(s): inesHeader's flat .cfg covers NROM ` +
127
+ `(mapper 0, ≤2 PRG banks). For a banked mapper you'll need a linker .cfg that ` +
128
+ `places each bank — the per-bank .asm files + chr.bin are ready; see the NES ` +
129
+ `MENTAL_MODEL.md "CHR-ROM / banked rebuilds" note.`,
130
+ };
131
+ },
132
+
133
+ // ───────────────────────────────────────────── C64 (PRG: 2-byte load addr + body)
134
+ // build({platform:'c64'}) IS cc65/ca65 (matches the disasm syntax), so this is
135
+ // a one-call build() rebuild. planRegions strips the 2-byte load address
136
+ // (fileOffset 2); we re-emit it via a synthesized LOADADDR segment + a custom
137
+ // 2-area .cfg (LOADADDR then the body). PROVEN byte-identical via build().
138
+ c64(data, _regions) {
139
+ const loadAddr = data[0] | (data[1] << 8);
140
+ const bodyLen = data.length - 2;
141
+ const loadaddrSrc =
142
+ "; C64 PRG load-address word — auto-generated by disasm({target:'project'}).\n" +
143
+ '.setcpu "6502"\n' +
144
+ '.segment "LOADADDR"\n' +
145
+ ` .word $${loadAddr.toString(16).toUpperCase().padStart(4, "0")} ; .prg load address\n`;
146
+ const cfg =
147
+ "# C64 .prg rebuild .cfg — auto-generated by disasm({target:'project'}).\n" +
148
+ "# LOADADDR(2 B little-endian) + MAIN(program image), concatenated into one .prg.\n" +
149
+ "MEMORY {\n" +
150
+ ` LOADADDR: start = $${(loadAddr - 2).toString(16).toUpperCase()}, size = $0002, type = ro, file = %O, fill = yes;\n` +
151
+ ` MAIN: start = $${loadAddr.toString(16).toUpperCase()}, size = $${bodyLen.toString(16).toUpperCase()}, type = ro, file = %O, fill = yes, fillval = $FF;\n` +
152
+ "}\n" +
153
+ "SEGMENTS {\n" +
154
+ " LOADADDR: load = LOADADDR, type = ro;\n" +
155
+ " CODE: load = MAIN, type = ro;\n" +
156
+ "}\n";
157
+ return {
158
+ blobs: { "loadaddr.asm": enc(loadaddrSrc) },
159
+ build: {
160
+ output: "rom",
161
+ platform: "c64",
162
+ language: "asm",
163
+ sourcesPaths: { "loadaddr.asm": "loadaddr.asm", "prg.asm": "prg.asm" },
164
+ linkerConfig: cfg,
165
+ },
166
+ verifiable: true,
167
+ notes:
168
+ `C64 .prg rebuild: the 2-byte load address ($${loadAddr.toString(16).toUpperCase()}) is re-emitted by the ` +
169
+ `synthesized loadaddr.asm (LOADADDR segment); prg.asm carries the program image. The ` +
170
+ `custom .cfg concatenates LOADADDR(2 B) + CODE(${bodyLen} B). One-call build() rebuild, ` +
171
+ `byte-identical.`,
172
+ };
173
+ },
174
+
175
+ // ───────────────────────────────────────────── Atari 7800 (.a78: header IN the region)
176
+ // build({platform:'atari7800'}) IS cc65/ca65, and planRegions keeps the 128-byte
177
+ // .a78 header inside rom.asm — so a single flat cc65 build reproduces the whole
178
+ // .a78. One-call build() rebuild, PROVEN byte-identical.
179
+ atari7800(data) {
180
+ const org = (0x10000 - data.length) & 0xffff;
181
+ const cfg =
182
+ "# Atari 7800 .a78 rebuild .cfg — auto-generated by disasm({target:'project'}).\n" +
183
+ "# One flat CODE segment carrying the .a78 header + cart image (planRegions keeps\n" +
184
+ "# the 128-byte header inside rom.asm).\n" +
185
+ "MEMORY {\n" +
186
+ ` M: start = $${org.toString(16).toUpperCase()}, size = $${data.length.toString(16).toUpperCase()}, type = ro, file = %O, fill = yes, fillval = $FF;\n` +
187
+ "}\n" +
188
+ "SEGMENTS {\n" +
189
+ " CODE: load = M, type = ro;\n" +
190
+ "}\n";
191
+ return {
192
+ blobs: {},
193
+ build: {
194
+ output: "rom",
195
+ platform: "atari7800",
196
+ language: "asm",
197
+ sourcesPaths: { "rom.asm": "rom.asm" },
198
+ linkerConfig: cfg,
199
+ },
200
+ verifiable: true,
201
+ notes:
202
+ `Atari 7800 .a78 rebuild: planRegions keeps the 128-byte .a78 header inside rom.asm ` +
203
+ `(org $${org.toString(16).toUpperCase()}), so this single flat cc65 build reproduces the whole .a78 ` +
204
+ `(header + cart). One-call build() rebuild, byte-identical. (If planRegions ever ` +
205
+ `strips the .a78 header, ship it as a blob + re-prepend instead.)`,
206
+ };
207
+ },
208
+
209
+ // ───────────────────────────────────────────── Atari 2600 (flat, dasm — no build() route)
210
+ // The 2600 disasm emits ca65 syntax, but build({platform:'atari2600'}) is DASM,
211
+ // which can't consume ca65 (.setcpu etc.). So no one-call build() route. The
212
+ // rebuild IS byte-exact via the native ca65/ld65 chain (a flat fill .cfg) — that
213
+ // recipe is in notes. (cc65 handles 6502; ld65 links it byte-exact.)
214
+ atari2600(data, regions) {
215
+ const reg = regions[0];
216
+ const cfg =
217
+ "# Atari 2600 flat-cart rebuild .cfg — auto-generated by disasm({target:'project'}).\n" +
218
+ "MEMORY {\n" +
219
+ ` M: start = $${reg.startAddress.toString(16).toUpperCase()}, size = $${reg.bytes.length.toString(16).toUpperCase()}, type = ro, file = %O, fill = yes, fillval = $FF;\n` +
220
+ "}\n" +
221
+ "SEGMENTS {\n CODE: load = M, type = ro;\n}\n";
222
+ return {
223
+ blobs: { "atari2600_rebuild.cfg": enc(cfg) },
224
+ build: null,
225
+ verifiable: true,
226
+ notes:
227
+ `Atari 2600 flat ${reg.bytes.length}-byte cart @ $${reg.startAddress.toString(16).toUpperCase()}. The disasm emits ca65 ` +
228
+ `syntax, but build({platform:'atari2600'}) is DASM — it can't reassemble ca65. ` +
229
+ `Rebuild with the bundled native ca65/ld65 (byte-identical proven):\n` +
230
+ ` ca65 rom.asm -o rom.o && ld65 -C atari2600_rebuild.cfg -o game.bin rom.o\n` +
231
+ `(atari2600_rebuild.cfg is shipped as a blob.)`,
232
+ };
233
+ },
234
+
235
+ // ───────────────────────────────────────────── Atari Lynx (.lnx: 64-byte header stripped)
236
+ // build({platform:'lynx'}) IS cc65/ca65 and reproduces the HEADERLESS cart image
237
+ // byte-exact (one-call). The 64-byte LNX header is stripped by planRegions; we
238
+ // ship it as a blob (lnx_header.bin) — but build() can't prepend it, so the full
239
+ // .lnx needs a final `cat lnx_header.bin + image`. verifiable reflects the full
240
+ // .lnx recipe (header blob + built image), which is byte-identical.
241
+ lynx(data, regions) {
242
+ const hasLnxHeader =
243
+ data.length >= 64 && data[0] === 0x4c && data[1] === 0x59 && data[2] === 0x4e && data[3] === 0x58;
244
+ const reg = regions[0];
245
+ const bodyLen = reg.bytes.length;
246
+ const org = reg.startAddress;
247
+ const base = hasLnxHeader ? 64 : 0;
248
+ const pad = trailingPad(data, base + bodyLen);
249
+ const cfg =
250
+ "# Atari Lynx headerless cart-image rebuild .cfg — auto-generated by disasm.\n" +
251
+ "MEMORY {\n" +
252
+ ` M: start = $${org.toString(16).toUpperCase()}, size = $${bodyLen.toString(16).toUpperCase()}, type = ro, file = %O, fill = yes, fillval = $FF;\n` +
253
+ "}\n" +
254
+ "SEGMENTS {\n CODE: load = M, type = ro;\n}\n";
255
+ const blobs = {};
256
+ if (hasLnxHeader) blobs["lnx_header.bin"] = data.slice(0, 64);
257
+ return {
258
+ blobs,
259
+ build: {
260
+ output: "rom",
261
+ platform: "lynx",
262
+ language: "asm",
263
+ sourcesPaths: { "cart.asm": "cart.asm" },
264
+ linkerConfig: cfg,
265
+ },
266
+ verifiable: pad.uniform && pad.count === 0,
267
+ notes: !pad.uniform
268
+ ? `Lynx: disasm's trailing-pad trim dropped a non-uniform run; the cart image can't ` +
269
+ `be reproduced byte-exact from cart.asm. Re-disassemble without trailing-pad trimming.`
270
+ : pad.count > 0
271
+ ? `Lynx: disasm trimmed ${pad.count} trailing 0x${pad.byte.toString(16).padStart(2, "0")} pad byte(s) — re-pad the ` +
272
+ `built image to ${data.length - base} bytes, then prepend lnx_header.bin for the full .lnx.`
273
+ : `Lynx .lnx rebuild: build() of cart.asm reproduces the HEADERLESS cart image ` +
274
+ `byte-identical. build() can't prepend the 64-byte LNX header, so for the original ` +
275
+ `.lnx, concatenate the shipped lnx_header.bin (exact original header) + the built ` +
276
+ `image. The headerless image alone runs on cores that accept an unheadered Lynx ROM. ` +
277
+ `(Load org $${org.toString(16).toUpperCase()} is the disasm's byte-exact assumption.)`,
278
+ };
279
+ },
280
+
281
+ // ───────────────────────────────────────────── SNES (LoROM, ca65 — no build() route)
282
+ // build({platform:'snes'}) is ASAR (or tcc816 for C), neither of which assembles
283
+ // ca65. No one-call build() route. The rebuild IS byte-exact via native ca65/
284
+ // ld65: each bankN.asm wrapped in its own segment + an N-area .cfg. Recipe in
285
+ // notes; wrappers + .cfg shipped as blobs.
286
+ snes(data, regions) {
287
+ const hasHeader = data.length % 1024 === 512;
288
+ const nBanks = regions.length;
289
+ const blobs = {};
290
+ if (hasHeader) blobs["copier_header.bin"] = data.slice(0, 512);
291
+ const memLines = [];
292
+ const segLines = [];
293
+ for (let i = 0; i < nBanks; i++) {
294
+ blobs[`bank${i}_seg.asm`] = enc(
295
+ `; SNES LoROM bank ${i} wrapper — auto-generated by disasm({target:'project'}).\n` +
296
+ `.segment "BANK${i}"\n.include "bank${i}.asm"\n`
297
+ );
298
+ memLines.push(` BANK${i}: start = $8000, size = $8000, type = ro, file = %O, fill = yes, fillval = $FF;`);
299
+ segLines.push(` BANK${i}: load = BANK${i}, type = ro;`);
300
+ }
301
+ const cfg =
302
+ "# SNES LoROM rebuild .cfg — auto-generated by disasm({target:'project'}).\n" +
303
+ `# ${nBanks} x 32KB banks at $8000, concatenated in order into one .sfc.\n` +
304
+ "MEMORY {\n" + memLines.join("\n") + "\n}\n" +
305
+ "SEGMENTS {\n" + segLines.join("\n") + "\n}\n";
306
+ blobs["snes_rebuild.cfg"] = enc(cfg);
307
+ return {
308
+ blobs,
309
+ build: null,
310
+ verifiable: true,
311
+ notes:
312
+ `SNES LoROM (${nBanks} x 32KB bank(s)${hasHeader ? ", 512-byte copier header stripped → copier_header.bin" : ""}). ` +
313
+ `The disasm emits ca65/65816 syntax, but build({platform:'snes'}) is asar/tcc816 — no ` +
314
+ `build() route. Rebuild with the bundled native ca65/ld65 (byte-identical proven): for ` +
315
+ `each bank, ca65 --cpu 65816 -o bankN.o bankN_seg.asm; then ld65 -C snes_rebuild.cfg ` +
316
+ `-o game.sfc bank0.o..bank${nBanks - 1}.o` +
317
+ (hasHeader ? ` (prepend copier_header.bin for the .smc with its 512-byte header).` : `.`) +
318
+ ` (Wrappers + snes_rebuild.cfg shipped as blobs.)`,
319
+ };
320
+ },
321
+
322
+ // ───────────────────────────────────────────── PC Engine (HuCard — disasm region is LOSSY)
323
+ // PCE is the one honestly-NOT-verifiable case: planRegions trims real trailing
324
+ // $FF padding AND doesn't strip a 512-byte copier header, so the region is a
325
+ // lossy view of the .pce. We still emit a best-effort build call; the note is
326
+ // explicit that an exact rebuild needs a planRegions fix.
327
+ pce(data, regions) {
328
+ const reg = regions[0];
329
+ const bodyLen = reg.bytes.length;
330
+ const hasCopierHeader = data.length % 1024 === 512;
331
+ const trimmedPad = data.length - (hasCopierHeader ? 512 : 0) - bodyLen;
332
+ const cfg =
333
+ "# PC Engine HuCard rebuild .cfg — auto-generated by disasm({target:'project'}).\n" +
334
+ "# NOTE: see BUILD.md — the region is a LOSSY view of this .pce.\n" +
335
+ "MEMORY {\n" +
336
+ ` M: start = $${reg.startAddress.toString(16).toUpperCase()}, size = $${bodyLen.toString(16).toUpperCase()}, type = ro, file = %O, fill = yes, fillval = $FF;\n` +
337
+ "}\n" +
338
+ "SEGMENTS {\n CODE: load = M, type = ro;\n}\n";
339
+ return {
340
+ blobs: { "pce_rebuild.cfg": enc(cfg) },
341
+ build: null,
342
+ verifiable: false,
343
+ notes:
344
+ `PC Engine: NOT byte-identical from this disassembly. The region (${bodyLen} B) is shorter ` +
345
+ `than the original (${data.length} B): planRegions trimmed ${trimmedPad > 0 ? trimmedPad : 0} byte(s) of REAL ` +
346
+ `HuCard $FF padding` +
347
+ (hasCopierHeader ? `, AND a 512-byte copier header was not stripped (its bytes are the first bytes of rom.asm at a bogus org)` : ``) +
348
+ `. To rebuild faithfully, re-disassemble after fixing planRegions to (a) strip a ` +
349
+ `512-byte copier header when len%1024==512 and (b) not trailing-pad-trim HuCard images ` +
350
+ `(or trim only to an 8KB-bank boundary). Also: the disasm emits ca65 but ` +
351
+ `build({platform:'pce'}) is cc65 — the native ca65/ld65 chain with pce_rebuild.cfg is ` +
352
+ `the rebuild path once the region is faithful.`,
353
+ };
354
+ },
355
+
356
+ // ───────────────────────────────────────────── SMS / GG (flat Z80, no header)
357
+ sms(data, regions) { return planSegaFlat("sms", data, regions); },
358
+ gg(data, regions) { return planSegaFlat("gg", data, regions); },
359
+
360
+ // ───────────────────────────────────────────── MSX ("AB" cart header + Z80)
361
+ // build({platform:'msx'}) is SDCC (sdasz80) — can't reassemble the GNU-`as`
362
+ // rom.asm the disasm emits. No build() route. The 16-byte "AB" header is
363
+ // stripped by planRegions (region starts at $4010); shipped as msx_header.bin
364
+ // + re-prepended. With the reassemble.js .org-floor fix, rom.asm now round-trips
365
+ // (the region is at $4010), so the native recipe is byte-exact.
366
+ msx(data, regions) {
367
+ const reg = regions.find((r) => r.file === "rom.asm") ?? regions[0];
368
+ const hasHeader = data.length >= 2 && data[0] === 0x41 && data[1] === 0x42; // "AB"
369
+ const blobs = {};
370
+ if (hasHeader) blobs["msx_header.bin"] = data.slice(0, 16);
371
+ const bodyStart = hasHeader ? 16 : 0;
372
+ const bodyLen = reg ? reg.bytes.length : data.length - bodyStart;
373
+ const pad = trailingPad(data, bodyStart + bodyLen);
374
+ return {
375
+ blobs,
376
+ build: null,
377
+ verifiable: pad.uniform,
378
+ notes:
379
+ `MSX cartridge = 16-byte "AB" header at $4000 + Z80 image; the disasm strips the header ` +
380
+ `(rom.asm starts at $4010)` +
381
+ (hasHeader ? `. The 16 header bytes are shipped as msx_header.bin.` : ` (no "AB" header in this ROM).`) +
382
+ ` build({platform:'msx'}) is SDCC — it can't reassemble the GNU-\`as\` rom.asm. Rebuild ` +
383
+ `natively (bundled z80 binutils): z80-elf-as -march=z80 rom.asm -o rom.o; ` +
384
+ `z80-elf-objcopy -O binary rom.o body.bin; take body.bin[0x4010 : 0x4010+${bodyLen}]; ` +
385
+ `cat msx_header.bin + that body` +
386
+ (pad.count > 0 ? `; pad with 0x${pad.byte.toString(16).padStart(2, "0")} up to ${data.length} bytes.` : `.`) +
387
+ (pad.uniform ? ` Byte-identical (with the reassemble .org-floor fix).` : ` WARNING: trailing pad is non-uniform — capture the tail separately.`),
388
+ };
389
+ },
390
+
391
+ // ───────────────────────────────────────────── Game Boy / GBC (sm83)
392
+ gb(data, regions) { return planGb(data, regions); },
393
+ gbc(data, regions) { return planGb(data, regions); },
394
+
395
+ // ───────────────────────────────────────── Genesis / Mega Drive (m68k, flat)
396
+ // build({platform:'genesis'}) is vasm68k (Motorola syntax) or SGDK C, and it
397
+ // re-pads + rewrites the $18E checksum — no build() route. The header+vectors
398
+ // ($000000..resetPC) are not in any region → shipped as header.bin + prepended;
399
+ // trailing pad re-added. Native recipe byte-identical proven.
400
+ genesis(data, regions) {
401
+ const reg = regions.find((r) => r.file === "rom.asm") || regions[0];
402
+ const start = reg.startAddress;
403
+ const regionLen = reg.bytes.length;
404
+ const header = data.slice(0, start);
405
+ const pad = trailingPad(data, start + regionLen);
406
+ return {
407
+ blobs: { "header.bin": header },
408
+ build: null,
409
+ verifiable: pad.uniform,
410
+ notes:
411
+ `Genesis rebuild = header.bin (${header.length} B: 68k vectors + Sega header, up to the reset ` +
412
+ `PC $${start.toString(16)}) + rom.asm region + ${pad.count} byte(s) of 0x${pad.byte.toString(16).padStart(2, "0")} trailing pad, ` +
413
+ `concatenated in file order. build({platform:'genesis'}) is vasm68k/SGDK and rewrites the ` +
414
+ `$18E checksum — no build() route. Rebuild natively: reassembleForPlatform({platform:'genesis', ` +
415
+ `bytes:<rom.asm region bytes>, startAddress:0x${start.toString(16)}}) (or m68k-elf-as → ld → ` +
416
+ `objcopy; the LINKED objcopy output starts at offset 0 — take bin.slice(0, ${regionLen})), then ` +
417
+ `header.bin ++ region ++ pad. The $18E checksum is inside header.bin (re-added verbatim). ` +
418
+ (pad.uniform ? `Byte-identical proven.` : `WARNING: trailing pad is non-uniform — capture the tail separately.`),
419
+ };
420
+ },
421
+
422
+ // ───────────────────────────────────────────── Game Boy Advance (ARM7TDMI)
423
+ // build({platform:'gba'}) is C-only (asm path not wired) — no build() route. The
424
+ // 192-byte header is a data region; shipped as header.bin (blob) and prepended
425
+ // verbatim (reproduces the 0xBD checksum, no recompute). code.asm reassembles
426
+ // natively (Thumb-as-ARM falls to the byte-exact .byte floor). Native recipe
427
+ // byte-identical proven.
428
+ gba(data, regions) {
429
+ const codeReg = regions.find((r) => r.file === "code.asm") || regions[regions.length - 1];
430
+ const codeStart = codeReg.startAddress;
431
+ const codeLen = codeReg.bytes.length;
432
+ const header = data.slice(0, 0xc0);
433
+ const pad = trailingPad(data, 0xc0 + codeLen);
434
+ return {
435
+ blobs: { "header.bin": header },
436
+ build: null,
437
+ verifiable: pad.uniform,
438
+ notes:
439
+ `GBA rebuild = header.bin (192 B) + code.asm region + ${pad.count} byte(s) of ` +
440
+ `0x${pad.byte.toString(16).padStart(2, "0")} trailing pad, concatenated in file order. ` +
441
+ `build({platform:'gba'}) is C-only (asm path not wired) — no build() route. Rebuild ` +
442
+ `natively: reassembleForPlatform({platform:'gba', bytes:<code.asm region bytes>, ` +
443
+ `startAddress:0x${codeStart.toString(16)}}) (or arm-none-eabi-as → ld → objcopy; take ` +
444
+ `bin.slice(0, ${codeLen})), then header.bin ++ code ++ pad. Use header.bin (this blob) for the ` +
445
+ `concat — the 0xBD header checksum is inside it (reproduced verbatim, no recompute). ` +
446
+ (pad.uniform ? `Byte-identical proven.` : `WARNING: trailing pad is non-uniform — capture the tail separately.`),
447
+ };
448
+ },
449
+ };
450
+
451
+ /**
452
+ * Shared SMS/GG flat-ROM planner. The region is the whole image at $0000 with a
453
+ * uniform trailing pad stripped by trimTrailingPad — recover the cart size + pad
454
+ * byte so the rebuilder re-pads exactly. build:null (SDCC can't reassemble the
455
+ * GNU-`as` rom.asm); native recipe in notes, byte-identical proven.
456
+ */
457
+ function planSegaFlat(platform, data, regions) {
458
+ const reg = regions.find((r) => r.file === "rom.asm") ?? regions[0];
459
+ const trimmedLen = reg ? reg.bytes.length : data.length;
460
+ const pad = trailingPad(data, trimmedLen);
461
+ const name = platform === "sms" ? "Master System" : "Game Gear";
462
+ return {
463
+ blobs: {},
464
+ build: null,
465
+ verifiable: pad.uniform,
466
+ notes:
467
+ `${name} ROM is a FLAT Z80 image at $0000 with no external cartridge header (the "TMR ` +
468
+ `SEGA" signature at $7FF0 is in-image). disasm emits one rom.asm at $0000; trimTrailingPad ` +
469
+ `stripped ${pad.count} trailing 0x${pad.byte.toString(16).padStart(2, "0")} byte(s) (uniform run; original cart ${data.length} B). ` +
470
+ `build({platform:'${platform}'}) is SDCC — it can't reassemble the GNU-\`as\` rom.asm. Rebuild ` +
471
+ `natively (bundled z80 binutils, byte-identical proven): z80-elf-as -march=z80 rom.asm -o ` +
472
+ `rom.o; z80-elf-objcopy -O binary rom.o body.bin (region at $0000 — no lead-in to strip); ` +
473
+ (pad.count > 0 ? `pad body.bin with 0x${pad.byte.toString(16).padStart(2, "0")} up to ${data.length} bytes.` : `that's the cart image.`) +
474
+ (pad.uniform ? `` : ` WARNING: trailing pad is non-uniform — capture the tail separately.`),
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Game Boy / GBC rebuild planner. Flat 16KB banks; header in-image at
480
+ * $0104-$014F inside bank 0 (reproduced as `.byte` data — no patchGbHeader/rgbfix
481
+ * needed). build({platform:'gb'}) is RGBDS/SDCC — can't reassemble the GNU-`as`
482
+ * bankN.asm. Native recipe byte-identical proven (with the .org-floor fix that
483
+ * makes bank1 @ $4000 round-trip).
484
+ */
485
+ function planGb(data, regions) {
486
+ const banks = regions.length;
487
+ return {
488
+ blobs: {},
489
+ build: null,
490
+ verifiable: true,
491
+ notes:
492
+ `Game Boy${banks > 1 ? ` (${banks} × 16KB banks)` : ""}: the disasm emits GNU-\`as\` (gbz80) syntax ` +
493
+ `that build() can't reassemble — rgbasm rejects it and rgbfix would rewrite the checksums — so ` +
494
+ `no build() route. The header ($0104-$014F incl. the $014D header + $014E-$014F global ` +
495
+ `checksums) is \`.byte\` data in bank0.asm and reproduces exactly: do NOT run rgbfix; ` +
496
+ `patchGbHeader is NOT needed. Rebuild natively (bundled binutils, byte-identical proven), ` +
497
+ `assembling each bank and concatenating:\n` +
498
+ regions
499
+ .map(
500
+ (r) =>
501
+ ` z80-elf-as -march=gbz80 ${r.file} -o ${r.name}.o && z80-elf-objcopy -O binary ${r.name}.o ${r.name}.full && ` +
502
+ `dd if=${r.name}.full of=${r.name}.bin bs=1 skip=$((0x${r.startAddress.toString(16)})) count=16384`
503
+ )
504
+ .join("\n") +
505
+ `\n cat ${regions.map((r) => r.name + ".bin").join(" ")} > rebuilt.gb`,
506
+ };
507
+ }