romdevtools 0.87.2 → 0.88.1

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 CHANGED
@@ -105,7 +105,9 @@ questions, and running both early is normal.** A good default:
105
105
  you may have just skipped a long memory hunt. If it returns nothing useful (no
106
106
  match, or no cheat for *your* target), that's fine — move on, no time lost.
107
107
  3. **Disassemble / trace** whenever the hack is about CODE or about data the
108
- cheats don't cover: `disasm({target:'project'})` for a rebuildable project,
108
+ cheats don't cover: `disasm({target:'project'})` for a rebuildable project
109
+ (then edit a region `.asm` and `build({output:'reassemble', platform, path})` to
110
+ rebuild a byte-identical ROM in one call — the "cmp before commit" gate),
109
111
  `disasm({target:'references'})` for "what touches this address", `breakpoint({on:'write'})` for the exact
110
112
  instruction that wrote a byte, `watch({on:'mem'})`/`breakpoint({on:'write',precision:'sampled'})` to find an address
111
113
  empirically. For STRUCTURE — "what are the functions, how do they call each
@@ -347,10 +349,13 @@ Different platforms have different levels of MCP-exposed debugging — different
347
349
  > (VDP DMA) — elsewhere use `breakpoint({on:'write'})`/`watch({on:'range'})`. All other RE tools above
348
350
  > work on every platform that has the register-write/watch core hooks (the 15 classic platforms).
