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.
- package/AGENTS.md +60 -12
- package/CHANGELOG.md +258 -0
- package/examples/README.md +2 -0
- package/examples/atari2600/templates/platformer.asm +460 -0
- package/examples/atari2600/templates/racing.asm +463 -0
- package/examples/atari2600/templates/shmup.asm +386 -0
- package/examples/atari2600/templates/sports.asm +362 -0
- package/examples/atari7800/templates/default.c +49 -5
- package/examples/atari7800/templates/platformer.c +43 -4
- package/examples/atari7800/templates/puzzle.c +39 -4
- package/examples/atari7800/templates/racing.c +39 -4
- package/examples/atari7800/templates/shmup.c +40 -2
- package/examples/atari7800/templates/sports.c +36 -5
- package/examples/c64/templates/platformer.c +19 -5
- package/examples/c64/templates/puzzle.c +32 -2
- package/examples/c64/templates/shmup.c +28 -2
- package/examples/c64/templates/sports.c +30 -2
- package/examples/gb/templates/default.c +110 -16
- package/examples/gb/templates/platformer.c +25 -4
- package/examples/gb/templates/puzzle.c +32 -2
- package/examples/gb/templates/racing.c +72 -8
- package/examples/gb/templates/shmup.c +38 -1
- package/examples/gb/templates/sports.c +48 -1
- package/examples/gba/templates/gba_hello.c +29 -11
- package/examples/gba/templates/puzzle.c +15 -3
- package/examples/gba/templates/racing.c +65 -3
- package/examples/gba/templates/shmup.c +41 -4
- package/examples/gba/templates/sports.c +36 -2
- package/examples/gba/templates/tonc_hello.c +41 -5
- package/examples/gbc/templates/default.c +103 -26
- package/examples/gbc/templates/platformer.c +25 -4
- package/examples/gbc/templates/puzzle.c +32 -2
- package/examples/gbc/templates/racing.c +85 -19
- package/examples/gbc/templates/shmup.c +34 -1
- package/examples/gbc/templates/sports.c +45 -1
- package/examples/genesis/templates/puzzle.c +37 -3
- package/examples/genesis/templates/racing.c +44 -11
- package/examples/genesis/templates/sgdk_hello.c +34 -1
- package/examples/genesis/templates/shmup.c +31 -1
- package/examples/gg/templates/default.c +56 -18
- package/examples/gg/templates/platformer.c +18 -12
- package/examples/gg/templates/puzzle.c +38 -7
- package/examples/gg/templates/racing.c +51 -5
- package/examples/gg/templates/shmup.c +47 -3
- package/examples/gg/templates/sports.c +46 -3
- package/examples/lynx/templates/default.c +39 -8
- package/examples/lynx/templates/puzzle.c +28 -1
- package/examples/lynx/templates/racing.c +34 -7
- package/examples/lynx/templates/shmup.c +42 -3
- package/examples/lynx/templates/sports.c +29 -2
- package/examples/msx/platformer/main.c +213 -0
- package/examples/msx/puzzle/main.c +250 -0
- package/examples/msx/racing/main.c +249 -0
- package/examples/msx/shmup/main.c +288 -0
- package/examples/msx/sports/main.c +182 -0
- package/examples/nes/templates/default.c +67 -19
- package/examples/nes/templates/platformer.c +65 -6
- package/examples/nes/templates/puzzle.c +67 -6
- package/examples/nes/templates/racing.c +45 -13
- package/examples/nes/templates/shmup.c +51 -2
- package/examples/nes/templates/sports.c +51 -6
- package/examples/pce/platformer/main.c +283 -0
- package/examples/pce/puzzle/main.c +304 -0
- package/examples/pce/racing/main.c +304 -0
- package/examples/pce/shmup/main.c +346 -0
- package/examples/pce/sports/main.c +254 -0
- package/examples/sms/main.c +35 -6
- package/examples/sms/templates/puzzle.c +34 -5
- package/examples/sms/templates/racing.c +39 -2
- package/examples/sms/templates/shmup.c +41 -2
- package/examples/sms/templates/sports.c +43 -2
- package/examples/snes/templates/default.c +50 -28
- package/examples/snes/templates/platformer-data.asm +22 -0
- package/examples/snes/templates/platformer.c +16 -1
- package/examples/snes/templates/puzzle-data.asm +22 -0
- package/examples/snes/templates/puzzle.c +17 -1
- package/examples/snes/templates/racing-data.asm +22 -0
- package/examples/snes/templates/racing.c +17 -1
- package/examples/snes/templates/shmup-data.asm +22 -0
- package/examples/snes/templates/shmup.c +20 -1
- package/examples/snes/templates/sports-data.asm +22 -0
- package/examples/snes/templates/sports.c +16 -1
- package/package.json +1 -1
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +122 -1
- package/src/host/callbacks.js +9 -1
- package/src/host/types.js +15 -8
- package/src/http/tool-registry.js +26 -1
- package/src/mcp/tools/cart-parts.js +75 -3
- package/src/mcp/tools/disasm-rebuild.js +507 -0
- package/src/mcp/tools/disasm.js +95 -6
- package/src/mcp/tools/frame.js +168 -3
- package/src/mcp/tools/lifecycle.js +4 -2
- package/src/mcp/tools/project.js +54 -9
- package/src/mcp/tools/state.js +201 -14
- package/src/mcp/tools/toolchain.js +76 -3
- package/src/mcp/tools/watch-memory.js +125 -14
- package/src/platforms/c64/MENTAL_MODEL.md +45 -1
- package/src/platforms/c64/d64.js +281 -0
- package/src/platforms/gb/MENTAL_MODEL.md +10 -0
- package/src/platforms/msx/MENTAL_MODEL.md +10 -6
- package/src/platforms/nes/MENTAL_MODEL.md +63 -2
- package/src/platforms/pce/MENTAL_MODEL.md +9 -4
- package/src/platforms/pce/lib/c/pce_video.c +1 -1
- package/src/rom-id/identifier.js +15 -0
- package/src/toolchains/cc65/ines.js +145 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
- 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
|
+
}
|
package/src/mcp/tools/disasm.js
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
1141
|
-
|
|
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(
|
|
1234
|
+
"\t.byte " + slice.map(hex).join(",") +
|
|
1146
1235
|
`\t; ${(startAddress + i).toString(16).toUpperCase().padStart(6, "0")}`
|
|
1147
1236
|
);
|
|
1148
1237
|
}
|