romdevtools 0.16.0 → 0.21.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 (110) hide show
  1. package/AGENTS.md +60 -12
  2. package/CHANGELOG.md +258 -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/platformer.c +43 -4
  10. package/examples/atari7800/templates/puzzle.c +39 -4
  11. package/examples/atari7800/templates/racing.c +39 -4
  12. package/examples/atari7800/templates/shmup.c +40 -2
  13. package/examples/atari7800/templates/sports.c +36 -5
  14. package/examples/c64/templates/platformer.c +19 -5
  15. package/examples/c64/templates/puzzle.c +32 -2
  16. package/examples/c64/templates/shmup.c +28 -2
  17. package/examples/c64/templates/sports.c +30 -2
  18. package/examples/gb/templates/default.c +110 -16
  19. package/examples/gb/templates/platformer.c +25 -4
  20. package/examples/gb/templates/puzzle.c +32 -2
  21. package/examples/gb/templates/racing.c +72 -8
  22. package/examples/gb/templates/shmup.c +38 -1
  23. package/examples/gb/templates/sports.c +48 -1
  24. package/examples/gba/templates/gba_hello.c +29 -11
  25. package/examples/gba/templates/puzzle.c +15 -3
  26. package/examples/gba/templates/racing.c +65 -3
  27. package/examples/gba/templates/shmup.c +41 -4
  28. package/examples/gba/templates/sports.c +36 -2
  29. package/examples/gba/templates/tonc_hello.c +41 -5
  30. package/examples/gbc/templates/default.c +103 -26
  31. package/examples/gbc/templates/platformer.c +25 -4
  32. package/examples/gbc/templates/puzzle.c +32 -2
  33. package/examples/gbc/templates/racing.c +85 -19
  34. package/examples/gbc/templates/shmup.c +34 -1
  35. package/examples/gbc/templates/sports.c +45 -1
  36. package/examples/genesis/templates/puzzle.c +37 -3
  37. package/examples/genesis/templates/racing.c +44 -11
  38. package/examples/genesis/templates/sgdk_hello.c +34 -1
  39. package/examples/genesis/templates/shmup.c +31 -1
  40. package/examples/gg/templates/default.c +56 -18
  41. package/examples/gg/templates/platformer.c +18 -12
  42. package/examples/gg/templates/puzzle.c +38 -7
  43. package/examples/gg/templates/racing.c +51 -5
  44. package/examples/gg/templates/shmup.c +47 -3
  45. package/examples/gg/templates/sports.c +46 -3
  46. package/examples/lynx/templates/default.c +39 -8
  47. package/examples/lynx/templates/puzzle.c +28 -1
  48. package/examples/lynx/templates/racing.c +34 -7
  49. package/examples/lynx/templates/shmup.c +42 -3
  50. package/examples/lynx/templates/sports.c +29 -2
  51. package/examples/msx/platformer/main.c +213 -0
  52. package/examples/msx/puzzle/main.c +250 -0
  53. package/examples/msx/racing/main.c +249 -0
  54. package/examples/msx/shmup/main.c +288 -0
  55. package/examples/msx/sports/main.c +182 -0
  56. package/examples/nes/templates/default.c +67 -19
  57. package/examples/nes/templates/platformer.c +65 -6
  58. package/examples/nes/templates/puzzle.c +67 -6
  59. package/examples/nes/templates/racing.c +45 -13
  60. package/examples/nes/templates/shmup.c +51 -2
  61. package/examples/nes/templates/sports.c +51 -6
  62. package/examples/pce/platformer/main.c +283 -0
  63. package/examples/pce/puzzle/main.c +304 -0
  64. package/examples/pce/racing/main.c +304 -0
  65. package/examples/pce/shmup/main.c +346 -0
  66. package/examples/pce/sports/main.c +254 -0
  67. package/examples/sms/main.c +35 -6
  68. package/examples/sms/templates/puzzle.c +34 -5
  69. package/examples/sms/templates/racing.c +39 -2
  70. package/examples/sms/templates/shmup.c +41 -2
  71. package/examples/sms/templates/sports.c +43 -2
  72. package/examples/snes/templates/default.c +50 -28
  73. package/examples/snes/templates/platformer-data.asm +22 -0
  74. package/examples/snes/templates/platformer.c +16 -1
  75. package/examples/snes/templates/puzzle-data.asm +22 -0
  76. package/examples/snes/templates/puzzle.c +17 -1
  77. package/examples/snes/templates/racing-data.asm +22 -0
  78. package/examples/snes/templates/racing.c +17 -1
  79. package/examples/snes/templates/shmup-data.asm +22 -0
  80. package/examples/snes/templates/shmup.c +20 -1
  81. package/examples/snes/templates/sports-data.asm +22 -0
  82. package/examples/snes/templates/sports.c +16 -1
  83. package/package.json +1 -1
  84. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  85. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  86. package/src/host/LibretroHost.js +122 -1
  87. package/src/host/callbacks.js +9 -1
  88. package/src/host/types.js +15 -8
  89. package/src/http/tool-registry.js +26 -1
  90. package/src/mcp/tools/cart-parts.js +75 -3
  91. package/src/mcp/tools/disasm-rebuild.js +507 -0
  92. package/src/mcp/tools/disasm.js +95 -6
  93. package/src/mcp/tools/frame.js +168 -3
  94. package/src/mcp/tools/lifecycle.js +4 -2
  95. package/src/mcp/tools/project.js +54 -9
  96. package/src/mcp/tools/state.js +201 -14
  97. package/src/mcp/tools/toolchain.js +76 -3
  98. package/src/mcp/tools/watch-memory.js +125 -14
  99. package/src/platforms/c64/MENTAL_MODEL.md +45 -1
  100. package/src/platforms/c64/d64.js +281 -0
  101. package/src/platforms/gb/MENTAL_MODEL.md +10 -0
  102. package/src/platforms/msx/MENTAL_MODEL.md +10 -6
  103. package/src/platforms/nes/MENTAL_MODEL.md +63 -2
  104. package/src/platforms/pce/MENTAL_MODEL.md +9 -4
  105. package/src/platforms/pce/lib/c/pce_video.c +1 -1
  106. package/src/rom-id/identifier.js +15 -0
  107. package/src/toolchains/cc65/ines.js +145 -0
  108. package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
  109. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
  110. package/src/toolchains/common/reassemble.js +10 -2