349
351
  > `disasm({target:'rom'})` + `disasm({target:'references'})` cover **all 18** (incl. the 3D consoles' MIPS/SH-4), and `disasm({target:'project'})` byte-exact reassembly the **15 classic platforms** (incl. GameTank) — every
350
- > CPU family disassembles through a native binutils `objdump` (WASM), and
351
- > `disasm({target:'project'})` reassembles byte-exact through the matching native
352
- > `as`/`ld`/`objcopy`. The per-platform notes below cover the platform-SPECIFIC
353
- > inspectors + chips (PC Engine + MSX: generic shapes only so far).
352
+ > CPU family disassembles through a native binutils `objdump` (WASM). To REBUILD,
353
+ > **`build({output:'reassemble', platform, path})` turns a `disasm({target:'project'})`
354
+ > dir back into a byte-identical ROM in ONE call on all 15** — it assembles each
355
+ > region `.asm` with the native assembler and splices the results into the original's
356
+ > header/gaps/pad. (Edit a region first for an intentional change; a same-length edit
357
+ > rebuilds a modified ROM, a length-changing edit is refused.) The per-platform notes
358
+ > below cover the platform-SPECIFIC inspectors + chips (PC Engine + MSX: generic shapes only so far).
354
359
 
355
360
  The deep per-platform inspectors + the exact memory-region names, core quirks, and any platform-specific traps live in **each platform's `MENTAL_MODEL.md`** (read via `platform({op:'doc', platform, name:'mental_model'})`) — read it for the system you're on. Symptom → doc:
356
361
  - **NES** — blank/black screen, wrong sprites/colors, or need live PPU regs / CIRAM-attribute / MMC1-banked CHR state.
@@ -365,7 +370,7 @@ The deep per-platform inspectors + the exact memory-region names, core quirks, a
365
370
  - **Atari Lynx** — `sprites({op:'inspect'})` returns an SCB list head (no fixed OAM), or you need the Mikey palette/audio, 65C02 regs, or the `lynx_hw_regs` $FC00-$FDFF window.
366
371
  - **MSX** — VDP/PSG inspection or AY8910 `audioDebug`. (ColecoVision is bring-up-only: standard `system_ram`/`save_ram`/`video_ram`, no custom inspectors — extend by patching its core per the snes9x/gpgx pattern.)
367
372
  - **PC Engine** — generic shapes + the core's native regions only so far (no custom-inspector treatment yet).
368
- - **GameTank** — Clyde Shaffer's open-hardware W65C02S console (cc65 toolchain, ext `.gtr`). Full classic-style Tier-1: `cpu({op:'read'})` (65c02 regsnap), `audioDebug({op:'inspect', chip:'acp'})` (the ACP audio coprocessor), `breakpoint({on:'write'|'read'|'pc'})` + watchdog + coverage, `disasm` (`bytes`/`rom`/`references` + the 6502 Rizin/Ghidra cfg/xrefs/functions/decompile path), `cart({op:'extract'|'wrap'})`, and the 6502 re-inject path (`romPatch`) same as NES/C64/Lynx. **No `sprites({op:'inspect'})`/`background`** — it's a blitter framebuffer with no sprite OAM / no tilemap (like the Dreamcast). No genre example games yet (brand-new platform).
373
+ - **GameTank** — Clyde Shaffer's open-hardware W65C02S console (cc65 toolchain, ext `.gtr`). Full classic-style Tier-1: `cpu({op:'read'})` (65c02 regsnap), `audioDebug({op:'inspect', chip:'acp'})` (the ACP audio coprocessor), `breakpoint({on:'write'|'read'|'pc'})` + watchdog + coverage, `disasm` (`bytes`/`rom`/`references`/`project` + the 6502 Rizin/Ghidra cfg/xrefs/functions/decompile path — a 32KB flat cart @ `$8000`, one region, rebuilt byte-identical via `build({output:'reassemble', platform:'gametank', path})`), `cart({op:'extract'|'wrap'})`, and the 6502 re-inject path (`romPatch`) same as NES/C64/Lynx. **No `sprites({op:'inspect'})`/`background`** — it's a blitter framebuffer with no sprite OAM / no tilemap (like the Dreamcast). No genre example games yet (brand-new platform).
369
374
 
370
375
  Starter snippets per platform live under `src/platforms/<platform>/lib/`. Discover via `examples({op:'snippets', platform})` (default `mode:'list'`), fetch one via `examples({op:'snippets', platform, mode:'get', snippetName})`. SNES + NES + Genesis + SMS + Game Boy + Atari 2600 + Atari 7800 have substantial snippet libraries; others are minimal.
371
376
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ All notable changes to `romdevtools`. Dates are release dates.
4
4
  (Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
5
5
  the `romdev-mcp` bin is kept as an alias.)
6
6
 
7
+ ## 0.88.0 — 2026-07-12
8
+
9
+ - **`build({output:'reassemble'})` — the UNIFORM byte-exact ROUND-TRIP.** `disasm({target:'project'})`
10
+ splits a ROM into byte-exact region `.asm` files, but a one-call rebuild only existed for the
11
+ cc65-native subset (NES/C64/Atari7800/Lynx/PCE via `rebuild.json`). Every other platform documented
12
+ a "run these native tools yourself" recipe — so an agent (or one of Jay's disassemble/annotate
13
+ skills) following the "rebuild byte-identical + `cmp` before every commit" gate would stall on
14
+ SNES/Genesis/GB/etc. This adds a single rebuild call that works on **every** classic platform.
15
+ - `disasm({target:'project'})` now also writes `reassemble.json` (a `{platform, romTemplate,
16
+ romLength, regions:[{file, startAddress, fileOffset, byteLength}]}` manifest) + a verbatim
17
+ `original.rom` copy, for all platforms. `BUILD.md` leads with the one-call path.
18
+ - `build({output:'reassemble', platform, path})` reads the manifest, ASSEMBLES each (possibly
19
+ hand-edited) region `.asm` with the platform's native assembler — ca65 for 6502/65816, GNU-as for
20
+ m68k/arm/z80/sm83 — and SPLICES each result into a copy of `original.rom` at its file offset, so
21
+ the cartridge header, inter-region gaps, and trailing pad return byte-for-byte. Returns
22
+ `{ok, byteExact, outputPath, regions:[{file, byteExact, …}]}`.
23
+ - **The annotation gate is now real on every platform:** a same-length region edit rebuilds a
24
+ modified-but-valid ROM (`byteExact:false`, exactly the changed bytes differ); a length-changing
25
+ edit is REFUSED with a precise per-region error (splicing can't shift every later byte). Verified
26
+ byte-identical end-to-end on all six CPU families (6502, 65816, sm83, z80, m68k, arm).
27
+ - New `assembleRegionText()` in `toolchains/common/reassemble.js` (the assemble-only half — no
28
+ disassemble/heal). It also fixes a latent silent-truncation bug: a length-growing edit on the GNU
29
+ paths was truncated back to the region size by the objcopy slice; it now reports `producedLength`
30
+ so the caller catches it.
31
+ - **GameTank joins the rebuildable-project platforms (14 → 15 classic).** `disasm({target:'project'})`
32
+ now handles GameTank's flat W65C02S EEPROM cart (32KB mapped at `$8000`, vectors at `$FFFA`; rides the
33
+ 6502-family da65/ca65 path), so `build({output:'reassemble', platform:'gametank'})` rebuilds it
34
+ byte-identical like every other classic system. `.gtr` now sniffs to `gametank` (no explicit platform
35
+ needed) and has a `PLATFORM_VIRTUAL_EXT` entry. (The docs already claimed GameTank was covered — this
36
+ makes it true.)
37
+
38
+ ## 0.87.2 — 2026-07-12
39
+
40
+ - Pin the reproducible `cc65` toolchain (`romdev-toolchain-cc65@0.1.3`) so cc65 builds are
41
+ deterministic across environments.
42
+ - Fix the m68k (Genesis) byte-exact reassembly: the ELF `.text` word-aligned, so `objcopy -O binary`
43
+ emitted 2 leading pad bytes and the region shifted by 2. `SUBALIGN(1)` on the link script pins the
44
+ section at its origin.
45
+
46
+ ## 0.87.1 — 2026-07-08
47
+
48
+ - **PICO-8: input fix + 5 finished genre example games.** The `_clearInputOnResume` patch delivers
49
+ the first button press after a cart load (without it no cart leaves its title screen). Ships five
50
+ complete example carts (shmup/platformer/puzzle/sports/racing) with original art + looping
51
+ public-domain music.
52
+
53
+ ## 0.87.0 — 2026-07-08
54
+
55
+ - **PICO-8 platform via FAKE-08.** The MIT-licensed FAKE-08 player compiled to a WASM libretro core
56
+ (`romdev-core-fake08`, no BIOS). Full tool surface: `build({platform:'pico8'})` packages a `.p8`,
57
+ `disasm({target:'source'})` returns the cart's Lua, `memory({region:'system_ram'})` reads the flat
58
+ 64KB PICO-8 map, and load/run/see/drive work. Capability tier `fantasy` (excluded from the
59
+ CPU-emulator RE contracts — it's a Lua VM, not machine code).
60
+
7
61
  ## 0.86.0 — 2026-07-01
8
62
 
9
63
  - **GameTank Game Genie — a brand-new cheat-code format.** Nobody had made GameTank cheat codes
package/README.md CHANGED
@@ -10,7 +10,7 @@ npx romdevtools
10
10
 
11
11
  - **Build** — bundled per-platform toolchains (cc65, SDCC, RGBDS, asar, vasm, SGDK, PVSnesLib, libtonc, …) as WASM. Write source, compile, get a real ROM.
12
12
  - **Run + see + drive** — load the ROM into an emulated console (libretro cores as WASM), step frames, screenshot, script controller input. The 2D consoles render in software; the 3D consoles (N64 via glide64, PlayStation via Beetle PSX HW, Dreamcast via Flycast) render on the **real GPU** through [`native-gles`](https://github.com/monteslu/native-gles) — headless OpenGL/EGL, no browser. PlayStation ships [OpenBIOS](https://github.com/grumpycoders/pcsx-redux) (MIT) embedded, so there's no proprietary firmware to supply. **Dreamcast is EXPERIMENTAL** — it boots and renders, but the WASM build runs the SH-4 (+ AICA) as interpreters, so it's currently too slow to be playable; treat it as analysis/RE-only for now.
13
- - **Inspect + romhack** — read CPU/video/save RAM, watch memory, write-breakpoints, the Cheat-Engine value-search loop, a bundled cheat database, mapper-aware disassembly, and a byte-exact rebuildable-project disassembler.
13
+ - **Inspect + romhack** — read CPU/video/save RAM, watch memory, write-breakpoints, the Cheat-Engine value-search loop, a bundled cheat database, mapper-aware disassembly, and a byte-exact rebuildable-project disassembler (`disasm({target:'project'})` splits a ROM into byte-exact region `.asm`; **`build({output:'reassemble'})` rebuilds it into a byte-identical ROM in one call on all 15 classic platforms** — edit a region and rebuild, the "cmp before commit" gate for any structural hack).
14
14
  - **Reverse-engineering analysis engine (all 18 platforms — incl. the 3D consoles' MIPS R3000/R4300 + SH-4)** — control-flow graphs, deep cross-references, auto-detected functions (ranked real-code-first), a one-shot structural map, and a Ghidra **decompiler** (C-like pseudocode, with hardware registers named and 6502 SLEIGH clutter folded to readable C): `disasm({target:'cfg'|'xrefs'|'functions'|'decompile'})` and `symbols({op:'analyze'})`. And the piece no static tool has: **live computed-jumptable recovery** — `breakpoint({on:'jumptable'})` runs the emulator to resolve the `JMP (table,X)` / RTS-trick dispatchers (state machines, script/battle VMs) that static analysis collapses to "could not recover." Understand *how* a routine works before you touch it — no $3,000 IDA license, no install.
15
15
  - **Convert assets** — PNG → platform tiles/tilemaps, quantize-to-palette, audio importers (BRR for SNES, XGM2 PCM for Genesis).
16
16
  - **Native game runtimes (beyond emulation)** — the same run/see/drive loop also hosts two *native* game formats: **wasmcart** (`.wasc` — WASM games from any language; 2026) and **jsgame** (`.jsgame` — JavaScript canvas/WebGL games; 2024). `loadMedia({platform:'wasmcart'|'jsgame'})` → `frame({op:'step'|'screenshot'})` → `input`, over the same tools — plus V8/WASM introspection (the cart's live WASM heap + exports; the JS game's globals) an emulator can't offer, and `pack({target:'wasc'|'jsgame'})` to zip a source dir into the distributable archive (the "build" step; romdev doesn't compile the WASM — bring your own). A capability descriptor marks the emulator-only tools (memory regions / cpuState / disasm) *not-applicable* for these kinds. Depends on the [`wasmcart`](https://www.npmjs.com/package/wasmcart) + [`rungame`](https://www.npmjs.com/package/rungame) packages (jsgame runs in a `vm` realm — the server self-re-execs with `--experimental-vm-modules`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.87.2",
3
+ "version": "0.88.1",
4
4
  "description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 17 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, PlayStation, N64, Dreamcast, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
5
5
  "type": "module",
6
6
  "main": "src/mcp/server.js",
@@ -59,7 +59,7 @@
59
59
  "romdev-core-fceumm": "0.11.0",
60
60
  "romdev-core-flycast": "0.2.0",
61
61
  "romdev-core-gambatte": "0.10.0",
62
- "romdev-core-gametank": "0.2.0",
62
+ "romdev-core-gametank": "0.3.0",
63
63
  "romdev-core-geargrafx": "0.8.0",
64
64
  "romdev-core-gpgx": "0.13.0",
65
65
  "romdev-core-handy": "0.8.0",
@@ -212,6 +212,7 @@ export const PLATFORM_VIRTUAL_EXT = {
212
212
  atari7800: ".a78",
213
213
  pce: ".pce",
214
214
  msx: ".rom",
215
+ gametank: ".gtr",
215
216
  pico8: ".p8",
216
217
  };
217
218
  import { RETRO_DEVICE_JOYPAD, ROMDEV_PIXEL_FORMAT_RGBA8888 } from "./retroConstants.js";
@@ -25,7 +25,7 @@ const WASMCART_FB_FORMAT = RETRO_PIXEL_FORMAT_XRGB8888;
25
25
  // setInput speaks the libretro button vocabulary (b,y,select,start,up,down,left,
26
26
  // right,a,x,l,r,…); wasmcart pads carry the same conceptual buttons. We translate
27
27
  // the input bitmask/object into the pad object shape CartHost._writePads expects.
28
- const JOYPAD = {
28
+ const _JOYPAD = {
29
29
  b: 0, y: 1, select: 2, start: 3, up: 4, down: 5, left: 6, right: 7,
30
30
  a: 8, x: 9, l: 10, r: 11, l2: 12, r2: 13, l3: 14, r3: 15,
31
31
  };
@@ -1043,7 +1043,7 @@ async function disassembleRomCore(args) {
1043
1043
  return jsonContent({ ...baseResult, ok: true, asm });
1044
1044
  }
1045
1045
 
1046
- async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
1046
+ export async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
1047
1047
  const { reassembleForPlatform, CPU_FAMILY } = await import("../../toolchains/common/reassemble.js");
1048
1048
  const data = new Uint8Array(await readFile(romPath));
1049
1049
  const resolved = platform ?? sniffPlatformFromPath(romPath);
@@ -1098,11 +1098,35 @@ async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
1098
1098
  );
1099
1099
  }
1100
1100
  const buildMd = renderBuildMd({
1101
- platform: resolved, romPath, regions: out, blobs: writtenBlobs,
1101
+ platform: resolved, romPath, outputDir, regions: out, blobs: writtenBlobs,
1102
1102
  build: absBuild, verifiable: plan.verifiable, notes: plan.notes, allOk,
1103
1103
  });
1104
1104
  await writeFile(nodePath.join(outputDir, "BUILD.md"), buildMd);
1105
1105
 
1106
+ // reassemble.json — the UNIFORM byte-exact rebuild manifest, written for
1107
+ // EVERY platform (unlike rebuild.json's one-call subset). It lets
1108
+ // build({output:'reassemble', path}) rebuild the ROM in one call on any
1109
+ // platform: assemble each region .asm and splice it into a copy of the
1110
+ // ORIGINAL rom (kept as original.rom) at its file offset, so the header,
1111
+ // inter-region gaps, and trailing pad come back verbatim. See toolchain.js.
1112
+ await writeFile(nodePath.join(outputDir, "original.rom"), data);
1113
+ const reassembleManifest = {
1114
+ platform: resolved,
1115
+ romTemplate: "original.rom", // relative to the project dir
1116
+ romLength: data.length,
1117
+ regions: regions.map((reg) => ({
1118
+ file: reg.file,
1119
+ startAddress: reg.startAddress,
1120
+ fileOffset: reg.fileOffset,
1121
+ byteLength: reg.bytes.length,
1122
+ ...(reg.kind === "data" ? { kind: "data" } : {}),
1123
+ })),
1124
+ };
1125
+ await writeFile(
1126
+ nodePath.join(outputDir, "reassemble.json"),
1127
+ JSON.stringify(reassembleManifest, null, 2) + "\n"
1128
+ );
1129
+
1106
1130
  return jsonContent({
1107
1131
  ok: allOk,
1108
1132
  path: romPath,
@@ -1116,14 +1140,17 @@ async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
1116
1140
  verifiable: plan.verifiable, // true = expected byte-identical via that call
1117
1141
  buildDoc: "BUILD.md",
1118
1142
  notes: plan.notes,
1143
+ // The UNIFORM one-call rebuild, available on EVERY platform (splices
1144
+ // the reassembled regions into a copy of the original for the gaps).
1145
+ reassemble: { output: "reassemble", platform: resolved, path: outputDir },
1119
1146
  },
1120
1147
  note: allOk
1121
1148
  ? `All ${out.length} region(s) round-trip BYTE-EXACT (avg ${avgReadable}% disassembled as instructions, the rest as .byte data). ` +
1149
+ `Rebuild byte-identical in ONE call: build({output:'reassemble', platform:'${resolved}', path:'${outputDir}'}).` +
1122
1150
  (absBuild
1123
- ? `Rebuild it with the build() call in rebuild.json / BUILD.md` +
1124
- (plan.verifiable ? " — expected byte-identical." : " (see notes — may need linker tweaks).")
1125
- : `See BUILD.md for how to rebuild.`)
1126
- : `Some regions did NOT round-trip byte-exact — see regions[].note.`,
1151
+ ? ` (build() one-call rebuild also available via rebuild.json.)`
1152
+ : ``)
1153
+ : `Some regions did NOT round-trip byte-exact — see regions[].note. build({output:'reassemble', platform:'${resolved}', path:'${outputDir}'}) still rebuilds the original bytes (edited regions must reassemble to their length).`,
1127
1154
  });
1128
1155
  }
