romdevtools 0.27.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 +309 -0
  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 +141 -24
  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
@@ -30,17 +30,19 @@
30
30
  // and BUILD.md. `build.linkerConfig`, when present, is INLINE .cfg text (not a
31
31
  // path) — the emitter does not rewrite it.
32
32
  //
33
- // IMPORTANT — why most platforms have `build: null`:
33
+ // IMPORTANT — which platforms get a one-call build() and why:
34
34
  // `disasm` emits each region in the syntax of the NATIVE reassembler for that
35
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.
36
+ // what round-trips it byte-exact. A one-call build() rebuild exists exactly
37
+ // where build()'s asm toolchain IS cc65/ca65: NES (NROM and banked mappers),
38
+ // C64, Atari 7800 (flat and SuperGame banked), Lynx, and PCE (flat and
39
+ // banked HuCards). The rest differ: SNES=asar, Atari2600=dasm,
40
+ // SMS/GG/MSX/GB/GBC=SDCC, Genesis=vasm/SGDK, GBA=arm-gcc(C-only) none of
41
+ // which consume the disasm's syntax. For those, the byte-exact rebuild is
42
+ // the NATIVE recipe documented in `notes` (per-bank for banked carts; the
43
+ // blobs are still written), so the project is rebuildable even though
44
+ // there's no single build() call. `verifiable` reflects whether that recipe
45
+ // is proven byte-identical, regardless of build()-vs-native.
44
46
 
