romdevtools 0.88.0 → 0.89.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 +7 -1
- package/CHANGELOG.md +38 -0
- package/package.json +2 -2
- package/src/mcp/tools/disasm.js +188 -16
package/AGENTS.md
CHANGED
|
@@ -107,7 +107,13 @@ questions, and running both early is normal.** A good default:
|
|
|
107
107
|
3. **Disassemble / trace** whenever the hack is about CODE or about data the
|
|
108
108
|
cheats don't cover: `disasm({target:'project'})` for a rebuildable project
|
|
109
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
|
|
110
|
+
rebuild a byte-identical ROM in one call — the "cmp before commit" gate; a
|
|
111
|
+
`.gitignore` is written so the kept `original.rom` can't be committed. **Large
|
|
112
|
+
ROM (≥512KB, e.g. a 1MB SNES/Genesis cart)? Pass `background:true`** and poll
|
|
113
|
+
`disasm({target:'project', job, outputDir})` — the reassemble can take minutes
|
|
114
|
+
and would otherwise time the call out. A uniform-fill/padding bank reports
|
|
115
|
+
`fill:true` + `readablePercent:null`, so "low % = data bank" isn't fooled by a
|
|
116
|
+
$FF pad tail),
|
|
111
117
|
`disasm({target:'references'})` for "what touches this address", `breakpoint({on:'write'})` for the exact
|
|
112
118
|
instruction that wrote a byte, `watch({on:'mem'})`/`breakpoint({on:'write',precision:'sampled'})` to find an address
|
|
113
119
|
empirically. For STRUCTURE — "what are the functions, how do they call each
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ 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.89.0 — 2026-07-14
|
|
8
|
+
|
|
9
|
+
Large-ROM `disasm({target:'project'})` no longer times out the tool call — the last of the
|
|
10
|
+
findings from the 1 MB ActRaiser run. A multi-MB cart (32-bank SNES/Genesis) can take minutes to
|
|
11
|
+
reassemble; that used to time the MCP call out mid-run even though the server finished writing every
|
|
12
|
+
bank.
|
|
13
|
+
|
|
14
|
+
- **Parallelized the per-bank reassembly.** The region loop was strictly sequential (one bank at a
|
|
15
|
+
time, one WASM worker). It now fires all regions concurrently and the worker pool caps real
|
|
16
|
+
parallelism at `ROM_DEV_WASM_POOL_SIZE` (default 2) — so a 32-bank cart runs pool-many banks at
|
|
17
|
+
once instead of serializing 32 heal loops. Order + byte-exactness preserved.
|
|
18
|
+
- **`disasm({target:'project', background:true})` — the timeout-proof path.** For a large ROM, start
|
|
19
|
+
the disassembly in the background and get a `{jobId}` back IMMEDIATELY; the server keeps working
|
|
20
|
+
even if the call's client times out. Poll with `disasm({target:'project', job, outputDir})` — it
|
|
21
|
+
reports `regionsDone/regionsTotal` while running, then returns the exact same completion payload a
|
|
22
|
+
synchronous call would, once `status:'done'` (or the reason on `'error'`). Job state lives in a
|
|
23
|
+
`.romdev-job.json` in the output dir (stateless across calls; git-ignored). The sync path is
|
|
24
|
+
unchanged and remains the default for normal-size ROMs.
|
|
25
|
+
|
|
26
|
+
## 0.88.2 — 2026-07-13
|
|
27
|
+
|
|
28
|
+
Two fixes from a real-world `disasm({target:'project'})` + `build({output:'reassemble'})` run — a
|
|
29
|
+
1 MB commercial SNES ROM (which rebuilt **byte-identical**, validating 0.88.0 on real hardware data).
|
|
30
|
+
|
|
31
|
+
- **ROM data can't be committed by accident.** `disasm({target:'project'})` keeps a verbatim
|
|
32
|
+
`original.rom` (the reassemble splice template) in the project dir — copyrighted cartridge bytes.
|
|
33
|
+
It now also writes a `.gitignore` that excludes `original.rom` plus common ROM extensions, so a
|
|
34
|
+
scaffolded project can't check the ROM into git. If a `.gitignore` already exists, the rules are
|
|
35
|
+
appended (deduped), never clobbered. (No `*.md` in the list — it collides with Markdown / BUILD.md;
|
|
36
|
+
`original.rom` covers the template on every platform regardless.) The payload advertises
|
|
37
|
+
`romProtected: ".gitignore"`.
|
|
38
|
+
- **`readablePercent` no longer lies about padding.** A uniform-fill bank (all `$FF` or `$00`)
|
|
39
|
+
disassembles into junk instructions (`sbc $FFFFFF,x` …) and used to report a bogus ~100% readable —
|
|
40
|
+
so the *emptiest* bank looked the "most readable," inverting the "low % = data bank" heuristic. Fill
|
|
41
|
+
regions are now detected (≥99.5% one byte, ≥256 bytes) and reported honestly: `readablePercent: null`
|
|
42
|
+
+ `fill: true` + `fillByte`, excluded from the code-only `readablePercentAvg`, and labeled in the
|
|
43
|
+
region `.asm` header. The payload reports `fillRegions` count.
|
|
44
|
+
|
|
7
45
|
## 0.88.0 — 2026-07-12
|
|
8
46
|
|
|
9
47
|
- **`build({output:'reassemble'})` — the UNIFORM byte-exact ROUND-TRIP.** `disasm({target:'project'})`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "romdevtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.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",
|
|
@@ -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.
|
|
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",
|
package/src/mcp/tools/disasm.js
CHANGED
|
@@ -1043,40 +1043,147 @@ async function disassembleRomCore(args) {
|
|
|
1043
1043
|
return jsonContent({ ...baseResult, ok: true, asm });
|
|
1044
1044
|
}
|
|
1045
1045
|
|
|
1046
|
-
|
|
1046
|
+
// The job-status file dropped in the output dir so a background disassembly's
|
|
1047
|
+
// progress survives across MCP calls (no in-memory registry — poll reads the
|
|
1048
|
+
// file). Presence + `status` is the source of truth.
|
|
1049
|
+
const JOB_FILE = ".romdev-job.json";
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* disasm({target:'project'}) orchestrator. Three modes:
|
|
1053
|
+
* - default (sync): run to completion, return the full payload.
|
|
1054
|
+
* - background:true → START the work detached, return {jobId} immediately (for
|
|
1055
|
+
* multi-MB ROMs whose reassemble would time out the tool call).
|
|
1056
|
+
* - job:'<id>' → POLL: read the status file; return progress, or the final
|
|
1057
|
+
* payload once done, or the error if it failed.
|
|
1058
|
+
*/
|
|
1059
|
+
export async function disassembleProjectCore(args) {
|
|
1060
|
+
const { path: romPath, outputDir, platform, background, job } = args;
|
|
1061
|
+
|
|
1062
|
+
// ── POLL an existing background job ────────────────────────────────────────
|
|
1063
|
+
if (job) {
|
|
1064
|
+
if (!outputDir) throw new Error("disasm({target:'project', job}): outputDir is required to locate the job status file.");
|
|
1065
|
+
let st;
|
|
1066
|
+
try { st = JSON.parse(await readFile(nodePath.join(outputDir, JOB_FILE), "utf8")); }
|
|
1067
|
+
catch { throw new Error(`disasm({target:'project', job:'${job}'}): no job found in '${outputDir}' (missing ${JOB_FILE}). Start one with background:true first.`); }
|
|
1068
|
+
if (st.jobId !== job) throw new Error(`disasm({target:'project'}): job '${job}' doesn't match the job in '${outputDir}' ('${st.jobId}'). Poll the outputDir you started.`);
|
|
1069
|
+
if (st.status === "running") {
|
|
1070
|
+
return jsonContent({
|
|
1071
|
+
ok: null, status: "running", jobId: job, platform: st.platform, outputDir,
|
|
1072
|
+
regionsDone: st.regionsDone ?? 0, regionsTotal: st.regionsTotal ?? null,
|
|
1073
|
+
note: `Still disassembling — ${st.regionsDone ?? 0}/${st.regionsTotal ?? "?"} regions done. Poll again: disasm({target:'project', job:'${job}', outputDir:'${outputDir}'}).`,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
if (st.status === "error") {
|
|
1077
|
+
return jsonContent({ ok: false, status: "error", jobId: job, platform: st.platform, outputDir, error: st.error, note: `Background disassembly FAILED: ${st.error}` });
|
|
1078
|
+
}
|
|
1079
|
+
// done → hand back the stored final payload verbatim.
|
|
1080
|
+
return jsonContent(st.result);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ── START a background job ─────────────────────────────────────────────────
|
|
1084
|
+
if (background) {
|
|
1085
|
+
if (!outputDir) throw new Error("disasm({target:'project', background:true}): outputDir is required (the job writes its status + output there).");
|
|
1086
|
+
const resolved = platform ?? sniffPlatformFromPath(romPath);
|
|
1087
|
+
if (!resolved) throw new Error(`disassembleProject: could not detect platform from '${romPath}'. Pass platform explicitly.`);
|
|
1088
|
+
await mkdir(outputDir, { recursive: true });
|
|
1089
|
+
// A deterministic-but-unique id (no Date.now/random in scripts; here we're in
|
|
1090
|
+
// the server, so a monotonic counter + the dir is fine and readable).
|
|
1091
|
+
const jobId = `job-${resolved}-${(bgJobCounter++).toString(36)}`;
|
|
1092
|
+
const statusPath = nodePath.join(outputDir, JOB_FILE);
|
|
1093
|
+
await writeFile(statusPath, JSON.stringify({ jobId, status: "running", platform: resolved, regionsDone: 0, regionsTotal: null }, null, 2));
|
|
1094
|
+
// Fire-and-forget: run the work, updating the status file as it goes. Errors
|
|
1095
|
+
// are caught and recorded in the status file (the poll surfaces them) — an
|
|
1096
|
+
// unhandled rejection here must never crash the server.
|
|
1097
|
+
runProjectDisassembly(args, resolved, { statusPath, jobId }).then(
|
|
1098
|
+
async (payload) => { await writeFile(statusPath, JSON.stringify({ jobId, status: "done", platform: resolved, result: payload }, null, 2)); },
|
|
1099
|
+
async (err) => { await writeFile(statusPath, JSON.stringify({ jobId, status: "error", platform: resolved, error: String(err?.message ?? err) }, null, 2)).catch(() => {}); },
|
|
1100
|
+
);
|
|
1101
|
+
return jsonContent({
|
|
1102
|
+
ok: null, status: "running", jobId, platform: resolved, outputDir,
|
|
1103
|
+
note: `Disassembly started in the BACKGROUND (large ROM). Poll for completion: disasm({target:'project', job:'${jobId}', outputDir:'${outputDir}'}). ` +
|
|
1104
|
+
`The server keeps working even if this call's client times out; the poll returns the full result when done.`,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ── SYNC (default) ─────────────────────────────────────────────────────────
|
|
1109
|
+
const resolved = platform ?? sniffPlatformFromPath(romPath);
|
|
1110
|
+
if (!resolved) throw new Error(`disassembleProject: could not detect platform from '${romPath}'. Pass platform explicitly.`);
|
|
1111
|
+
await mkdir(outputDir, { recursive: true });
|
|
1112
|
+
return jsonContent(await runProjectDisassembly(args, resolved, null));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
let bgJobCounter = 1;
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* The actual disassemble-project work. Returns the payload OBJECT (not wrapped in
|
|
1119
|
+
* jsonContent — the caller wraps, so the same object can also be stored in the job
|
|
1120
|
+
* status file). `progress` (or null) receives {statusPath, jobId} to checkpoint
|
|
1121
|
+
* per-region completion for the poll surface.
|
|
1122
|
+
*/
|
|
1123
|
+
async function runProjectDisassembly({ path: romPath, outputDir }, resolved, progress) {
|
|
1047
1124
|
const { reassembleForPlatform, CPU_FAMILY } = await import("../../toolchains/common/reassemble.js");
|
|
1048
1125
|
const data = new Uint8Array(await readFile(romPath));
|
|
1049
|
-
const resolved = platform ?? sniffPlatformFromPath(romPath);
|
|
1050
|
-
if (!resolved) throw new Error(`disassembleProject: could not detect platform from '${romPath}'. Pass platform explicitly.`);
|
|
1051
1126
|
await mkdir(outputDir, { recursive: true });
|
|
1052
1127
|
|
|
1053
1128
|
// Plan the regions to disassemble for this platform (per-bank for banked
|
|
1054
1129
|
// ROMs; one region for flat ROMs). Each region → its own byte-exact .asm.
|
|
1055
1130
|
const regions = planRegions(resolved, data);
|
|
1056
1131
|
if (!regions.length) throw new Error(`disassembleProject: no regions planned for '${resolved}' (unsupported or empty ROM).`);
|
|
1132
|
+
if (progress) {
|
|
1133
|
+
await writeFile(nodePath.join(outputDir, JOB_FILE),
|
|
1134
|
+
JSON.stringify({ jobId: progress.jobId, status: "running", platform: resolved, regionsDone: 0, regionsTotal: regions.length }, null, 2)).catch(() => {});
|
|
1135
|
+
}
|
|
1057
1136
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1137
|
+
// Reassemble every region CONCURRENTLY. Each region is independent (its own
|
|
1138
|
+
// WASM worker job + its own file write), and the worker pool caps real
|
|
1139
|
+
// parallelism at ROM_DEV_WASM_POOL_SIZE — so firing all banks at once runs
|
|
1140
|
+
// pool-many at a time and cuts wall-time by that factor on multi-bank ROMs
|
|
1141
|
+
// (a 32-bank SNES cart no longer serializes 32 heal loops). Order is
|
|
1142
|
+
// preserved by mapping over indices. (For very large ROMs the async-job
|
|
1143
|
+
// path below still applies so the client never times out mid-run.)
|
|
1144
|
+
let regionsDone = 0;
|
|
1145
|
+
const out = await Promise.all(regions.map(async (reg) => {
|
|
1060
1146
|
// Known-data regions (e.g. the GBA cartridge header) are emitted as a
|
|
1061
1147
|
// clean `.byte` dump — byte-exact by construction, NOT a failed disasm.
|
|
1062
1148
|
const r = reg.kind === "data"
|
|
1063
1149
|
? { ok: true, readablePercent: 0, source: dataRegionSource(reg.bytes, reg.startAddress, CPU_FAMILY[resolved]), note: "data region (not code)" }
|
|
1064
1150
|
: await reassembleForPlatform({ platform: resolved, bytes: reg.bytes, startAddress: reg.startAddress });
|
|
1151
|
+
// A uniform-FILL region ($FF/$00 padding) disassembles into junk that
|
|
1152
|
+
// reports a high readablePercent — a trap (the emptiest bank looks the
|
|
1153
|
+
// "most readable"). Detect it and report readability HONESTLY as null +
|
|
1154
|
+
// a `fill` flag, so `readablePercent` never lies about padding.
|
|
1155
|
+
const fillByte = uniformFillByte(reg.bytes);
|
|
1156
|
+
const isFill = fillByte != null && reg.kind !== "data";
|
|
1157
|
+
const readablePercent = isFill ? null : r.readablePercent;
|
|
1065
1158
|
const header = `; ${reg.label} — ${reg.bytes.length} bytes @ $${reg.startAddress.toString(16).toUpperCase()} ` +
|
|
1066
1159
|
`(file 0x${reg.fileOffset.toString(16).toUpperCase()}), ${resolved}\n` +
|
|
1067
|
-
`; round-trip: ${r.ok ? "BYTE-EXACT" : "FAILED"} ·
|
|
1160
|
+
`; round-trip: ${r.ok ? "BYTE-EXACT" : "FAILED"} · ` +
|
|
1161
|
+
(isFill ? `FILL region (all $${fillByte.toString(16).toUpperCase().padStart(2, "0")}) — readability N/A`
|
|
1162
|
+
: `readable ${r.readablePercent}%`) +
|
|
1068
1163
|
(r.note ? ` · ${r.note}` : "") + "\n\n";
|
|
1069
1164
|
await writeFile(nodePath.join(outputDir, reg.file), header + r.source);
|
|
1070
|
-
|
|
1165
|
+
// Checkpoint progress for the poll surface (background jobs only).
|
|
1166
|
+
regionsDone++;
|
|
1167
|
+
if (progress) {
|
|
1168
|
+
await writeFile(nodePath.join(outputDir, JOB_FILE),
|
|
1169
|
+
JSON.stringify({ jobId: progress.jobId, status: "running", platform: resolved, regionsDone, regionsTotal: regions.length }, null, 2)).catch(() => {});
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1071
1172
|
region: reg.name, file: reg.file, startAddress: "$" + reg.startAddress.toString(16).toUpperCase(),
|
|
1072
|
-
bytes: reg.bytes.length, roundTripOk: r.ok, readablePercent
|
|
1173
|
+
bytes: reg.bytes.length, roundTripOk: r.ok, readablePercent,
|
|
1073
1174
|
...(reg.kind === "data" ? { kind: "data" } : {}),
|
|
1175
|
+
...(isFill ? { fill: true, fillByte: "$" + fillByte.toString(16).toUpperCase().padStart(2, "0") } : {}),
|
|
1074
1176
|
...(r.note ? { note: r.note } : {}),
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1177
|
+
};
|
|
1178
|
+
}));
|
|
1077
1179
|
|
|
1078
1180
|
const allOk = out.every((r) => r.roundTripOk);
|
|
1079
|
-
|
|
1181
|
+
// Average readability over CODE regions only — fill regions have no
|
|
1182
|
+
// meaningful percent (readablePercent:null) and would skew the average.
|
|
1183
|
+
const codeRegions = out.filter((r) => r.readablePercent != null);
|
|
1184
|
+
const avgReadable = codeRegions.length
|
|
1185
|
+
? Math.round(codeRegions.reduce((s, r) => s + r.readablePercent, 0) / codeRegions.length)
|
|
1186
|
+
: 0;
|
|
1080
1187
|
|
|
1081
1188
|
// Make the project TURNKEY: write the rebuild glue (data blobs, the exact
|
|
1082
1189
|
// build() call, and human-readable instructions) so it rebuilds without
|
|
@@ -1110,6 +1217,22 @@ export async function disassembleProjectCore({ path: romPath, outputDir, platfor
|
|
|
1110
1217
|
// ORIGINAL rom (kept as original.rom) at its file offset, so the header,
|
|
1111
1218
|
// inter-region gaps, and trailing pad come back verbatim. See toolchain.js.
|
|
1112
1219
|
await writeFile(nodePath.join(outputDir, "original.rom"), data);
|
|
1220
|
+
// original.rom is a verbatim copy of the source ROM (the reassemble splice
|
|
1221
|
+
// template) — copyrighted cartridge data that must NEVER be committed. Emit
|
|
1222
|
+
// a .gitignore that excludes it plus common ROM extensions so a scaffolded
|
|
1223
|
+
// project can't accidentally check the ROM into git. Append (deduped) if a
|
|
1224
|
+
// .gitignore already exists so we don't clobber the user's rules.
|
|
1225
|
+
// NOTE: no `*.md` — Genesis ROMs share that extension with Markdown, and
|
|
1226
|
+
// ignoring it would exclude BUILD.md. `original.rom` covers the template on
|
|
1227
|
+
// every platform regardless; the extension list is a belt-and-suspenders
|
|
1228
|
+
// for any raw ROM a user drops in the dir.
|
|
1229
|
+
await ensureGitignore(outputDir, [
|
|
1230
|
+
"# romdev: never commit ROM data (original.rom is the reassemble template)",
|
|
1231
|
+
"original.rom",
|
|
1232
|
+
"*.rom", "*.nes", "*.sfc", "*.smc", "*.gb", "*.gbc", "*.gba",
|
|
1233
|
+
"*.gen", "*.sms", "*.gg", "*.a26", "*.a78", "*.lnx", "*.pce", "*.gtr",
|
|
1234
|
+
".romdev-job.json",
|
|
1235
|
+
]);
|
|
1113
1236
|
const reassembleManifest = {
|
|
1114
1237
|
platform: resolved,
|
|
1115
1238
|
romTemplate: "original.rom", // relative to the project dir
|
|
@@ -1127,13 +1250,17 @@ export async function disassembleProjectCore({ path: romPath, outputDir, platfor
|
|
|
1127
1250
|
JSON.stringify(reassembleManifest, null, 2) + "\n"
|
|
1128
1251
|
);
|
|
1129
1252
|
|
|
1130
|
-
|
|
1253
|
+
// Return the raw payload OBJECT (the orchestrator wraps it in jsonContent
|
|
1254
|
+
// for the sync path, or stores it in the job status file for background).
|
|
1255
|
+
return {
|
|
1131
1256
|
ok: allOk,
|
|
1132
1257
|
path: romPath,
|
|
1133
1258
|
platform: resolved,
|
|
1134
1259
|
regions: out,
|
|
1135
1260
|
roundTrip: { regions: out.length, allByteExact: allOk, failed: out.filter((r) => !r.roundTripOk).map((r) => r.region) },
|
|
1136
|
-
readablePercentAvg: avgReadable,
|
|
1261
|
+
readablePercentAvg: avgReadable, // over CODE regions only; fill/padding excluded
|
|
1262
|
+
fillRegions: out.filter((r) => r.fill).length, // padding banks flagged (readablePercent:null), NOT counted as readable
|
|
1263
|
+
romProtected: ".gitignore", // original.rom + ROM extensions git-ignored so the ROM can't be committed
|
|
1137
1264
|
rebuild: {
|
|
1138
1265
|
blobs: writtenBlobs,
|
|
1139
1266
|
buildCall: absBuild, // the exact build({...}) args to reproduce the ROM
|
|
@@ -1151,7 +1278,45 @@ export async function disassembleProjectCore({ path: romPath, outputDir, platfor
|
|
|
1151
1278
|
? ` (build() one-call rebuild also available via rebuild.json.)`
|
|
1152
1279
|
: ``)
|
|
1153
1280
|
: `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).`,
|
|
1154
|
-
}
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* If a region is (near-)uniform fill — one byte value repeated — return that
|
|
1286
|
+
* byte; else null. Padding banks ($FF/$00) disassemble into junk that reports a
|
|
1287
|
+
* bogus-high readablePercent, so we flag them and exclude them from readability.
|
|
1288
|
+
* Threshold: ≥99.5% one value AND ≥256 bytes (small regions aren't "padding"),
|
|
1289
|
+
* so a real code bank with incidental repeats is never misflagged.
|
|
1290
|
+
*/
|
|
1291
|
+
function uniformFillByte(bytes) {
|
|
1292
|
+
if (!bytes || bytes.length < 256) return null;
|
|
1293
|
+
const first = bytes[0];
|
|
1294
|
+
let same = 0;
|
|
1295
|
+
for (let i = 0; i < bytes.length; i++) if (bytes[i] === first) same++;
|
|
1296
|
+
return same / bytes.length >= 0.995 ? first : null;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Ensure a project dir's .gitignore contains each of `entries`, without
|
|
1301
|
+
* clobbering an existing one. Creates the file if absent; otherwise appends only
|
|
1302
|
+
* the lines not already present (whitespace-trimmed match), preserving the user's
|
|
1303
|
+
* existing rules and order. Blank/comment lines are appended verbatim only if the
|
|
1304
|
+
* whole block is new (we don't dedupe comments against existing text).
|
|
1305
|
+
*/
|
|
1306
|
+
async function ensureGitignore(outputDir, entries) {
|
|
1307
|
+
const gi = nodePath.join(outputDir, ".gitignore");
|
|
1308
|
+
let existing = "";
|
|
1309
|
+
try { existing = await readFile(gi, "utf8"); } catch { /* no .gitignore yet */ }
|
|
1310
|
+
const have = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
|
|
1311
|
+
const toAdd = entries.filter((e) => {
|
|
1312
|
+
const t = e.trim();
|
|
1313
|
+
if (!t) return false;
|
|
1314
|
+
if (t.startsWith("#")) return existing === "" || !existing.includes(t); // keep the header once
|
|
1315
|
+
return !have.has(t);
|
|
1316
|
+
});
|
|
1317
|
+
if (!toAdd.length) return;
|
|
1318
|
+
const prefix = existing === "" ? "" : (existing.endsWith("\n") ? "" : "\n");
|
|
1319
|
+
await writeFile(gi, existing + prefix + toAdd.join("\n") + "\n");
|
|
1155
1320
|
}
|
|
1156
1321
|
|
|
1157
1322
|
/** Rewrite a planRebuild build()'s bare *Paths filenames to absolute paths. */
|
|
@@ -1178,8 +1343,9 @@ function renderBuildMd({ platform, romPath, outputDir, regions, blobs, build, ve
|
|
|
1178
1343
|
lines.push(`- \`${r.file}\` — ${r.region}${r.kind === "data" ? " (data)" : ""}, byte-exact${r.roundTripOk === false ? " ⚠ round-trip FAILED" : ""}.`);
|
|
1179
1344
|
}
|
|
1180
1345
|
for (const b of blobs) lines.push(`- \`${b.file}\` — ${b.bytes} bytes of binary data (extracted from the ROM; do not hand-edit).`);
|
|
1181
|
-
lines.push("- `original.rom` — a verbatim copy of the source ROM (the rebuild template; do not edit).");
|
|
1346
|
+
lines.push("- `original.rom` — a verbatim copy of the source ROM (the rebuild template; do not edit). **Copyrighted ROM data — never commit it. A `.gitignore` excluding it (and ROM extensions) is written for you.**");
|
|
1182
1347
|
lines.push("- `reassemble.json` — the region→offset manifest the one-call rebuild reads.");
|
|
1348
|
+
lines.push("- `.gitignore` — keeps `original.rom` + raw ROM files out of git (appended if you already had one).");
|
|
1183
1349
|
if (build) lines.push("- `rebuild.json` — the alternate cc65-native `build()` args below.");
|
|
1184
1350
|
lines.push("");
|
|
1185
1351
|
|
|
@@ -1547,7 +1713,11 @@ export function registerDisasmTools(server, z) {
|
|
|
1547
1713
|
"'project' = turn a ROM into a complete re-buildable disassembly in one call across all systems; splits into " +
|
|
1548
1714
|
"regions (PER-BANK on every banked format: NES mappers, SNES LoROM, GB MBC, Sega-mapper SMS/GG, MSX megaROM, " +
|
|
1549
1715
|
"2600 F8/F6/F4, 7800 SuperGame, >32KB HuCards), REASSEMBLES each and verifies BYTE-EXACT (`roundTripOk`); " +
|
|
1550
|
-
"non-faithful lines fall back to `.byte` so it ALWAYS rebuilds; `readablePercent` reports instruction-vs-data
|
|
1716
|
+
"non-faithful lines fall back to `.byte` so it ALWAYS rebuilds; `readablePercent` reports instruction-vs-data (a uniform-FILL " +
|
|
1717
|
+
"bank — all $FF/$00 padding — reports `readablePercent:null` + `fill:true`, NOT a bogus 100%). Also writes a `.gitignore` " +
|
|
1718
|
+
"so the kept `original.rom` (copyrighted ROM data) can't be committed. **LARGE ROM (≥512KB, e.g. a 1MB SNES cart)? Pass " +
|
|
1719
|
+
"`background:true`** — the reassemble can take minutes and would time out the call; you get a `{jobId}` immediately, then poll " +
|
|
1720
|
+
"`disasm({target:'project', job, outputDir})` (reports `regionsDone/regionsTotal`, then the full result when `status:'done'`). " +
|
|
1551
1721
|
"REBUILD (one call, ALL 15 classic platforms): `build({output:'reassemble', platform, path})` turns the project " +
|
|
1552
1722
|
"dir back into a BYTE-IDENTICAL ROM — it assembles each region + splices them into the kept `original.rom` " +
|
|
1553
1723
|
"(header/gaps/pad verbatim). Edit a region `.asm` first for a change: a same-length edit rebuilds a modified " +
|
|
@@ -1613,6 +1783,8 @@ export function registerDisasmTools(server, z) {
|
|
|
1613
1783
|
annotateFileOffsets: z.boolean().default(true).describe("target=rom: append `; @0xNNNN` file offset to every line (for romPatch)."),
|
|
1614
1784
|
// project
|
|
1615
1785
|
outputDir: z.string().optional().describe("target=project: directory to write the project into (one .asm per region). target=recompile: directory to write main.asm + nes_seam.asm for build({platform:'snes'})."),
|
|
1786
|
+
background: z.boolean().default(false).describe("target=project: run the disassembly in the BACKGROUND and return IMMEDIATELY with a {jobId} instead of blocking. Use this for LARGE ROMs (≥512KB — a multi-bank SNES/Genesis cart) where the full reassemble can take minutes and would otherwise time out the tool call. Poll with disasm({target:'project', job:'<jobId>', outputDir}) until status is 'done' (or 'error'); the final poll returns the same payload the synchronous call would have. Small ROMs don't need this — they finish well within the call."),
|
|
1787
|
+
job: z.string().optional().describe("target=project: poll a background job started with background:true. Pass the jobId you got back (and the same outputDir). Returns {status:'running', regionsDone, regionsTotal} while working, or the full completion payload once status is 'done'. On 'error' the failure reason is included."),
|
|
1616
1788
|
targetPlatform: z.string().optional().describe("target=recompile: the platform to EMIT (default 'snes'). The engine is generic (lift source→IR→emit target): 'snes' = 1:1 emulation-mode port with the PPU render layers (withShim/withRuntime); 'genesis' = real 6502→68000 LOGIC translation (presentation stubbed, verify with frame({op:'compareRam'})). Source `platform` is 'nes' today; more source lifters land as built."),
|
|
1617
1789
|
withShim: z.boolean().default(false).describe("target=recompile: phase-1 STATIC render (default off). Emit the NES-PPU-on-SNES shim — boots the original ROM, converts its tiles/nametable/palette to SNES VRAM/CGRAM data + a 65816 upload routine that draws the original's STATIC boot screen on SNES (verified on snes9x). Draws the first screen only; sprites don't animate. For a LIVE port use withRuntime instead."),
|
|
1618
1790
|
withRuntime: z.boolean().default(false).describe("target=recompile: phase-2 LIVE render (default off). Implies withShim (BG) and adds the per-frame runtime: each vblank it flushes the game's shadow OAM to SNES sprites and runs the game's own NMI handler, so SPRITES ANIMATE and the game's per-frame logic runs — the port plays, not just boots to a screenshot. Background is static from the shim; live nametable/scroll streaming is phase 3. Verified on snes9x."),
|