1129
1156
 
@@ -1142,7 +1169,7 @@ function absolutizeBuild(build, outputDir) {
1142
1169
  }
1143
1170
 
1144
1171
  /** Human + agent readable rebuild instructions for a disassembled project. */
1145
- function renderBuildMd({ platform, romPath, regions, blobs, build, verifiable, notes, allOk }) {
1172
+ function renderBuildMd({ platform, romPath, outputDir, regions, blobs, build, verifiable, notes, allOk }) {
1146
1173
  const lines = [];
1147
1174
  lines.push(`# Rebuilding this ${platform} project`, "");
1148
1175
  lines.push(`Disassembled from \`${nodePath.basename(romPath)}\` by \`disasm({target:'project'})\`.`, "");
@@ -1151,21 +1178,35 @@ function renderBuildMd({ platform, romPath, regions, blobs, build, verifiable, n
1151
1178
  lines.push(`- \`${r.file}\` — ${r.region}${r.kind === "data" ? " (data)" : ""}, byte-exact${r.roundTripOk === false ? " ⚠ round-trip FAILED" : ""}.`);
1152
1179
  }
1153
1180
  for (const b of blobs) lines.push(`- \`${b.file}\` — ${b.bytes} bytes of binary data (extracted from the ROM; do not hand-edit).`);
1154
- lines.push("- `rebuild.json` — the exact `build()` args below, with absolute paths.", "");
1181
+ lines.push("- `original.rom` — a verbatim copy of the source ROM (the rebuild template; do not edit).");
1182
+ lines.push("- `reassemble.json` — the region→offset manifest the one-call rebuild reads.");
1183
+ if (build) lines.push("- `rebuild.json` — the alternate cc65-native `build()` args below.");
1184
+ lines.push("");
1185
+
1186
+ // The UNIFORM one-call rebuild — available on EVERY platform.
1187
+ lines.push("## Rebuild (recommended — one call, all platforms)", "");
1188
+ lines.push("Rebuild this project into a **byte-identical** ROM in one call:", "");
1189
+ lines.push("```json", JSON.stringify({ output: "reassemble", platform, path: outputDir }, null, 2), "```", "");
1190
+ lines.push(
1191
+ "Pass these as the arguments to the `build` tool. It assembles each region `.asm` " +
1192
+ "and splices the result into a copy of `original.rom`, so the header, gaps, and pad " +
1193
+ "return verbatim. `byteExact:true` = the rebuild equals the original exactly. Edit a " +
1194
+ "region `.asm` first to make a change — a same-length edit rebuilds a modified ROM; a " +
1195
+ "length-changing edit is reported (splicing would shift every later byte).", "",
1196
+ );
1197
+
1155
1198
  if (build) {
1156
- lines.push("## Rebuild", "");
1199
+ lines.push("## Rebuild (alternate — cc65-native `build()`)", "");
1157
1200
  if (verifiable) {
1158
- lines.push("This rebuilds **byte-identical** to the source ROM. Call:", "");
1201
+ lines.push("This platform also has a native `build()` recipe that rebuilds byte-identical:", "");
1159
1202
  } else {
1160
- lines.push("Rebuild call (see Notes — may need linker adjustments for an exact match):", "");
1203
+ lines.push("Alternate native `build()` recipe (see Notes — may need linker adjustments):", "");
1161
1204
  }
1162
1205
  lines.push("```json", JSON.stringify(build, null, 2), "```", "");
1163
- lines.push("Pass these as the arguments to the `build` tool. The same JSON is in `rebuild.json`.", "");
1164
- } else {
1165
- lines.push("## Rebuild", "", "No automatic rebuild recipe for this platform yet. " + notes, "");
1206
+ lines.push("The same JSON is in `rebuild.json`.", "");
1166
1207
  }
1167
1208
  if (notes) lines.push("## Notes", "", notes, "");
1168
- if (!allOk) lines.push("> ⚠ Some regions did not round-trip byte-exact edit those `.asm` files before rebuilding.", "");
1209
+ if (!allOk) lines.push("> ⚠ Some regions did not round-trip byte-exact as INSTRUCTIONS (they floored to `.byte` data, which is still byte-exact). The one-call rebuild above is unaffected.", "");
1169
1210
  return lines.join("\n") + "\n";
1170
1211
  }
1171
1212
 
@@ -1507,9 +1548,13 @@ export function registerDisasmTools(server, z) {
1507
1548
  "regions (PER-BANK on every banked format: NES mappers, SNES LoROM, GB MBC, Sega-mapper SMS/GG, MSX megaROM, " +
1508
1549
  "2600 F8/F6/F4, 7800 SuperGame, >32KB HuCards), REASSEMBLES each and verifies BYTE-EXACT (`roundTripOk`); " +
1509
1550
  "non-faithful lines fall back to `.byte` so it ALWAYS rebuilds; `readablePercent` reports instruction-vs-data. " +
1510
- "NES/C64/7800/Lynx/PCE ship a one-call `build()` rebuild in rebuild.json (flat AND banked); the rest ship a " +
1511
- "proven native recipe in BUILD.md. (SNES 65816 usually lands at the byte-exact " +
1512
- "data-only floor its `.a8/.i8` width state desyncs when instructions and pinned `.byte` mix.)\n" +
1551
+ "REBUILD (one call, ALL 15 classic platforms): `build({output:'reassemble', platform, path})` turns the project " +
1552
+ "dir back into a BYTE-IDENTICAL ROM it assembles each region + splices them into the kept `original.rom` " +
1553
+ "(header/gaps/pad verbatim). Edit a region `.asm` first for a change: a same-length edit rebuilds a modified " +
1554
+ "ROM (`byteExact:false`), a length-changing edit is refused. NES/C64/7800/Lynx/PCE ALSO ship a cc65-native " +
1555
+ "one-call `build({output:'rom'})` recipe in rebuild.json (flat AND banked). (SNES 65816 usually lands at the " +
1556
+ "byte-exact data-only floor — its `.a8/.i8` width state desyncs when instructions and pinned `.byte` mix; the " +
1557
+ "reassemble rebuild is byte-identical regardless of readability.)\n" +
1513
1558
  "'references' = scan a ROM's code for operands matching a CPU `address` and classify each (call/jump/branch/" +
1514
1559
  "read/write); also walks the vector table. Banked carts are scanned PER BANK (all of the formats above) — " +
1515
1560
  "refs carry `prgBank` (NES) / `romBank` (everything else). LIMITATION: direct addressing only " +
@@ -1663,6 +1708,7 @@ function sniffPlatformFromPath(p) {
1663
1708
  if (/\.prg$/i.test(p)) return "c64";
1664
1709
  if (/\.(lnx|lyx)$/i.test(p)) return "lynx";
1665
1710
  if (/\.gba$/i.test(p)) return "gba";
1711
+ if (/\.gtr$/i.test(p)) return "gametank";
1666
1712
  if (/\.(z64|n64|v64)$/i.test(p)) return "n64";
1667
1713
  if (/\.(psexe|psx)$/i.test(p)) return "ps1"; // .exe/.bin are ambiguous — pass platform explicitly
1668
1714
  if (/\.(gen|md|bin)$/i.test(p)) return "genesis";
@@ -1883,6 +1929,21 @@ function planRegions(platform, data) {
1883
1929
  }
1884
1930
  return regions;
1885
1931
  }
1932
+ if (platform === "gametank") {
1933
+ // GameTank (Clyde Shaffer's open W65C02S console): a flat, size-keyed EEPROM
1934
+ // cart (default 32KB = EEPROM32K) mapped so the image ends at $FFFF — a 32KB
1935
+ // ROM spans $8000-$FFFF, with the 6-byte NMI/RESET/IRQ vector table at $FFFA.
1936
+ // One flat region at the top of the address space; the 65C02 rides the same
1937
+ // da65/ca65 6502-family path (--cpu 65c02). No trailing-pad trim: the cart is
1938
+ // exactly `size` bytes and the vectors + any pad are REAL bytes.
1939
+ const org = (0x10000 - data.length) & 0xffff;
1940
+ regions.push({
1941
+ name: "rom", file: "rom.asm", bytes: data.slice(0),
1942
+ startAddress: org, fileOffset: 0,
1943
+ label: `flat cart @ $${org.toString(16).toUpperCase()} (W65C02, vectors at $FFFA)`,
1944
+ });
1945
+ return regions;
1946
+ }
1886
1947
  if (platform === "msx") {
1887
1948
  // MSX cartridge maps at $4000-$BFFF; the 16-byte "AB" header at $4000 is
1888
1949
  // data (magic + INIT/STATEMENT/DEVICE/TEXT pointers), code follows.
@@ -712,10 +712,11 @@ export function registerToolchainTools(server, z, sessionKey) {
712
712
  "• output:'rom' (default) — assemble or compile `source` (single) / `sources` ({name:contents}) / `sourcePath` / `sourcesPaths`. Returns the ROM (path by default; `inline:true` for binaryBase64) + build log. **`binaryIncludes`/`binaryIncludePaths` (base64/path CHR-ROM, music blobs for `.incbin`) — WITHOUT them no game with external assets builds.** `includes`/`includePaths` for `.include`d text. `linkerConfig` (cc65; NES preset 'chr-ram-runtime' RECOMMENDED). `crt0`/`crt0Path`/`codeLoc`/`dataLoc` (SDCC). `runtime`/`maxmod`/`rebuildSdk` (GBA/Genesis SDK). **`lint:'strict'` fails the build (stage:'lint', no binary) if the pre-flight SDCC crash-pattern scan flags anything (e.g. the uint8 loop-bound trap); 'advisory' (default) just lists hits in issues[].** **`includeSymbols:true` returns the .map text inline on a PLAIN rom build — distinct from output:'romWithDebug' which writes .dbg/.map FILES.** Language is inferred from extension/content — usually OMIT `language`.\n" +
713
713
  "• output:'romWithDebug' — like 'rom' but also emits linker debug info for the `symbols` tool: cc65 → `.dbg`, SDCC → sdld `.map`, Genesis m68k → GNU ld map (find where a RAM var landed). DEFAULT writes ROM + debug file + log to disk (`outputPath` required unless `inline:true`). **`resolveSymbols:['grid','score']` folds those names' addresses ({resolvedSymbols:{grid:{address,hex,region?,ramOffset?}}}) straight into the result — the cheap way to a WRAM variable's address without loading the whole map (or round-tripping it through `symbols`).**\n" +
714
714
  "• output:'run' — BUILD + LOAD + RUN + SCREENSHOT in one round trip — the fastest iteration loop. Same build args; runs `frames` frames and returns the screenshot INLINE. `holdInputs` holds controller state; `screenshotPath` writes the PNG to disk instead; `projectName` titles the playtest window.\n" +
715
- "• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point auto-detects `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm); pass `entry:'smw.asm'` to point at a differently-named top-level file (e.g. an existing disassembly). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` (recursively, including subdirectories) become binaryIncludes (for `.incbin`). `options` (e.g. asar `--define _VER=1`) and `defines` are honored here too. Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for an examples({op:'fork'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a forked project.**",
715
+ "• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point auto-detects `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm); pass `entry:'smw.asm'` to point at a differently-named top-level file (e.g. an existing disassembly). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` (recursively, including subdirectories) become binaryIncludes (for `.incbin`). `options` (e.g. asar `--define _VER=1`) and `defines` are honored here too. Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for an examples({op:'fork'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a forked project.**\n" +
716
+ "• output:'reassemble' — **the UNIFORM byte-exact ROUND-TRIP**: rebuild a `disasm({target:'project'})` output dir (`path`) into a byte-identical ROM in ONE call, on EVERY classic platform (NES/SNES/Genesis/GB/GBC/SMS/GG/MSX/GBA/C64/Atari/PCE/Lynx) — not just the cc65-native subset that `rebuild.json` covers. It reads the `reassemble.json` manifest disasm wrote, ASSEMBLES each region `.asm` with the platform's native assembler (ca65 for 6502/65816, GNU-as for m68k/arm/z80/sm83), and SPLICES each result into a copy of the original ROM (`original.rom`, kept in the dir) at its file offset — so the cartridge header, inter-region gaps, and trailing pad return verbatim. Returns `{ok, byteExact, outputPath, regions:[{file,byteExact,...}]}`; writes the ROM to `outputPath` (or `<dir>/rebuilt.<ext>` by default). **This is the 'cmp before commit' gate for a ROM-hacking / annotation workflow: edit a region `.asm`, call this, get a byte-identical ROM back (or a precise per-region mismatch if your edit changed a region's length).** `byteExact:true` = the rebuild equals `original.rom` exactly.",
716
717
  {
717
- output: z.enum(["rom", "romWithDebug", "run", "project"])
718
- .describe("rom=produce a ROM (default); romWithDebug=ROM + .dbg/.map debug files; run=build+load+run+screenshot; project=build a project directory."),
718
+ output: z.enum(["rom", "romWithDebug", "run", "project", "reassemble"])
719
+ .describe("rom=produce a ROM (default); romWithDebug=ROM + .dbg/.map debug files; run=build+load+run+screenshot; project=build a project directory; reassemble=rebuild a disasm({target:'project'}) dir into a byte-identical ROM (uniform across ALL platforms)."),
719
720
  platform: z.string().describe("Target platform id (e.g. 'nes', 'genesis')."),
720
721
  language: z.string().optional().describe("Language override ('c'/'asm'/'basic'). USUALLY OMIT — inferred from source extension/content; only the ambiguous case falls to the platform default. Ignored by output:'romWithDebug' (C only)."),
721
722
  // source inputs (rom/romWithDebug/run)
@@ -780,6 +781,10 @@ export function registerToolchainTools(server, z, sessionKey) {
780
781
  if (!args.path) throw new Error("build({output:'project'}): `path` (the project directory) is required.");
781
782
  return await buildProjectImpl(args);
782
783
  }
784
+ case "reassemble": {
785
+ if (!args.path) throw new Error("build({output:'reassemble'}): `path` (the disasm project directory) is required.");
786
+ return await reassembleProjectCore(args);
787
+ }
783
788
  case "romWithDebug": return await buildSourceWithDebugCore(args);
784
789
  default: throw new Error(`build: unknown output '${args.output}'`);
785
790
  }
@@ -1162,3 +1167,133 @@ export async function buildProjectCore({ path: projPath, platform, outputPath, o
1162
1167
  issues: rankIssues(result.issues ?? []),
1163
1168
  });
1164
1169
  }
1170
+
1171
+ /**
1172
+ * build({output:'reassemble'}) — the UNIFORM byte-exact round-trip.
1173
+ *
1174
+ * Rebuild a `disasm({target:'project'})` output dir into a byte-identical ROM,
1175
+ * on EVERY platform. Reads the `reassemble.json` manifest disasm wrote, then for
1176
+ * each region assembles its (possibly hand-edited) `.asm` with the platform's
1177
+ * native assembler and SPLICES the result into a copy of the original ROM
1178
+ * (`original.rom`, kept in the dir) at the region's file offset — so the header,
1179
+ * inter-region gaps, and trailing pad come back verbatim. The result equals the
1180
+ * original exactly when no region was edited; an edit that keeps a region's
1181
+ * length rebuilds a modified-but-valid ROM; an edit that CHANGES a region's
1182
+ * length is reported as a per-region mismatch (splicing would corrupt offsets).
1183
+ *
1184
+ * This is the "cmp before commit" gate a ROM-hacking / annotation workflow needs,
1185
+ * and unlike rebuild.json it exists for ALL classic platforms, not just the
1186
+ * cc65-native subset.
1187
+ */
1188
+ export async function reassembleProjectCore({ path: projPath, platform, outputPath }) {
1189
+ const { assembleRegionText } = await import("../../toolchains/common/reassemble.js");
1190
+ const manifestPath = path.join(projPath, "reassemble.json");
1191
+ let manifest;
1192
+ try {
1193
+ manifest = JSON.parse(await readFile(manifestPath, "utf8"));
1194
+ } catch {
1195
+ throw new Error(
1196
+ `build({output:'reassemble'}): no reassemble.json in '${projPath}'. This op rebuilds a ` +
1197
+ `disasm({target:'project'}) directory — run that first (it writes reassemble.json + original.rom).`,
1198
+ );
1199
+ }
1200
+ const plat = platform ?? manifest.platform;
1201
+ if (platform && manifest.platform && platform !== manifest.platform) {
1202
+ throw new Error(`build({output:'reassemble'}): platform '${platform}' ≠ manifest platform '${manifest.platform}'.`);
1203
+ }
1204
+
1205
+ // The gap template: a copy of the original ROM. Reassembled regions overwrite
1206
+ // their ranges; everything else (header, vectors, inter-region gaps, pad) is
1207
+ // preserved byte-for-byte from here.
1208
+ const templatePath = path.isAbsolute(manifest.romTemplate)
1209
+ ? manifest.romTemplate
1210
+ : path.join(projPath, manifest.romTemplate);
1211
+ let template;
1212
+ try {
1213
+ template = new Uint8Array(await readFile(templatePath));
1214
+ } catch {
1215
+ throw new Error(`build({output:'reassemble'}): missing ROM template '${templatePath}' (the original.rom disasm kept). Re-run disasm({target:'project'}).`);
1216
+ }
1217
+ if (manifest.romLength != null && template.length !== manifest.romLength) {
1218
+ throw new Error(`build({output:'reassemble'}): original.rom is ${template.length} bytes but manifest says ${manifest.romLength} — stale project.`);
1219
+ }
1220
+
1221
+ const rom = template.slice(); // mutable copy — splice regions into it
1222
+ const regionResults = [];
1223
+ let anyEditKeptExact = false;
1224
+
1225
+ for (const reg of manifest.regions) {
1226
+ const asmText = await readFile(path.join(projPath, reg.file), "utf8");
1227
+ const r = await assembleRegionText({
1228
+ platform: plat,
1229
+ asmText,
1230
+ startAddress: reg.startAddress,
1231
+ byteLength: reg.byteLength,
1232
+ });
1233
+ if (!r.ok || !r.bytes) {
1234
+ regionResults.push({ file: reg.file, ok: false, byteExact: false, error: (r.log || "assemble failed").split("\n").slice(-3).join(" | ").slice(0, 400) });
1235
+ continue;
1236
+ }
1237
+ // A length-changing edit can't be spliced without shifting every later byte —
1238
+ // refuse rather than silently corrupt the ROM. `producedLength` (GNU path) is
1239
+ // the true assembled size BEFORE the byteLength slice; without it fall back to
1240
+ // the sliced length (the cc65 path errors at ld65 on overflow, so it's safe).
1241
+ const producedLen = r.producedLength ?? r.bytes.length;
1242
+ if (producedLen !== reg.byteLength) {
1243
+ regionResults.push({
1244
+ file: reg.file, ok: false, byteExact: false,
1245
+ error: `region reassembled to ${producedLen} bytes but must be ${reg.byteLength} (a length-changing edit can't be spliced back — keep the region's byte count, or use build({output:'rom'}) with a full linker recipe).`,
1246
+ });
1247
+ continue;
1248
+ }
1249
+ // Compare against the original bytes at this offset to report whether THIS
1250
+ // region matches the source ROM (edited-but-valid regions differ, that's ok).
1251
+ const orig = template.slice(reg.fileOffset, reg.fileOffset + reg.byteLength);
1252
+ let regionExact = true;
1253
+ for (let i = 0; i < reg.byteLength; i++) {
1254
+ rom[reg.fileOffset + i] = r.bytes[i];
1255
+ if (r.bytes[i] !== orig[i]) regionExact = false;
1256
+ }
1257
+ if (!regionExact) anyEditKeptExact = true;
1258
+ regionResults.push({
1259
+ file: reg.file, ok: true, byteExact: regionExact,
1260
+ startAddress: "$" + reg.startAddress.toString(16).toUpperCase(),
1261
+ fileOffset: "0x" + reg.fileOffset.toString(16).toUpperCase(),
1262
+ bytes: reg.byteLength,
1263
+ });
1264
+ }
1265
+
1266
+ // Whole-ROM byte-exact vs the original template.
1267
+ let romExact = rom.length === template.length;
1268
+ if (romExact) for (let i = 0; i < rom.length; i++) if (rom[i] !== template[i]) { romExact = false; break; }
1269
+
1270
+ const anyRegionFailed = regionResults.some((r) => !r.ok);
1271
+ const ext = PLATFORM_VIRTUAL_EXT[plat] ?? ".bin";
1272
+ const outPath = outputPath ?? path.join(projPath, "rebuilt" + ext);
1273
+ // Only write when every region assembled (a failed region means the ROM is
1274
+ // incomplete/garbage — don't hand back a broken image).
1275
+ let wrote = null;
1276
+ if (!anyRegionFailed) {
1277
+ await writeFile(outPath, rom);
1278
+ wrote = outPath;
1279
+ }
1280
+
1281
+ const note = anyRegionFailed
1282
+ ? `Reassembly INCOMPLETE — ${regionResults.filter((r) => !r.ok).length} region(s) failed (see regions[].error). No ROM written.`
1283
+ : romExact
1284
+ ? `Byte-IDENTICAL to the original ROM (${rom.length} bytes). Round-trip verified — safe to commit.`
1285
+ : anyEditKeptExact
1286
+ ? `Rebuilt ${rom.length} bytes with edited region(s) (regions[].byteExact=false = your changes). Not identical to the original — that's expected for an intentional edit.`
1287
+ : `Rebuilt ${rom.length} bytes but NOT byte-identical to the original despite no reported region edits — investigate regions[] before trusting this.`;
1288
+
1289
+ return jsonContent({
1290
+ ok: !anyRegionFailed,
1291
+ output: "reassemble",
1292
+ platform: plat,
1293
+ byteExact: !anyRegionFailed && romExact,
1294
+ outputPath: wrote,
1295
+ romLength: rom.length,
1296
+ regions: regionResults,
1297
+ note,
1298
+ });
1299
+ }
@@ -462,24 +462,31 @@ that won't reproduce faithfully heals to a `.byte`/`db` of its real bytes, so th
462
462
  `.asm` ALWAYS rebuilds (`roundTrip.allByteExact`). `readablePercent` per region tells you
463
463
  how much came back as real instructions vs. data. Alongside the `.asm` it writes the
464
464
  turnkey **rebuild glue**: data blobs (NES CHR-ROM → `chr.bin`; stripped Genesis/GBA/Lynx/MSX
465
- cartridge header → `*.bin`), a `BUILD.md` with the exact steps, and — where a one-call
466
- rebuild exists — a `rebuild.json` of the precise `build({...})` args. So the loop is
467
- `disasm({target:'project'})` edit a `.asm` → rebuild → `romPatch({op:'diff'})` to confirm.
468
-
469
- **Two rebuild tiers** (the disasm emits each CPU's native-reassembler syntax — ca65 for
470
- 6502/65816, GNU `as` for m68k/arm/z80/gbz80 which only some `build()` toolchains consume):
471
- - **One-call `build()` rebuild, byte-identical** **NES (NROM *and* banked mappers), C64,
472
- Atari 7800 (flat *and* SuperGame banked), Lynx, PC Engine (flat *and* banked HuCards)**.
473
- Feed `rebuild.json` straight to `build`. Banked projects ship a HEADER segment with the
474
- original header bytes (16 iNES / 128 .a78 / 512 copier), per-bank segment wrappers, and a
475
- generated multi-bank `.cfg` referenced via `linkerConfigPath` (so the cfg never streams
476
- through context). (Lynx: `build()` yields the headerless image; prepend the shipped
477
- `lnx_header.bin` for the full `.lnx`.)
478
- - **Native-recipe rebuild (`buildCall:null`), byte-identical, steps in `BUILD.md`** **SMS,
479
- GG, MSX, GB, GBC, Genesis, GBA, Atari 2600**. Their `build()` toolchains (SDCC/RGBDS/asar/
480
- dasm/vasm) can't reassemble ca65/GNU-as syntax, so `BUILD.md` gives the proven native
481
- `as`/`ld`/`objcopy` chainper-bank on banked carts (Sega-mapper SMS/GG, MSX megaROMs,
482
- banked 2600 get per-bank wrappers + cfg blobs and a bank-by-bank recipe).
465
+ cartridge header → `*.bin`), a verbatim `original.rom` template, a `reassemble.json` manifest,
466
+ a `BUILD.md` with the exact steps, and where a cc65-native one-call rebuild also exists — a
467
+ `rebuild.json` of the precise `build({...})` args. So the loop is
468
+ `disasm({target:'project'})` → edit a `.asm` → `build({output:'reassemble'})` → `romPatch({op:'diff'})` to confirm.
469
+
470
+ **The rebuild one call, every platform.** `build({output:'reassemble', platform, path})` turns
471
+ the project dir back into a **byte-identical ROM in ONE call on all 15 classic platforms** — no
472
+ per-CPU recipe to run by hand. It assembles each region `.asm` with the CPU's native assembler
473
+ (ca65 for 6502/65816, GNU `as`/`ld`/`objcopy` for m68k/arm/z80/gbz80) and splices the results into
474
+ a copy of `original.rom`, so the cartridge header, inter-region gaps, and trailing pad come back
475
+ verbatim. `byteExact:true` = the rebuild equals the original exactly. **Edit a region `.asm` first
476
+ for an intentional change:** a same-length edit rebuilds a modified-but-valid ROM
477
+ (`byteExact:false`, only the changed bytes differ); a length-changing edit is REFUSED with a
478
+ per-region error (splicing can't shift every later byte — keep the byte count, or drop to a full
479
+ `build({output:'rom'})` linker recipe). This is the "cmp before commit" gate for any structural hack.
480
+
481
+ **Alternate cc65-native `build()` rebuild (`rebuild.json`)** for **NES (NROM *and* banked
482
+ mappers), C64, Atari 7800 (flat *and* SuperGame banked), Lynx, PC Engine (flat *and* banked
483
+ HuCards)** the project also ships a `rebuild.json` you can feed straight to `build({output:'rom'})`
484
+ (these platforms' `build()` toolchain IS cc65/ca65). Banked projects ship a HEADER segment with
485
+ the original header bytes (16 iNES / 128 .a78 / 512 copier), per-bank segment wrappers, and a
486
+ generated multi-bank `.cfg` via `linkerConfigPath`. (Lynx: it yields the headerless image; prepend
487
+ the shipped `lnx_header.bin` for the full `.lnx`.) The other platforms (SMS/GG/MSX/GB/GBC/Genesis/
488
+ GBA/Atari 2600) have no `rebuild.json` — but `build({output:'reassemble'})` above rebuilds them all
489
+ the same one-call way, so you never need the native `as`/`ld`/`objcopy` chain yourself.
483
490
 
484
491
  **Rebuilding a commercial NES (NROM CHR-ROM) game — `build({inesHeader})`:** the most common
485
492
  NES RE rebuild. `build({output:'rom', platform:'nes', inesHeader:{prgBanks, chrBanks, mapper,
@@ -219,7 +219,8 @@ What you can read:
219
219
  F6 = 16 KB, F4 = 32 KB) `references` scans EVERY 4 KB bank at `$F000`,
220
220
  refs tagged `romBank`; `disasm({target:'project'})` likewise emits one
221
221
  region per bank plus per-bank `BANKn` wrappers and a multi-area `.cfg`
222
- blob for the native ca65/ld65 rebuild.
222
+ blob, and **`build({output:'reassemble', platform:'atari2600', path})`
223
+ rebuilds the whole cart byte-identical in one call** (no ca65/ld65 by hand).
223
224
 
224
225
  Memory regions for **`memory({op:'read'})`**:
225
226
 
@@ -182,8 +182,10 @@ Pair `sprites` / `palette` / `background` / `cpu` with
182
182
  `disasm({target:'rom'})`, `disasm({target:'references'})`, and
183
183
  `disasm({target:'project'})` run the native binutils
184
184
  **`arm-none-eabi-objdump`** (WASM) — **ARM mode by default**, pass
185
- `thumb:true` for Thumb code. The byte-exact project reassembles through
186
- `arm-none-eabi-as` / `ld` / `objcopy`.
185
+ `thumb:true` for Thumb code. To rebuild, **`build({output:'reassemble',
186
+ platform:'gba', path})` turns the project dir back into a byte-identical ROM in
187
+ one call** — it assembles each region through `arm-none-eabi-as`/`ld`/`objcopy` and
188
+ splices the results into the original's header/pad (you don't run them yourself).
187
189
 
188
190
  **Gotcha (until ARM/Thumb mode-tracking lands):** GBA C compiles mostly to
189
191
  **Thumb** reached via an **ARM crt0 stub**, so an ARM-mode disasm of a full
@@ -121,8 +121,10 @@ exactly this.
121
121
  `objdump`. MegaROMs (>32 KB) are handled per 16 KB bank: `references` scans
122
122
  bank 0 at `$4000` (after the "AB" header) and banks 1+ at `$8000` (an
123
123
  assumed ASCII16-style window), refs tagged `romBank`;
124
- `disasm({target:'project'})` splits the header into its own data region and
125
- emits a bank-by-bank native rebuild recipe in `BUILD.md`.
124
+ `disasm({target:'project'})` splits the header into its own data region, and
125
+ **`build({output:'reassemble', platform:'msx', path})` rebuilds the whole ROM
126
+ byte-identical in one call** (each bank assembled + spliced back — no
127
+ bank-by-bank native recipe to run by hand).
126
128
 
127
129
  ## MCP debug & inspection tooling
128
130
 
@@ -321,7 +321,9 @@ disassemblers.
321
321
  Sega-mapper banked carts (>48 KB) are handled per-bank: `references` scans
322
322
  every 16 KB bank (bank 0 @ `$0000`, bank 1 @ `$4000`, banks 2+ @ their slot-2
323
323
  window `$8000`), refs tagged `romBank`; `disasm({target:'project'})` emits one
324
- region per bank with a bank-by-bank native rebuild recipe in `BUILD.md`.
324
+ region per bank, and **`build({output:'reassemble', platform:'sms', path})`
325
+ rebuilds the whole cart into a byte-identical ROM in one call** (it assembles each
326
+ bank and splices them back — no bank-by-bank native recipe to run by hand).
325
327
 
326
328
  ## Horizontal scrolling (for side-scrollers)
327
329
 
@@ -224,9 +224,10 @@ video are fully readable, so you assert live state instead of guessing:
224
224
  master mixer (see "Debugging sound" above for `audioDebug`).
225
225
  - **`disasm({target:'references'})`** scans EVERY 32 KB LoROM bank (refs
226
226
  tagged `romBank`) — a hit in bank 12 of a 1 MB cart shows up, not just
227
- bank 0. `disasm({target:'project'})` likewise splits per-bank with a
228
- native ca65/ld65 rebuild recipe (build() is asar, which can't consume
229
- the disasm's ca65 output).
227
+ bank 0. `disasm({target:'project'})` likewise splits per-bank, and
228
+ **`build({output:'reassemble', platform:'snes', path})` rebuilds the whole dir
229
+ into a byte-identical ROM in one call** (it assembles each region's ca65 + splices
230
+ them into the original's header/pad — no need to run ca65/ld65 yourself).
230
231
  - **Memory regions:** `memory({op:'read'})` exposes OAM, CGRAM, ARAM (SPC700
231
232
  audio RAM), and **FillRAM**. Note the FillRAM quirk: snes9x mirrors the
232
233
  PPU registers $2100-$213F (OBSEL/BGMODE/TM/TS/color-math, etc.) into
@@ -76,6 +76,9 @@ export const CPU_FAMILY = {
76
76
  // PC Engine's HuC6280 is a 65C02 superset — the 6502-family da65/ca65 path
77
77
  // reassembles it (da65 also has an explicit --cpu huc6280 mode for decode).
78
78
  pce: "6502",
79
+ // GameTank's W65C02S is a 65C02 superset — same 6502-family da65/ca65 path.
80
+ // 65C02-only opcodes da65 doesn't decode floor to `.byte` (still byte-exact).
81
+ gametank: "6502",
79
82
  };
80
83
 
81
84
  /**
@@ -149,7 +152,12 @@ async function reassembleGnuNative(disasm, startAddress, original, tools, family
149
152
  // binutils chain for this CPU. asArgs (e.g. for z80's -march=gbz80) ride
150
153
  // through to the assembler call (the z80 wrapper bakes march in itself).
151
154
  const { runAs, runLd, runObjcopy, fmtLines = "" } = tools;
152
- const ld = `${fmtLines}ENTRY(_start)\nSECTIONS {\n .text 0x${startAddress.toString(16)} : {\n *(.text*) *(.rodata*) *(.data*)\n }\n /DISCARD/ : { *(.ARM.attributes) *(.comment) *(.note*) }\n}\n`;
155
+ // SUBALIGN(1) is load-bearing: without it, the m68k ELF aligns .text to a 2-byte
156
+ // (word) boundary, so `objcopy -O binary` emits 2 leading pad bytes and the whole
157
+ // region shifts by 2 → byte-exact FAILS even for a pure `.byte` floor. Forcing
158
+ // sub-alignment to 1 places the section exactly at its origin with no leading pad.
159
+ // (ARM shares this linked path; harmless there where alignment already matches.)
160
+ const ld = `${fmtLines}ENTRY(_start)\nSECTIONS {\n .text 0x${startAddress.toString(16)} : SUBALIGN(1) {\n *(.text*) *(.rodata*) *(.data*)\n }\n /DISCARD/ : { *(.ARM.attributes) *(.comment) *(.note*) }\n}\n`;
153
161
 
154
162
  // Parse objdump's normalized lines into {label?, code?, addr, bytes}.
155
163
  const lines = disasm.split(/\r?\n/).map(parseLine);
@@ -358,3 +366,116 @@ async function reassembleCc65Native(disasm, startAddress, original, family) {
358
366
  note: ok ? "incremental heal did not converge; emitted byte-exact data-only (low readability)" : "could not reach byte-exact even as data" };
359
367
  }
360
368
  }
369
+
370
+ // ── ASSEMBLE-ONLY path (the byte-exact ROUND-TRIP rebuild) ───────────────────
371
+ //
372
+ // reassembleForPlatform above DISASSEMBLES bytes and heals them back. But
373
+ // build({output:'reassemble'}) rebuilds from region .asm files that
374
+ // disasm({target:'project'}) already emitted (and that an agent may have
375
+ // EDITED). Those files are the platform's native dialect already — ca65 for
376
+ // 6502/65816, GNU-as for m68k/arm/z80/sm83 — so we just ASSEMBLE them; no
377
+ // disassemble, no heal loop. `assembleRegionText` is that half: source text +
378
+ // its origin/length → the produced bytes (or null + log on error). It reuses
379
+ // the SAME per-family as/ld/objcopy/ca65/ld65 calls the heal path uses, so a
380
+ // region that round-trips in disasm reassembles identically here.
381
+
382
+ /**
383
+ * Assemble one region's .asm source (possibly hand-edited) back to raw bytes,
384
+ * using the platform's native assembler chain. No disassembly, no heal loop —
385
+ * the source is already the right dialect.
386
+ * @param {Object} a
387
+ * @param {string} a.platform
388
+ * @param {string} a.asmText the region .asm contents (comments ok; leading `; ...` header ignored by the assembler)
389
+ * @param {number} a.startAddress CPU/origin address of the first byte
390
+ * @param {number} a.byteLength expected produced length (the region's original size)
391
+ * @returns {Promise<{ ok:boolean, bytes:Uint8Array|null, family:string, log?:string }>}
392
+ */
393
+ export async function assembleRegionText(a) {
394
+ const { platform, asmText, startAddress, byteLength } = a;
395
+ const family = CPU_FAMILY[platform];
396
+ if (!family) throw new Error(`assembleRegionText: no reassembly path for platform '${platform}'`);
397
+
398
+ if (family === "6502" || family === "65816") {
399
+ const { runCa65, runLd65 } = await import("../cc65/cc65.js");
400
+ const cpuTag = family === "65816" ? "65816" : "6502";
401
+ const cfg = `MEMORY{M:start $${startAddress.toString(16)},size $${byteLength.toString(16)},type ro,file %O,fill yes,fillval $FF;}\nSEGMENTS{CODE:load M,type ro;}\n`;
402
+ // The emitted .asm carries `.setcpu`/`.org` from the heal path; if a hand-
403
+ // edited file dropped them, ca65 still needs the CPU + origin, so ensure both.
404
+ let src = asmText;
405
+ if (!/^\s*\.setcpu\b/m.test(src)) src = `\t.setcpu "${cpuTag}"\n` + src;
406
+ if (!/^\s*\.org\b/m.test(src)) src = src.replace(/(\.setcpu\s+"[^"]+")/, `$1\n\t.org $${startAddress.toString(16).toUpperCase()}`);
407
+ const ca = await runCa65({ source: src, target: "none" }).catch((e) => ({ object: null, log: String(e) }));
408
+ if (!ca || !ca.object) return { ok: false, bytes: null, family, log: ca?.log || "ca65 failed" };
409
+ const ld = await runLd65({ objects: { "o.o": ca.object }, target: "none", linkerConfig: cfg }).catch((e) => ({ binary: null, log: String(e) }));
410
+ if (!ld || !ld.binary) return { ok: false, bytes: null, family, log: ld?.log || "ld65 failed" };
411
+ return { ok: true, bytes: new Uint8Array(ld.binary), family };
412
+ }
413
+
414
+ // GNU families (m68k/arm/z80/sm83): as → (ld) → objcopy. Mirror the toolset
415
+ // selection + the SUBALIGN(1)/`.org`/no-link rules from reassembleGnuNative.
416
+ let tools;
417
+ if (family === "m68k") {
418
+ const m = await import("../m68k-elf-gcc/gcc.js");
419
+ tools = { runAs: m.runM68kAs, runLd: m.runM68kLd, runObjcopy: m.runM68kObjcopy, fmtLines: `OUTPUT_FORMAT("elf32-m68k")\nOUTPUT_ARCH(m68k)\n` };
420
+ } else if (family === "arm") {
421
+ const m = await import("../arm-none-eabi-gcc/gcc.js");
422
+ tools = { runAs: m.runArmAs, runLd: m.runArmLd, runObjcopy: m.runArmObjcopy, fmtLines: "" };
423
+ } else if (family === "z80" || family === "sm83") {
424
+ const z = await import("../z80/binutils.js");
425
+ tools = { runAs: z.runZ80As, runObjcopy: z.runZ80Objcopy, march: family === "sm83" ? "gbz80" : "z80", noLink: true };
426
+ } else {
427
+ throw new Error(`assembleRegionText: no reassembly path for family '${family}'`);
428
+ }
429
+
430
+ const { runAs, runLd, runObjcopy, fmtLines = "", noLink, march } = tools;
431
+ const ld = `${fmtLines}ENTRY(_start)\nSECTIONS {\n .text 0x${startAddress.toString(16)} : SUBALIGN(1) {\n *(.text*) *(.rodata*) *(.data*)\n }\n /DISCARD/ : { *(.ARM.attributes) *(.comment) *(.note*) }\n}\n`;
432
+
433
+ // GNU `as` for these CPUs does NOT treat `;` as a comment — `;` is a statement
434
+ // separator. The emitted .asm uses `;` two ways: a LEADING `; …` metadata header
435
+ // block, and a TRAILING `; ADDR` address comment on each `.byte`/instruction
436
+ // line (from dataRegionSource + normalizeObjdump). Strip BOTH: drop the leading
437
+ // comment-only lines, then cut any trailing `; …` off every remaining line.
438
+ let gnuSrc;
439
+ {
440
+ const lines = asmText.split(/\r?\n/);
441
+ let i = 0;
442
+ while (i < lines.length && (lines[i].trim() === "" || lines[i].trimStart().startsWith(";"))) i++;
443
+ gnuSrc = lines.slice(i).map((ln) => {
444
+ const c = ln.indexOf(";");
445
+ return (c >= 0 ? ln.slice(0, c) : ln).replace(/\s+$/, "");
446
+ }).join("\n");
447
+ }
448
+ // A DATA region (dataRegionSource) is just `.org` + `.byte` rows with no
449
+ // `.section .text`/`_start:` scaffolding the link script needs. Wrap it so the
450
+ // linked (m68k/arm) path finds `_start` at the origin; the code path already
451
+ // carries its own scaffolding (skip if present). The no-link (z80/gbz80) path
452
+ // keeps its `.org` and never needs `_start`.
453
+ if (!noLink && !/^\s*\.section\s+\.text/m.test(gnuSrc)) {
454
+ gnuSrc = `.section .text\n.global _start\n_start:\n` + gnuSrc.replace(/^\s*\.org\s+\S+\s*$/m, "");
455
+ }
456
+ gnuSrc = gnuSrc + "\n";
457
+ const a2 = await runAs({ source: gnuSrc, march }).catch((e) => ({ object: null, log: String(e) }));
458
+ if (!a2 || !a2.object) return { ok: false, bytes: null, family, log: a2?.log || "as failed" };
459
+ let elf;
460
+ if (noLink) {
461
+ elf = a2.object;
462
+ } else {
463
+ const l = await runLd({ objects: { "main.o": a2.object }, linkScript: ld }).catch((e) => ({ elf: null, log: String(e) }));
464
+ if (!l || !l.elf) return { ok: false, bytes: null, family, log: l?.log || "ld failed" };
465
+ elf = l.elf;
466
+ }
467
+ const o = await runObjcopy({ elf }).catch((e) => ({ binary: null, log: String(e) }));
468
+ if (!o || !o.binary) return { ok: false, bytes: null, family, log: o?.log || "objcopy failed" };
469
+ let bin = new Uint8Array(o.binary);
470
+ // producedLength = how many REAL bytes the region assembled to (for the no-link
471
+ // path, minus the `.org` leading-zero pad). The caller checks this against the
472
+ // region's expected byteLength so a length-changing edit is REFUSED, not
473
+ // silently truncated by the slice below.
474
+ const produced = noLink ? Math.max(0, bin.length - startAddress) : bin.length;
475
+ if (noLink) {
476
+ bin = bin.slice(startAddress, startAddress + byteLength);
477
+ } else if (bin.length > byteLength) {
478
+ bin = bin.slice(0, byteLength);
479
+ }
480
+ return { ok: true, bytes: bin, family, producedLength: produced };
481
+ }