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 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.88.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.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",
@@ -1043,40 +1043,147 @@ async function disassembleRomCore(args) {
1043
1043
  return jsonContent({ ...baseResult, ok: true, asm });
1044
1044
  }
1045
1045
 
1046
- export async function disassembleProjectCore({ path: romPath, outputDir, platform }) {
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
- const out = [];
1059
- for (const reg of regions) {
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"} · readable ${r.readablePercent}%` +
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
- out.push({
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: r.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
- const avgReadable = Math.round(out.reduce((s, r) => s + r.readablePercent, 0) / out.length);
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
- return jsonContent({
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."),