45
47
  /**
46
48
  * @param {string} platform
@@ -90,7 +92,7 @@ const PLANNERS = {
90
92
  // The ONE platform with a one-call build() rebuild: disasm emits ca65, and the
91
93
  // NES build() toolchain IS cc65/ca65. inesHeader synthesizes the 16B header +
92
94
  // the CHARS segment wiring + the flat NROM .cfg. PROVEN byte-identical.
93
- nes(data) {
95
+ nes(data, regions) {
94
96
  const prgBanks = data[4];
95
97
  const chrBanks = data[5];
96
98
  const mapper = (data[6] >> 4) | (data[7] & 0xf0);
@@ -103,30 +105,93 @@ const PLANNERS = {
103
105
  blobs["chr.bin"] = data.slice(prgEnd, prgEnd + chrBanks * 0x2000);
104
106
  binaryIncludePaths["chr.bin"] = "chr.bin";
105
107
  }
106
- const verifiable = mapper === 0 && prgBanks <= 2;
108
+ const flat = mapper === 0 && prgBanks <= 2;
109
+ if (flat) {
110
+ // NROM: the inesHeader one-call path (PROVEN byte-identical). Unchanged.
111
+ return {
112
+ blobs,
113
+ build: {
114
+ output: "rom",
115
+ platform: "nes",
116
+ sourcesPaths: { "prg.asm": "bank0.asm" },
117
+ ...(Object.keys(binaryIncludePaths).length ? { binaryIncludePaths } : {}),
118
+ inesHeader: {
119
+ prgBanks,
120
+ ...(chrBanks ? { chrBanks } : {}),
121
+ mirroring,
122
+ ...(battery ? { battery: true } : {}),
123
+ },
124
+ },
125
+ verifiable: true,
126
+ notes: `NROM rebuild: inesHeader auto-emits the 16B header + wires chr.bin into the ` +
127
+ `CHARS segment; bank0.asm is the PRG. One-call build() rebuild, byte-identical.`,
128
+ };
129
+ }
130
+ // BANKED mapper (>0 or >2 PRG banks): emit COMPLETE working glue — the old
131
+ // recipe pointed at bank0 only with the flat NROM cfg, which cannot rebuild
132
+ // a banked ROM and cost a real RE session an hour of hand-written segments
133
+ // + cfg (0.27.0 feedback #1). Components:
134
+ // nes_header.asm — HEADER segment with the ORIGINAL 16 iNES bytes
135
+ // bankN_seg.asm — `.segment "PRGn"` wrapper that includes bankN.asm
136
+ // nes_chars.asm — CHARS segment .incbin of chr.bin (when CHR-ROM)
137
+ // nes_rebuild.cfg — HEADER + one 16KB area per bank (+ CHR), in file order
138
+ // and a rebuild.json build() call wired to ALL of it (linkerConfigPath keeps
139
+ // the cfg out of context).
140
+ const hdrBytes = Array.from(data.slice(0, 16))
141
+ .map((b) => "$" + b.toString(16).toUpperCase().padStart(2, "0")).join(", ");
142
+ blobs["nes_header.asm"] = enc(
143
+ `; iNES header — the ORIGINAL 16 bytes, for a byte-exact rebuild.\n` +
144
+ `.segment "HEADER"\n .byte ${hdrBytes}\n`
145
+ );
146
+ const nBanks = regions.length;
147
+ const memLines = [
148
+ ` HEADER: start = $0000, size = $0010, type = ro, file = %O, fill = yes;`,
149
+ ];
150
+ const segLines = [
151
+ ` HEADER: load = HEADER, type = ro;`,
152
+ ];
153
+ const sourcesPaths = { "nes_header.s": "nes_header.asm" };
154
+ const includePaths = {};
155
+ for (let i = 0; i < nBanks; i++) {
156
+ const reg = regions[i];
157
+ blobs[`bank${i}_seg.asm`] = enc(
158
+ `; PRG bank ${i} wrapper (${reg.label}) — auto-generated by disasm({target:'project'}).\n` +
159
+ `.segment "PRG${i}"\n.include "bank${i}.asm"\n`
160
+ );
161
+ memLines.push(` PRG${i}: start = $${reg.startAddress.toString(16).toUpperCase()}, size = $4000, type = ro, file = %O, fill = yes, fillval = $FF;`);
162
+ segLines.push(` PRG${i}: load = PRG${i}, type = ro;`);
163
+ sourcesPaths[`bank${i}_seg.s`] = `bank${i}_seg.asm`;
164
+ includePaths[`bank${i}.asm`] = `bank${i}.asm`;
165
+ }
166
+ if (chrBanks > 0) {
167
+ blobs["nes_chars.asm"] = enc(`; CHR-ROM data.\n.segment "CHARS"\n .incbin "chr.bin"\n`);
168
+ memLines.push(` CHR: start = $0000, size = $${(chrBanks * 0x2000).toString(16).toUpperCase()}, type = ro, file = %O, fill = yes;`);
169
+ segLines.push(` CHARS: load = CHR, type = ro;`);
170
+ sourcesPaths["nes_chars.s"] = "nes_chars.asm";
171
+ }
172
+ blobs["nes_rebuild.cfg"] = enc(
173
+ `# Banked NES rebuild .cfg — auto-generated by disasm({target:'project'}).\n` +
174
+ `# mapper ${mapper}, ${nBanks} x 16KB PRG banks (last bank at $C000)${chrBanks ? ", " + chrBanks + " x 8KB CHR" : ""}.\n` +
175
+ `# MEMORY order = file order: header, banks 0..${nBanks - 1}${chrBanks ? ", CHR" : ""}.\n` +
176
+ "MEMORY {\n" + memLines.join("\n") + "\n}\n" +
177
+ "SEGMENTS {\n" + segLines.join("\n") + "\n}\n"
178
+ );
107
179
  return {
108
180
  blobs,
109
181
  build: {
110
182
  output: "rom",
111
183
  platform: "nes",
112
- sourcesPaths: { "prg.asm": "bank0.asm" },
184
+ sourcesPaths,
185
+ includePaths,
113
186
  ...(Object.keys(binaryIncludePaths).length ? { binaryIncludePaths } : {}),
114
- inesHeader: {
115
- prgBanks,
116
- ...(chrBanks ? { chrBanks } : {}),
117
- ...(mapper ? { mapper } : {}),
118
- mirroring,
119
- ...(battery ? { battery: true } : {}),
120
- },
187
+ linkerConfigPath: "nes_rebuild.cfg",
121
188
  },
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.`,
189
+ verifiable: true,
190
+ notes:
191
+ `Banked NES (mapper ${mapper}, ${nBanks} x 16KB PRG): per-bank PRGn segment wrappers + ` +
192
+ `nes_header.asm (original 16 bytes) + nes_rebuild.cfg are all emitted and wired into ` +
193
+ `rebuild.json a one-call byte-exact build() rebuild (linkerConfigPath keeps the cfg ` +
194
+ `out of context). Switchable banks land at $8000, the fixed top bank at $C000.`,
130
195
  };
131
196
  },
132
197
 
@@ -172,11 +237,75 @@ const PLANNERS = {
172
237
  };
173
238
  },
174
239
 
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) {
240
+ // ───────────────────────────────────────────── Atari 7800 (.a78)
241
+ // build({platform:'atari7800'}) IS cc65/ca65 one-call build() rebuilds, both
242
+ // shapes:
243
+ // flat (≤48KB cart): planRegions keeps the 128-byte .a78 header inside
244
+ // rom.asm; a single flat cc65 build reproduces the whole .a78. PROVEN.
245
+ // SuperGame banked (>48KB cart): planRegions splits the header into its own
246
+ // data region + one 16KB bank region per bank (last fixed @ $C000). Emit
247
+ // NES-style glue: a78_header_seg.asm (HEADER segment, original 128 bytes),
248
+ // bankN_seg.asm wrappers, multi-bank .cfg via linkerConfigPath.
249
+ atari7800(data, regions) {
250
+ const bankRegions = regions.filter((r) => r.name.startsWith("bank"));
251
+ if (bankRegions.length) {
252
+ const hasA78 = regions.some((r) => r.name === "a78_header");
253
+ const blobs = {};
254
+ const memLines = [];
255
+ const segLines = [];
256
+ const sourcesPaths = {};
257
+ const includePaths = {};
258
+ if (hasA78) {
259
+ const hdrBytes = Array.from(data.slice(0, 128))
260
+ .map((b) => "$" + b.toString(16).toUpperCase().padStart(2, "0")).join(", ");
261
+ // 128 bytes won't fit one .byte line cleanly — wrap at 16/line.
262
+ const rows = [];
263
+ for (let i = 0; i < 128; i += 16) {
264
+ rows.push(" .byte " + hdrBytes.split(", ").slice(i, i + 16).join(", "));
265
+ }
266
+ blobs["a78_header_seg.asm"] = enc(
267
+ `; .a78 header — the ORIGINAL 128 bytes, for a byte-exact rebuild.\n` +
268
+ `.segment "HEADER"\n${rows.join("\n")}\n`
269
+ );
270
+ memLines.push(` HEADER: start = $0000, size = $0080, type = ro, file = %O, fill = yes;`);
271
+ segLines.push(` HEADER: load = HEADER, type = ro;`);
272
+ sourcesPaths["a78_header_seg.s"] = "a78_header_seg.asm";
273
+ }
274
+ for (let i = 0; i < bankRegions.length; i++) {
275
+ const reg = bankRegions[i];
276
+ blobs[`bank${i}_seg.asm`] = enc(
277
+ `; SuperGame bank ${i} wrapper (${reg.label}) — auto-generated by disasm({target:'project'}).\n` +
278
+ `.segment "BANK${i}"\n.include "bank${i}.asm"\n`
279
+ );
280
+ memLines.push(` BANK${i}: start = $${reg.startAddress.toString(16).toUpperCase()}, size = $4000, type = ro, file = %O, fill = yes, fillval = $FF;`);
281
+ segLines.push(` BANK${i}: load = BANK${i}, type = ro;`);
282
+ sourcesPaths[`bank${i}_seg.s`] = `bank${i}_seg.asm`;
283
+ includePaths[`bank${i}.asm`] = `bank${i}.asm`;
284
+ }
285
+ blobs["a78_rebuild.cfg"] = enc(
286
+ `# Banked Atari 7800 (.a78 SuperGame) rebuild .cfg — auto-generated by disasm({target:'project'}).\n` +
287
+ `# MEMORY order = file order: ${hasA78 ? "header, " : ""}banks 0..${bankRegions.length - 1} (last bank fixed at $C000).\n` +
288
+ "MEMORY {\n" + memLines.join("\n") + "\n}\n" +
289
+ "SEGMENTS {\n" + segLines.join("\n") + "\n}\n"
290
+ );
291
+ return {
292
+ blobs,
293
+ build: {
294
+ output: "rom",
295
+ platform: "atari7800",
296
+ language: "asm",
297
+ sourcesPaths,
298
+ includePaths,
299
+ linkerConfigPath: "a78_rebuild.cfg",
300
+ },
301
+ verifiable: true,
302
+ notes:
303
+ `Banked Atari 7800 SuperGame (${bankRegions.length} x 16KB banks${hasA78 ? " + 128-byte .a78 header" : ""}): ` +
304
+ `per-bank BANKn segment wrappers${hasA78 ? " + a78_header_seg.asm (original 128 bytes)" : ""} + ` +
305
+ `a78_rebuild.cfg are emitted and wired into rebuild.json — a one-call byte-exact build() ` +
306
+ `rebuild. Switchable banks land at $8000, the fixed top bank at $C000.`,
307
+ };
308
+ }
180
309
  const org = (0x10000 - data.length) & 0xffff;
181
310
  const cfg =
182
311
  "# Atari 7800 .a78 rebuild .cfg — auto-generated by disasm({target:'project'}).\n" +
@@ -206,12 +335,43 @@ const PLANNERS = {
206
335
  };
207
336
  },
208
337
 
209
- // ───────────────────────────────────────────── Atari 2600 (flat, dasm — no build() route)
338
+ // ───────────────────────────────────────────── Atari 2600 (dasm — no build() route)
210
339
  // The 2600 disasm emits ca65 syntax, but build({platform:'atari2600'}) is DASM,
211
340
  // 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.)
341
+ // rebuild IS byte-exact via the native ca65/ld65 chain flat carts via a flat
342
+ // fill .cfg; banked carts (F8/F6/F4 every 4KB bank pages into $F000) via
343
+ // per-bank BANKn segment wrappers + a multi-area .cfg. Recipe in notes.
214
344
  atari2600(data, regions) {
345
+ if (regions.length > 1) {
346
+ const blobs = {};
347
+ const memLines = [];
348
+ const segLines = [];
349
+ for (let i = 0; i < regions.length; i++) {
350
+ blobs[`bank${i}_seg.asm`] = enc(
351
+ `; 2600 banked-cart bank ${i} wrapper — auto-generated by disasm({target:'project'}).\n` +
352
+ `.segment "BANK${i}"\n.include "bank${i}.asm"\n`
353
+ );
354
+ memLines.push(` BANK${i}: start = $F000, size = $1000, type = ro, file = %O, fill = yes, fillval = $FF;`);
355
+ segLines.push(` BANK${i}: load = BANK${i}, type = ro;`);
356
+ }
357
+ blobs["atari2600_rebuild.cfg"] = enc(
358
+ "# Atari 2600 banked-cart rebuild .cfg — auto-generated by disasm({target:'project'}).\n" +
359
+ `# ${regions.length} x 4KB banks, each at $F000, concatenated in file order.\n` +
360
+ "MEMORY {\n" + memLines.join("\n") + "\n}\n" +
361
+ "SEGMENTS {\n" + segLines.join("\n") + "\n}\n"
362
+ );
363
+ return {
364
+ blobs,
365
+ build: null,
366
+ verifiable: true,
367
+ notes:
368
+ `Atari 2600 banked cart (${regions.length} x 4KB banks @ $F000 — F8/F6/F4-style). The disasm ` +
369
+ `emits ca65 syntax, but build({platform:'atari2600'}) is DASM — it can't reassemble ca65. ` +
370
+ `Rebuild with the bundled native ca65/ld65 (byte-identical):\n` +
371
+ ` ca65 bank0_seg.asm -o b0.o && … && ld65 -C atari2600_rebuild.cfg -o game.bin b0.o..b${regions.length - 1}.o\n` +
372
+ `(Wrappers + atari2600_rebuild.cfg are shipped as blobs.)`,
373
+ };
374
+ }
215
375
  const reg = regions[0];
216
376
  const cfg =
217
377
  "# Atari 2600 flat-cart rebuild .cfg — auto-generated by disasm({target:'project'}).\n" +
@@ -319,37 +479,76 @@ const PLANNERS = {
319
479
  };
320
480
  },
321
481
 
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.
482
+ // ───────────────────────────────────────────── PC Engine (HuCard, ca65 one-call)
483
+ // planRegions is now FAITHFUL (copier header split into its own region, NO
484
+ // trailing-pad trim), and build({platform:'pce', language:'asm'}) is cc65/ca65
485
+ // the same match that makes NES a one-call rebuild. Both shapes:
486
+ // flat (≤32KB): one CODE area at the top-of-space org.
487
+ // banked (>32KB): one 8KB PAGE area per page (page 0 @ $E000 with the
488
+ // vectors, pages 1+ @ $8000) via NES-style segment wrappers + .cfg.
489
+ // A copier header, when present, is re-emitted as a HEADER segment so the
490
+ // single output file is the complete original .pce.
327
491
  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";
492
+ const hasCopier = regions.some((r) => r.name === "copier_header");
493
+ const codeRegions = regions.filter((r) => r.kind !== "data");
494
+ const blobs = {};
495
+ const memLines = [];
496
+ const segLines = [];
497
+ const sourcesPaths = {};
498
+ const includePaths = {};
499
+ if (hasCopier) {
500
+ const rows = [];
501
+ for (let i = 0; i < 512; i += 16) {
502
+ rows.push(" .byte " + Array.from(data.slice(i, i + 16))
503
+ .map((b) => "$" + b.toString(16).toUpperCase().padStart(2, "0")).join(", "));
504
+ }
505
+ blobs["copier_header_seg.asm"] = enc(
506
+ `; 512-byte copier header — the ORIGINAL bytes, for a byte-exact rebuild.\n` +
507
+ `.segment "HEADER"\n${rows.join("\n")}\n`
508
+ );
509
+ memLines.push(` HEADER: start = $0000, size = $0200, type = ro, file = %O, fill = yes;`);
510
+ segLines.push(` HEADER: load = HEADER, type = ro;`);
511
+ sourcesPaths["copier_header_seg.s"] = "copier_header_seg.asm";
512
+ }
513
+ const banked = codeRegions.length > 1;
514
+ for (let i = 0; i < codeRegions.length; i++) {
515
+ const reg = codeRegions[i];
516
+ const segName = banked ? `PAGE${i}` : "CODE";
517
+ blobs[`${reg.name}_seg.asm`] = enc(
518
+ `; ${reg.label} wrapper — auto-generated by disasm({target:'project'}).\n` +
519
+ `.segment "${segName}"\n.include "${reg.file}"\n`
520
+ );
521
+ memLines.push(` ${segName}: start = $${reg.startAddress.toString(16).toUpperCase()}, size = $${reg.bytes.length.toString(16).toUpperCase()}, type = ro, file = %O, fill = yes, fillval = $FF;`);
522
+ segLines.push(` ${segName}: load = ${segName}, type = ro;`);
523
+ sourcesPaths[`${reg.name}_seg.s`] = `${reg.name}_seg.asm`;
524
+ includePaths[reg.file] = reg.file;
525
+ }
526
+ blobs["pce_rebuild.cfg"] = enc(
527
+ `# PC Engine HuCard rebuild .cfg — auto-generated by disasm({target:'project'}).\n` +
528
+ `# MEMORY order = file order: ${hasCopier ? "copier header, " : ""}` +
529
+ (banked ? `pages 0..${codeRegions.length - 1} (8KB each; page 0 holds the vectors).\n` : `the flat HuCard image.\n`) +
530
+ "MEMORY {\n" + memLines.join("\n") + "\n}\n" +
531
+ "SEGMENTS {\n" + segLines.join("\n") + "\n}\n"
532
+ );
339
533
  return {
340
- blobs: { "pce_rebuild.cfg": enc(cfg) },
341
- build: null,
342
- verifiable: false,
534
+ blobs,
535
+ build: {
536
+ output: "rom",
537
+ platform: "pce",
538
+ language: "asm",
539
+ sourcesPaths,
540
+ includePaths,
541
+ linkerConfigPath: "pce_rebuild.cfg",
542
+ },
543
+ verifiable: true,
343
544
  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.`,
545
+ `PC Engine HuCard (${banked ? codeRegions.length + " x 8KB pages" : "flat " + codeRegions[0].bytes.length + " B"}` +
546
+ `${hasCopier ? " + 512-byte copier header (re-emitted as a HEADER segment)" : ""}): ` +
547
+ `segment wrappers + pce_rebuild.cfg are emitted and wired into rebuild.json — a one-call ` +
548
+ `byte-exact build() rebuild (the pce asm toolchain IS cc65/ca65, same as NES). ` +
549
+ (banked ? `Page 0 lands at $E000 (reset MPR7 mapping — vectors live there); pages 1+ at $8000 ` +
550
+ `(an ASSUMED window the game's MPR writes decide at runtime; only branch-label cosmetics ` +
551
+ `depend on it, the bytes are exact regardless).` : ``),
353
552
  };
354
553
  },
355
554
 
@@ -364,8 +563,36 @@ const PLANNERS = {
364
563
  // + re-prepended. With the reassemble.js .org-floor fix, rom.asm now round-trips
365
564
  // (the region is at $4010), so the native recipe is byte-exact.
366
565
  msx(data, regions) {
367
- const reg = regions.find((r) => r.file === "rom.asm") ?? regions[0];
368
566
  const hasHeader = data.length >= 2 && data[0] === 0x41 && data[1] === 0x42; // "AB"
567
+ const bankRegions = regions.filter((r) => r.name.startsWith("bank"));
568
+ if (bankRegions.length) {
569
+ // megaROM (>32KB, ASCII16-style 16KB banks). Bank 0's region starts at
570
+ // $4010 (header split out); banks 1+ are full 16KB at $8000. Native
571
+ // per-bank recipe: assemble, extract at each org, concatenate after the
572
+ // header bytes.
573
+ const blobs = {};
574
+ if (hasHeader) blobs["msx_header.bin"] = data.slice(0, 16);
575
+ return {
576
+ blobs,
577
+ build: null,
578
+ verifiable: true,
579
+ notes:
580
+ `MSX megaROM (${bankRegions.length} x 16KB banks: bank 0 @ $4010 after the "AB" header` +
581
+ `${hasHeader ? " — shipped as msx_header.bin" : ""}, banks 1+ @ $8000, an ASSUMED ASCII16-style ` +
582
+ `window — only branch-label cosmetics depend on it, the bytes are exact regardless). ` +
583
+ `build({platform:'msx'}) is SDCC — it can't reassemble the GNU-\`as\` bankN.asm. Rebuild ` +
584
+ `natively (bundled z80 binutils, byte-identical), assembling each bank and concatenating:\n` +
585
+ bankRegions
586
+ .map(
587
+ (r) =>
588
+ ` z80-elf-as -march=z80 ${r.file} -o ${r.name}.o && z80-elf-objcopy -O binary ${r.name}.o ${r.name}.full && ` +
589
+ `dd if=${r.name}.full of=${r.name}.bin bs=1 skip=$((0x${r.startAddress.toString(16)})) count=${r.bytes.length}`
590
+ )
591
+ .join("\n") +
592
+ `\n cat ${hasHeader ? "msx_header.bin " : ""}${bankRegions.map((r) => r.name + ".bin").join(" ")} > rebuilt.rom`,
593
+ };
594
+ }
595
+ const reg = regions.find((r) => r.file === "rom.asm") ?? regions[0];
369
596
  const blobs = {};
370
597
  if (hasHeader) blobs["msx_header.bin"] = data.slice(0, 16);
371
598
  const bodyStart = hasHeader ? 16 : 0;
@@ -455,10 +682,33 @@ const PLANNERS = {
455
682
  * GNU-`as` rom.asm); native recipe in notes, byte-identical proven.
456
683
  */