@@ -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
+ }
@@ -1013,7 +1013,7 @@ async function disassembleRomCore(args) {
1013
1013
  }
1014
1014
 
1015
1015
  async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
1016
- const { reassembleForPlatform } = await import("../../toolchains/common/reassemble.js");
1016
+ const { reassembleForPlatform, CPU_FAMILY } = await import("../../toolchains/common/reassemble.js");
1017
1017
  const data = new Uint8Array(await readFile(romPath));
1018
1018
  const resolved = platform ?? sniffPlatformFromPath(romPath);
1019
1019
  if (!resolved) throw new Error(`disassembleProject: could not detect platform from '${romPath}'. Pass platform explicitly.`);
@@ -1029,7 +1029,7 @@ async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
1029
1029
  // Known-data regions (e.g. the GBA cartridge header) are emitted as a
1030
1030
  // clean `.byte` dump — byte-exact by construction, NOT a failed disasm.
1031
1031
  const r = reg.kind === "data"
1032
- ? { ok: true, readablePercent: 0, source: dataRegionSource(reg.bytes, reg.startAddress), note: "data region (not code)" }
1032
+ ? { ok: true, readablePercent: 0, source: dataRegionSource(reg.bytes, reg.startAddress, CPU_FAMILY[resolved]), note: "data region (not code)" }
1033
1033
  : await reassembleForPlatform({ platform: resolved, bytes: reg.bytes, startAddress: reg.startAddress });
1034
1034
  const header = `; ${reg.label} — ${reg.bytes.length} bytes @ $${reg.startAddress.toString(16).toUpperCase()} ` +
1035
1035
  `(file 0x${reg.fileOffset.toString(16).toUpperCase()}), ${resolved}\n` +
@@ -1046,6 +1046,32 @@ async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
1046
1046
 
