romdevtools 0.87.2 → 0.88.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 +11 -6
- package/CHANGELOG.md +54 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/host/LibretroHost.js +1 -0
- package/src/host/WasmcartHost.js +1 -1
- package/src/mcp/tools/disasm.js +79 -18
- package/src/mcp/tools/toolchain.js +138 -3
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +25 -18
- package/src/platforms/atari2600/MENTAL_MODEL.md +2 -1
- package/src/platforms/gba/MENTAL_MODEL.md +4 -2
- package/src/platforms/msx/MENTAL_MODEL.md +4 -2
- package/src/platforms/sms/MENTAL_MODEL.md +3 -1
- package/src/platforms/snes/MENTAL_MODEL.md +4 -3
- package/src/toolchains/common/reassemble.js +122 -1
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),
|
|
351
|
-
> `disasm({target:'project'})`
|
|
352
|
-
>
|
|
353
|
-
>
|
|
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.
|
|
3
|
+
"version": "0.88.0",
|
|
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",
|
package/src/host/LibretroHost.js
CHANGED
package/src/host/WasmcartHost.js
CHANGED
|
@@ -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
|
|
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
|
};
|
package/src/mcp/tools/disasm.js
CHANGED
|
@@ -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
|
-
? `
|
|
1124
|
-
|
|
1125
|
-
|
|
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("- `
|
|
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
|
|
1201
|
+
lines.push("This platform also has a native `build()` recipe that rebuilds byte-identical:", "");
|
|
1159
1202
|
} else {
|
|
1160
|
-
lines.push("
|
|
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("
|
|
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
|
|
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
|
-
"
|
|
1511
|
-
"
|
|
1512
|
-
"
|
|
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 `
|
|
466
|
-
|
|
467
|
-
`
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
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.
|
|
186
|
-
`
|
|
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
|
-
|
|
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
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
+
}
|