457
684
  function planSegaFlat(platform, data, regions) {
685
+ const name = platform === "sms" ? "Master System" : "Game Gear";
686
+ if (regions.length > 1) {
687
+ // Sega-mapper banked cart: one full 16KB region per bank (no pad trim) —
688
+ // assemble each at its window org, extract, concatenate. Same per-bank
689
+ // native recipe shape as Game Boy.
690
+ return {
691
+ blobs: {},
692
+ build: null,
693
+ verifiable: true,
694
+ notes:
695
+ `${name} Sega-mapper banked cart (${regions.length} x 16KB banks: bank 0 @ $0000, bank 1 @ $4000, ` +
696
+ `banks 2+ @ $8000 — their slot-2 window). build({platform:'${platform}'}) is SDCC — it can't ` +
697
+ `reassemble the GNU-\`as\` bankN.asm. Rebuild natively (bundled z80 binutils, byte-identical), ` +
698
+ `assembling each bank and concatenating:\n` +
699
+ regions
700
+ .map(
701
+ (r) =>
702
+ ` z80-elf-as -march=z80 ${r.file} -o ${r.name}.o && z80-elf-objcopy -O binary ${r.name}.o ${r.name}.full && ` +
703
+ `dd if=${r.name}.full of=${r.name}.bin bs=1 skip=$((0x${r.startAddress.toString(16)})) count=${r.bytes.length}`
704
+ )
705
+ .join("\n") +
706
+ `\n cat ${regions.map((r) => r.name + ".bin").join(" ")} > rebuilt.${platform === "sms" ? "sms" : "gg"}`,
707
+ };
708
+ }
458
709
  const reg = regions.find((r) => r.file === "rom.asm") ?? regions[0];
459
710
  const trimmedLen = reg ? reg.bytes.length : data.length;
460
711
  const pad = trailingPad(data, trimmedLen);
461
- const name = platform === "sms" ? "Master System" : "Game Gear";
462
712
  return {
463
713
  blobs: {},
464
714
  build: null,