1047
1047
  const allOk = out.every((r) => r.roundTripOk);
1048
1048
  const avgReadable = Math.round(out.reduce((s, r) => s + r.readablePercent, 0) / out.length);
1049
+
1050
+ // Make the project TURNKEY: write the rebuild glue (data blobs, the exact
1051
+ // build() call, and human-readable instructions) so it rebuilds without
1052
+ // hand-wiring a header/CHR-blob/linker .cfg. See disasm-rebuild.js.
1053
+ const { planRebuild } = await import("./disasm-rebuild.js");
1054
+ const plan = planRebuild(resolved, data, regions);
1055
+ const writtenBlobs = [];
1056
+ for (const [name, bytes] of Object.entries(plan.blobs)) {
1057
+ await writeFile(nodePath.join(outputDir, name), bytes);
1058
+ writtenBlobs.push({ file: name, bytes: bytes.length });
1059
+ }
1060
+ // Absolutize the bare filenames in the build call so the recipe is
1061
+ // copy-pasteable as-is.
1062
+ const absBuild = plan.build ? absolutizeBuild(plan.build, outputDir) : null;
1063
+ if (absBuild) {
1064
+ await writeFile(
1065
+ nodePath.join(outputDir, "rebuild.json"),
1066
+ JSON.stringify(absBuild, null, 2) + "\n"
1067
+ );
1068
+ }
1069
+ const buildMd = renderBuildMd({
1070
+ platform: resolved, romPath, regions: out, blobs: writtenBlobs,
1071
+ build: absBuild, verifiable: plan.verifiable, notes: plan.notes, allOk,
1072
+ });
1073
+ await writeFile(nodePath.join(outputDir, "BUILD.md"), buildMd);
1074
+
1049
1075
  return jsonContent({
1050
1076
  ok: allOk,
1051
1077
  path: romPath,
@@ -1053,12 +1079,64 @@ async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
1053
1079
  regions: out,
1054
1080
  roundTrip: { regions: out.length, allByteExact: allOk, failed: out.filter((r) => !r.roundTripOk).map((r) => r.region) },
1055
1081
  readablePercentAvg: avgReadable,
1082
+ rebuild: {
1083
+ blobs: writtenBlobs,
1084
+ buildCall: absBuild, // the exact build({...}) args to reproduce the ROM
1085
+ verifiable: plan.verifiable, // true = expected byte-identical via that call
1086
+ buildDoc: "BUILD.md",
1087
+ notes: plan.notes,
1088
+ },
1056
1089
  note: allOk
1057
- ? `All ${out.length} region(s) round-trip BYTE-EXACT (avg ${avgReadable}% disassembled as instructions, the rest as .byte data). Edit the .asm files and rebuild.`
1090
+ ? `All ${out.length} region(s) round-trip BYTE-EXACT (avg ${avgReadable}% disassembled as instructions, the rest as .byte data). ` +
1091
+ (absBuild
1092
+ ? `Rebuild it with the build() call in rebuild.json / BUILD.md` +
1093
+ (plan.verifiable ? " — expected byte-identical." : " (see notes — may need linker tweaks).")
1094
+ : `See BUILD.md for how to rebuild.`)
1058
1095
  : `Some regions did NOT round-trip byte-exact — see regions[].note.`,
1059
1096
  });
1060
1097
  }
1061
1098
 
1099
+ /** Rewrite a planRebuild build()'s bare *Paths filenames to absolute paths. */
1100
+ function absolutizeBuild(build, outputDir) {
1101
+ const out = { ...build };
1102
+ for (const key of ["sourcesPaths", "binaryIncludePaths", "includePaths"]) {
1103
+ if (out[key]) {
1104
+ const m = {};
1105
+ for (const [virt, file] of Object.entries(out[key])) m[virt] = nodePath.join(outputDir, file);
1106
+ out[key] = m;
1107
+ }
1108
+ }
1109
+ return out;
1110
+ }
1111
+
1112
+ /** Human + agent readable rebuild instructions for a disassembled project. */
1113
+ function renderBuildMd({ platform, romPath, regions, blobs, build, verifiable, notes, allOk }) {
1114
+ const lines = [];
1115
+ lines.push(`# Rebuilding this ${platform} project`, "");
1116
+ lines.push(`Disassembled from \`${nodePath.basename(romPath)}\` by \`disasm({target:'project'})\`.`, "");
1117
+ lines.push("## Files", "");
1118
+ for (const r of regions) {
1119
+ lines.push(`- \`${r.file}\` — ${r.region}${r.kind === "data" ? " (data)" : ""}, byte-exact${r.roundTripOk === false ? " ⚠ round-trip FAILED" : ""}.`);
1120
+ }
1121
+ for (const b of blobs) lines.push(`- \`${b.file}\` — ${b.bytes} bytes of binary data (extracted from the ROM; do not hand-edit).`);
1122
+ lines.push("- `rebuild.json` — the exact `build()` args below, with absolute paths.", "");
1123
+ if (build) {
1124
+ lines.push("## Rebuild", "");
1125
+ if (verifiable) {
1126
+ lines.push("This rebuilds **byte-identical** to the source ROM. Call:", "");
1127
+ } else {
1128
+ lines.push("Rebuild call (see Notes — may need linker adjustments for an exact match):", "");
1129
+ }
1130
+ lines.push("```json", JSON.stringify(build, null, 2), "```", "");
1131
+ lines.push("Pass these as the arguments to the `build` tool. The same JSON is in `rebuild.json`.", "");
1132
+ } else {
1133
+ lines.push("## Rebuild", "", "No automatic rebuild recipe for this platform yet. " + notes, "");
1134
+ }
1135
+ if (notes) lines.push("## Notes", "", notes, "");
1136
+ if (!allOk) lines.push("> ⚠ Some regions did not round-trip byte-exact — edit those `.asm` files before rebuilding.", "");
1137
+ return lines.join("\n") + "\n";
1138
+ }
1139
+
1062
1140
  export function registerDisasmTools(server, z) {
1063
1141
  server.tool(
1064
1142
  "disasm",
@@ -1137,12 +1215,23 @@ function trimTrailingPad(bytes) {
1137
1215
 
1138
1216
  /** Emit a known-data region as a clean, byte-exact `.byte` dump (GAS/cc65 both
1139
1217
  * accept `.byte` with `.org`). 16 bytes per line, with the address in a comment. */
1140
- function dataRegionSource(bytes, startAddress) {
1141
- const rows = [`\t.org $${startAddress.toString(16).toUpperCase()}`];
1218
+ // Emit a byte-exact `.byte` dump for a known-DATA region (e.g. a cartridge
1219
+ // header). The syntax must match the platform's reassembler: ca65/vasm
1220
+ // (6502/65816) take `$`-prefixed hex and a bare `.org $...`; GNU `as`
1221
+ // (z80/sm83/m68k/arm) take `0x` hex and reject a `$` operand (`$2E` reads as an
1222
+ // undefined symbol → the link fails). `family` comes from CPU_FAMILY; default to
1223
+ // the ca65 form for the 6502 platforms that have used this path historically.
1224
+ function dataRegionSource(bytes, startAddress, family = "6502") {
1225
+ const gnu = family === "z80" || family === "sm83" || family === "m68k" || family === "arm";
1226
+ const hex = (b) => (gnu ? "0x" : "$") + b.toString(16).padStart(2, "0").toUpperCase();
1227
+ const org = gnu
1228
+ ? `\t.org 0x${startAddress.toString(16).toUpperCase()}`
1229
+ : `\t.org $${startAddress.toString(16).toUpperCase()}`;
1230
+ const rows = [org];
1142
1231
  for (let i = 0; i < bytes.length; i += 16) {
1143
1232
  const slice = Array.from(bytes.slice(i, i + 16));
1144
1233
  rows.push(
1145
- "\t.byte " + slice.map((b) => "$" + b.toString(16).padStart(2, "0").toUpperCase()).join(",") +
1234
+ "\t.byte " + slice.map(hex).join(",") +
1146
1235
  `\t; ${(startAddress + i).toString(16).toUpperCase().padStart(6, "0")}`
1147
1236
  );
1148
1237
  }