romdevtools 0.41.0 → 0.43.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/package.json +3 -1
  3. package/src/analysis/analyze.js +1 -1
  4. package/src/analysis/backtrace.js +198 -0
  5. package/src/analysis/nes-ppu-runtime.js +220 -0
  6. package/src/analysis/nes-ppu-shim.js +263 -0
  7. package/src/analysis/pointer-table.js +84 -0
  8. package/src/analysis/recompile/emit-65816.js +105 -0
  9. package/src/analysis/recompile/emit-m68k.js +295 -0
  10. package/src/analysis/recompile/index.js +169 -0
  11. package/src/analysis/recompile/ir.js +123 -0
  12. package/src/analysis/recompile/lift-6502.js +176 -0
  13. package/src/analysis/recompile-65816.js +533 -0
  14. package/src/cheats/gamegenie.js +14 -2
  15. package/src/cores/registry.js +43 -51
  16. package/src/mcp/state.js +91 -2
  17. package/src/mcp/tools/cheats.js +10 -1
  18. package/src/mcp/tools/disasm.js +329 -21
  19. package/src/mcp/tools/find-references.js +93 -2
  20. package/src/mcp/tools/frame.js +440 -4
  21. package/src/mcp/tools/index.js +34 -1
  22. package/src/mcp/tools/lifecycle.js +53 -24
  23. package/src/mcp/tools/memory.js +1 -1
  24. package/src/mcp/tools/playtest.js +24 -1
  25. package/src/mcp/tools/project.js +1 -1
  26. package/src/mcp/tools/rendering-context.js +4 -2
  27. package/src/mcp/tools/watch-memory.js +87 -9
  28. package/src/mcp/util.js +45 -0
  29. package/src/playtest/playtest.js +115 -46
  30. package/src/toolchains/cc65/da65.js +7 -0
  31. package/src/cores/wasm/bluemsx_libretro.js +0 -2
  32. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  33. package/src/cores/wasm/fceumm_libretro.js +0 -2
  34. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  35. package/src/cores/wasm/gambatte_libretro.js +0 -2
  36. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  37. package/src/cores/wasm/geargrafx_libretro.js +0 -2
  38. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  39. package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
  40. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  41. package/src/cores/wasm/handy_libretro.js +0 -2
  42. package/src/cores/wasm/handy_libretro.wasm +0 -0
  43. package/src/cores/wasm/mgba_libretro.js +0 -2
  44. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  45. package/src/cores/wasm/prosystem_libretro.js +0 -2
  46. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  47. package/src/cores/wasm/snes9x_libretro.js +0 -2
  48. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  49. package/src/cores/wasm/stella2014_libretro.js +0 -2
  50. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  51. package/src/cores/wasm/vice_x64_libretro.js +0 -2
  52. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
@@ -7,6 +7,8 @@ import { parseSymbols, buildSymbolMap } from "../../toolchains/common/symbols.js
7
7
  import { registersForPlatform } from "../../platforms/common/registers.js";
8
8
  import { findReferencesCore } from "./find-references.js";
9
9
  import { analyzeCfg, analyzeXrefs, analyzeFunctions, analyzeDecompile } from "../../analysis/analyze.js";
10
+ import { recompileNesToSnes, sliceFirstRoutine } from "../../analysis/recompile-65816.js";
11
+ import { decodePointerTable, reverseLookup } from "../../analysis/pointer-table.js";
10
12
 
11
13
  /** cfg/xrefs/functions all operate on a ROM file. Reuse the `path` arg. */
12
14
  function requireRomPath(args) {
@@ -14,6 +16,42 @@ function requireRomPath(args) {
14
16
  return args.path;
15
17
  }
16
18
 
19
+ /**
20
+ * CPU address → file offset for a platform's ROM image, dispatching to the
21
+ * per-platform mapper. Returns the offset or null (out of range / unmapped /
22
+ * unsupported platform). The single home for "where in the file is CPU address
23
+ * X" — used by file-offset annotation AND target=pointerTable.
24
+ * @param {string} platform
25
+ * @param {Uint8Array} data full ROM image
26
+ * @param {number} cpuAddr
27
+ * @param {{bank?:number, snesMapper?:number}} [opts]
28
+ */
29
+ function cpuAddrToFileOffset(platform, data, cpuAddr, opts = {}) {
30
+ try {
31
+ switch (platform) {
32
+ case "snes": return mapSnesAddress(data, cpuAddr, 1, opts.snesMapper).fileOffset;
33
+ case "sms": case "gg": return mapSmsAddress(data, cpuAddr, 1).fileOffset;
34
+ case "gb": case "gbc": return mapGbAddress(data, cpuAddr, 1, opts.bank).fileOffset;
35
+ case "atari2600": return mapAtari2600Address(data, cpuAddr, 1, opts.bank ?? 0).fileOffset;
36
+ case "atari7800": return mapAtari7800Address(data, cpuAddr, 1, opts.bank ?? 0).fileOffset;
37
+ case "c64": return mapC64Address(data, cpuAddr, 1, opts.bank ?? 0).fileOffset;
38
+ case "genesis": case "megadrive": case "md": return mapGenesisAddress(data, cpuAddr, 1).fileOffset;
39
+ case "nes": return mapNesAddress(data, cpuAddr, 1, opts.bank).fileOffset;
40
+ default: return null; // msx/pce/lynx/gba: no static address mapper here
41
+ }
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** Platforms target=pointerTable can map a CPU address for (has a mapper above).
48
+ * Their default jump-table word order: 6502/Z80/65816 are little-endian; the
49
+ * m68k (Genesis) stores words BIG-endian, so a Genesis `dc.w` table is BE. */
50
+ const POINTER_TABLE_PLATFORMS = new Set([
51
+ "nes", "snes", "sms", "gg", "gb", "gbc", "atari2600", "atari7800", "c64", "genesis", "megadrive", "md",
52
+ ]);
53
+ const BIG_ENDIAN_TABLE_PLATFORMS = new Set(["genesis", "megadrive", "md"]);
54
+
17
55
  // ── Per-platform CPU-address → file-offset mappers ────────────────
18
56
  // Each returns { bytes, fileOffset, cpu, notes } given the full ROM
19
57
  // bytes and a desired CPU address. Throws with a clear message if the
@@ -905,25 +943,12 @@ async function disassembleRomCore(args) {
905
943
  exitCode = r.exitCode;
906
944
  }
907
945
 
908
- // File-offset annotation: per-line cpu→file translator.
946
+ // File-offset annotation: per-line cpu→file translator (shared mapper).
947
+ // `args.bank` maps $8000-$BFFF to the chosen switchable bank; mapNesAddress
948
+ // ignores it for $C000+ (fixed top bank), so each line points at the right
949
+ // PRG offset.
909
950
  if (annotateFileOffsetsFlag) {
910
- const cpuToFile = (cpuAddr) => {
911
- try {
912
- if (resolved === "snes") return mapSnesAddress(data, cpuAddr, 1, mapper).fileOffset;
913
- if (resolved === "sms" || resolved === "gg") return mapSmsAddress(data, cpuAddr, 1).fileOffset;
914
- if (resolved === "gb" || resolved === "gbc") return mapGbAddress(data, cpuAddr, 1, args.bank).fileOffset;
915
- if (resolved === "atari2600") return mapAtari2600Address(data, cpuAddr, 1, args.bank ?? 0).fileOffset;
916
- if (resolved === "atari7800") return mapAtari7800Address(data, cpuAddr, 1, args.bank ?? 0).fileOffset;
917
- if (resolved === "c64") return mapC64Address(data, cpuAddr, 1, args.bank ?? 0).fileOffset;
918
- if (resolved === "genesis") return mapGenesisAddress(data, cpuAddr, 1).fileOffset;
919
- // `args.bank` maps $8000-$BFFF to the chosen switchable bank;
920
- // mapNesAddress ignores it for $C000+ (fixed top bank), so each
921
- // annotated line points at the correct PRG offset.
922
- return mapNesAddress(data, cpuAddr, 1, args.bank).fileOffset;
923
- } catch {
924
- return null;
925
- }
926
- };
951
+ const cpuToFile = (cpuAddr) => cpuAddrToFileOffset(resolved, data, cpuAddr, { bank: args.bank, snesMapper: mapper });
927
952
  // Secondary translator for NES — also report the header-stripped
928
953
  // PRG offset, since patchFile against `prg.bin` (from extractCart)
929
954
  // needs the header-less frame.
@@ -1144,6 +1169,276 @@ function renderBuildMd({ platform, romPath, regions, blobs, build, verifiable, n
1144
1169
  return lines.join("\n") + "\n";
1145
1170
  }
1146
1171
 
1172
+ /**
1173
+ * target:'recompile' — NES (6502) → SNES (65816) static recompile (emit backend
1174
+ * phase 1). Reads the NES ROM's PRG, disassembles the reset routine with da65,
1175
+ * translates it to asar-ready 65816 (the 6502 logic runs in EMULATION mode), and
1176
+ * returns the main.asm + seam include + a residue report. Optionally writes the
1177
+ * sources to `outputDir`. Build the result with build({platform:'snes'}) and
1178
+ * verify it with frame({op:'sideBySide'}) against the original.
1179
+ *
1180
+ * v1 scope: NROM, documented 6502, the reset/boot routine. The PPU/APU seam is
1181
+ * stubbed (no real NES-PPU-on-SNES runtime yet) so the port boots + runs the
1182
+ * logic but renders blank — the runtime shim is a separate task.
1183
+ */
1184
+ /**
1185
+ * target=pointerTable — STATIC decode of a jump/pointer table into an
1186
+ * index→handler map (+ optional reverse lookup). The static complement to the
1187
+ * LIVE resolveJumptable. Handles contiguous LE/BE tables, SPLIT lo/hi arrays at
1188
+ * two bases, and the 6502 RTS-trick (+1). Works on every platform with an address
1189
+ * mapper (nes/snes/sms/gg/gb/gbc/2600/7800/c64/genesis); banked carts honor `bank`.
1190
+ * The default endian follows the CPU (Genesis/m68k → BE; everything else → LE),
1191
+ * overridable via `endian`.
1192
+ */
1193
+ async function pointerTableCore(args) {
1194
+ const { platform, loBase, hiBase, count, convention = "direct", reverseHandler, bank } = args;
1195
+ if (loBase == null) throw new Error("disasm({target:'pointerTable'}): loBase (the table / low-byte base) is required.");
1196
+ if (count == null) throw new Error("disasm({target:'pointerTable'}): count (number of entries) is required.");
1197
+ const romPath = requireRomPath(args);
1198
+ const data = new Uint8Array(await readFile(romPath));
1199
+ const resolved = platform ?? (/\.nes$/i.test(romPath) ? "nes" : /\.(sfc|smc)$/i.test(romPath) ? "snes" : /\.gb$/i.test(romPath) ? "gb" : /\.sms$/i.test(romPath) ? "sms" : /\.(gen|md|bin)$/i.test(romPath) ? "genesis" : null);
1200
+ if (!resolved || !POINTER_TABLE_PLATFORMS.has(resolved)) {
1201
+ throw new Error(`disasm({target:'pointerTable'}): platform '${resolved ?? "unknown"}' has no static address mapper. Supported: ${[...POINTER_TABLE_PLATFORMS].join(", ")}. (msx/pce/lynx/gba: locate the table live with breakpoint({on:'jumptable'}).)`);
1202
+ }
1203
+ // CPU address → file offset via the platform mapper (banked-cart aware).
1204
+ const toOffset = (cpuAddr) => cpuAddrToFileOffset(resolved, data, cpuAddr, { bank });
1205
+ // Default endian follows the CPU's word order unless the caller overrides it.
1206
+ const endian = args.endian ?? (BIG_ENDIAN_TABLE_PLATFORMS.has(resolved) ? "BE" : "LE");
1207
+ const { entries, form } = decodePointerTable({ data, toOffset, count, loBase, hiBase, convention, endian });
1208
+ const fmt = (n) => "$" + (n & 0xffff).toString(16).toUpperCase().padStart(4, "0");
1209
+ const out = {
1210
+ platform: resolved,
1211
+ form,
1212
+ count,
1213
+ entries: entries.map((e) => ({ index: e.index, handler: fmt(e.handler), storedWord: fmt(e.storedWord) })),
1214
+ };
1215
+ if (reverseHandler != null) {
1216
+ const indices = reverseLookup(entries, reverseHandler);
1217
+ out.reverse = {
1218
+ handler: fmt(reverseHandler),
1219
+ indices,
1220
+ note: indices.length
1221
+ ? `dispatch index/indices ${indices.join(", ")} land on ${fmt(reverseHandler)}.`
1222
+ : `no table index reaches ${fmt(reverseHandler)} (check loBase/hiBase/count/convention, or it's reached another way).`,
1223
+ };
1224
+ }
1225
+ out.note = "index→handler decoded statically. For a SPLIT table pass loBase (low array) + hiBase (high array); for a contiguous `dw` table pass only loBase. convention:'rts+1' adds 1 (6502 RTS trick). reverseHandler answers 'which state triggers this routine?'. (For a COMPUTED dispatcher whose table you can't locate statically, use the live breakpoint({on:'jumptable'}).)";
1226
+ return out;
1227
+ }
1228
+
1229
+ async function recompileCore(args) {
1230
+ const { platform } = args;
1231
+ const targetPlatform = args.targetPlatform || "snes";
1232
+ if (platform && platform !== "nes") {
1233
+ throw new Error(`disasm({target:'recompile'}): only NES source is supported today (got '${platform}'). The engine is generic (lift→IR→emit); other source lifters land as they're built.`);
1234
+ }
1235
+ // The generic engine targets any platform with a registered emitter. SNES is the
1236
+ // 1:1 emulation-mode path (with the PPU shim/runtime render layers); Genesis is a
1237
+ // real 6502→68000 LOGIC translation (presentation seam stubbed — verify with the
1238
+ // RAM-diff oracle, frame({op:'compareRam'})).
1239
+ const { supportedPairs } = await import("../../analysis/recompile/index.js");
1240
+ const validTargets = new Set(supportedPairs().filter((p) => p.startsWith("nes→")).map((p) => p.split("→")[1]));
1241
+ if (!validTargets.has(targetPlatform)) {
1242
+ throw new Error(`disasm({target:'recompile'}): no emitter for target '${targetPlatform}'. Supported NES→ targets: ${[...validTargets].join(", ")}.`);
1243
+ }
1244
+ const romPath = requireRomPath(args);
1245
+ const rom = new Uint8Array(await readFile(romPath));
1246
+ // iNES validation: "NES\x1a" magic, then a 16-byte header, then PRG. v1 handles
1247
+ // NROM (mapper 0, 16KB or 32KB PRG). Give size vs mapper their OWN errors so the
1248
+ // message is never misleading (a 24KB mapper-0 ROM isn't a "mapped cart").
1249
+ const hasInes = rom.length >= 16 && rom[0] === 0x4e && rom[1] === 0x45 && rom[2] === 0x53 && rom[3] === 0x1a;
1250
+ if (!hasInes) {
1251
+ throw new Error("disasm({target:'recompile'}): not an iNES file (missing 'NES\\x1a' magic). Phase 1 takes a raw .nes ROM.");
1252
+ }
1253
+ const mapper = (rom[6] >> 4) | (rom[7] & 0xf0);
1254
+ if (mapper !== 0) {
1255
+ throw new Error(`disasm({target:'recompile'}): mapper ${mapper} is not supported in phase 1 — only NROM (mapper 0). Bank-switched carts need the per-bank recompile path (not yet built).`);
1256
+ }
1257
+ const prgSize = rom.length - 16;
1258
+ if (prgSize !== 0x4000 && prgSize !== 0x8000) {
1259
+ throw new Error(`disasm({target:'recompile'}): expected an NROM PRG of 16KB or 32KB; got ${prgSize} bytes. Phase 1 handles only those two sizes.`);
1260
+ }
1261
+ const prg = rom.subarray(16, 16 + prgSize);
1262
+ const prgBase = prgSize === 0x4000 ? 0xC000 : 0x8000; // 16KB mirrors into $C000
1263
+
1264
+ // Disassemble from the REAL reset vector, not blindly from the PRG base. The
1265
+ // reset routine lives wherever $FFFC/$FFFD point — often deep in PRG, with data
1266
+ // or padding ($00 = brk) at the base. Starting at the base translated that
1267
+ // padding as 6 garbage `brk`s; starting at the reset vector yields the actual
1268
+ // boot routine (robotfindskitten: 6-instr-garbage → 70-instr clean, 0 residue).
1269
+ const resetVec = prg[prg.length - 4] | (prg[prg.length - 3] << 8);
1270
+ const resetOff = resetVec - prgBase;
1271
+ if (resetOff < 0 || resetOff >= prg.length) {
1272
+ throw new Error(`disasm({target:'recompile'}): reset vector $${resetVec.toString(16)} is outside the PRG window ($${prgBase.toString(16)}-$FFFF). Corrupt ROM or unexpected layout.`);
1273
+ }
1274
+ // Slice the PRG at the reset vector so da65 starts decoding real code there.
1275
+ const codeBytes = prg.subarray(resetOff);
1276
+ const { runDa65 } = await import("../../toolchains/cc65/da65.js");
1277
+ const da = await runDa65({ bytes: codeBytes, cpu: "6502", startAddress: resetVec, options: ["--comments", "4"] });
1278
+ const da65Full = da.asm ?? "";
1279
+ // Slice the first routine (the reset path) — a flat disasm renders data tables
1280
+ // after it as bogus code. Phase 1 = the boot routine.
1281
+ const da65Asm = sliceFirstRoutine(da65Full);
1282
+
1283
+ // Optional NES-PPU-on-SNES shim: boot the original ROM, read its PPU state,
1284
+ // convert tiles/nametable/palette to SNES formats, and emit a shim that draws
1285
+ // that static boot picture.
1286
+ //
1287
+ // STATUS (WORKING, default OFF): the JS-side conversion (NES 2bpp→SNES 4bpp
1288
+ // tiles, NES palette→BGR555 CGRAM, nametable→tilemap) is correct and
1289
+ // unit-tested, and the emitted 65816 UPLOAD routine now DMAs all three to SNES
1290
+ // VRAM/CGRAM and turns the screen on — verified end-to-end on snes9x
1291
+ // (test/recompile-shim-render.test.js). It is still OFF by default because
1292
+ // phase 1 only draws the STATIC first screen: after the shim, the recompiled
1293
+ // NES logic runs against a STUBBED PPU seam, so animation/scroll/sprites are
1294
+ // not maintained (the next layer). Opt in with withShim:true for the static
1295
+ // boot picture. (frame compareRender/findDiverge are the oracles to debug it.)
1296
+ // The phase-2 runtime (withRuntime) implies the shim (it needs the BG + tiles
1297
+ // the shim uploads); enabling it turns the static port into a LIVE one (sprites
1298
+ // animate each vblank, the game's NMI runs). withShim alone is the static path.
1299
+ // The PPU shim/runtime are NES-PPU-on-SNES render layers — SNES target only. A
1300
+ // Genesis (or other) target is a LOGIC port (presentation seam stubbed); verify
1301
+ // it with the RAM-diff oracle, not a rendered screen.
1302
+ const snesTarget = targetPlatform === "snes";
1303
+ const wantRuntime = snesTarget && args.withRuntime === true;
1304
+ const wantShim = snesTarget && (args.withShim === true || wantRuntime);
1305
+ let shimAsm = null;
1306
+ let shimInfo = null;
1307
+ if (wantShim) {
1308
+ try {
1309
+ shimInfo = await buildNesPpuShim(rom);
1310
+ shimAsm = shimInfo.asm;
1311
+ } catch (e) {
1312
+ shimInfo = { error: e.message };
1313
+ }
1314
+ }
1315
+
1316
+ // Phase-2 runtime: disassemble the NES NMI handler ($FFFA) so the runtime's
1317
+ // vblank handler can call the game's own per-frame logic, and emit the runtime.
1318
+ let runtimeAsm = null;
1319
+ let nmiDa65Asm = null;
1320
+ let nmiInfo = null;
1321
+ if (wantRuntime) {
1322
+ try {
1323
+ const nmiVec = prg[prg.length - 6] | (prg[prg.length - 5] << 8);
1324
+ const nmiOff = nmiVec - prgBase;
1325
+ let nesNmiLabel = null;
1326
+ if (nmiOff >= 0 && nmiOff < prg.length && nmiVec !== resetVec) {
1327
+ const nmiDa = await runDa65({ bytes: prg.subarray(nmiOff), cpu: "6502", startAddress: nmiVec, options: ["--comments", "4"] });
1328
+ nmiDa65Asm = sliceFirstRoutine(nmiDa.asm ?? "");
1329
+ // The translated NMI's entry label (derive it the same way the recompiler
1330
+ // will) so the runtime calls the right routine.
1331
+ const { entry: ne } = recompileNesToSnes(nmiDa65Asm, { stubUndefined: false });
1332
+ nesNmiLabel = ne === "RECOMPILE_ENTRY" ? "RECOMPILE_NMI_ENTRY" : ne;
1333
+ }
1334
+ const { emitPpuRuntime } = await import("../../analysis/nes-ppu-runtime.js");
1335
+ runtimeAsm = emitPpuRuntime({ nesNmiLabel });
1336
+ nmiInfo = { nmiVector: "$" + nmiVec.toString(16).toUpperCase().padStart(4, "0"), gameNmi: nesNmiLabel };
1337
+ } catch (e) {
1338
+ nmiInfo = { error: e.message };
1339
+ }
1340
+ }
1341
+
1342
+ const { recompile } = await import("../../analysis/recompile/index.js");
1343
+ const { mainAsm, seamAsm, seamFile, residue, entry, nmiEntry, instrCount, seamCount, stubbed, targetIsa } =
1344
+ recompile(da65Asm, {
1345
+ source: "nes",
1346
+ target: targetPlatform,
1347
+ withShim: !!shimAsm,
1348
+ withRuntime: !!runtimeAsm,
1349
+ nmiSourceAsm: nmiDa65Asm,
1350
+ });
1351
+
1352
+ const runtimeOn = !!runtimeAsm;
1353
+ let written = null;
1354
+ if (args.outputDir) {
1355
+ await mkdir(args.outputDir, { recursive: true });
1356
+ const mainPath = nodePath.join(args.outputDir, "main.asm");
1357
+ await writeFile(mainPath, mainAsm);
1358
+ written = { mainAsm: mainPath };
1359
+ // The phase-2 runtime supplies its own seam; the phase-1 path uses nes_seam.
1360
+ if (runtimeOn) {
1361
+ const rtPath = nodePath.join(args.outputDir, "nes_ppu_runtime.asm");
1362
+ await writeFile(rtPath, runtimeAsm);
1363
+ written.runtimeAsm = rtPath;
1364
+ } else {
1365
+ const seamPath = nodePath.join(args.outputDir, seamFile);
1366
+ await writeFile(seamPath, seamAsm);
1367
+ written.seamAsm = seamPath;
1368
+ }
1369
+ if (shimAsm) {
1370
+ const shimPath = nodePath.join(args.outputDir, "nes_ppu_shim.asm");
1371
+ await writeFile(shimPath, shimAsm);
1372
+ written.shimAsm = shimPath;
1373
+ }
1374
+ }
1375
+
1376
+ const shimDrawn = !!shimAsm;
1377
+ return jsonContent({
1378
+ ok: true,
1379
+ source: "nes", target: targetPlatform, targetIsa,
1380
+ resetVector: "$" + resetVec.toString(16).toUpperCase().padStart(4, "0"),
1381
+ entry,
1382
+ ...(runtimeOn ? { nmiEntry, nmi: nmiInfo } : {}),
1383
+ instrCount, seamCount,
1384
+ stubbedCallees: stubbed,
1385
+ residue,
1386
+ ...(snesTarget ? {
1387
+ shim: shimDrawn
1388
+ ? { applied: true, phase: runtimeOn ? "live-background" : "static-boot-picture", tiles: shimInfo.tileCount, note: "Emitted the NES-PPU-on-SNES shim (converted tiles/nametable/palette → VRAM/CGRAM, BG1 on). It draws the original ROM's boot screen background." }
1389
+ : { applied: false, reason: shimInfo?.error || "withShim not set (default off)", note: "No PPU shim — the port runs the logic but renders blank. Pass withShim:true (static) or withRuntime:true (live) to draw the original ROM's picture on SNES." },
1390
+ runtime: runtimeOn
1391
+ ? { applied: true, phase: "live-sprites", gameNmi: nmiInfo?.gameNmi || null, note: "PHASE 2: the per-frame runtime is wired — each vblank it flushes the game's shadow OAM to SNES sprites and runs the game's NMI handler, so sprites ANIMATE (not a static screenshot). Background is from the shim; live nametable streaming is phase 3. Build all emitted files together with build({platform:'snes'})." }
1392
+ : { applied: false, note: "Static port (no per-frame runtime). Pass withRuntime:true to animate sprites + run the game's NMI each vblank." },
1393
+ } : {
1394
+ port: { kind: "logic-recompile", targetIsa, note: `LOGIC port: the NES 6502 was TRANSLATED to ${targetIsa} (not emulated) — game logic runs, the PPU/APU presentation seam is STUBBED. Build with the platform's toolchain, then VERIFY with the RAM-diff oracle: load the original NES ROM and this port side by side and frame({op:'compareRam'}) the work-RAM mirror. Presentation (a ${targetPlatform} render runtime) is a separate layer.` },
1395
+ }),
1396
+ note:
1397
+ `Recompiled the NES reset${runtimeOn ? " + NMI" : ""} routine(s) to ${targetIsa}. ${instrCount} instrs, ${seamCount} PPU/APU seam calls, ` +
1398
+ `${stubbed.length} callee(s) stubbed (isolation), ${residue.length} residue line(s). ` +
1399
+ (snesTarget
1400
+ ? (runtimeOn
1401
+ ? "PHASE 2: the 6502 logic runs in emulation mode; the runtime flushes sprites + runs the game NMI every vblank, and the shim draws the BG — so the port is LIVE (sprites move). "
1402
+ : shimDrawn
1403
+ ? `The 6502 logic runs in 65816 EMULATION mode; the shim draws the converted static boot picture (${shimInfo.tileCount} tiles). `
1404
+ : "The 6502 logic runs in 65816 EMULATION mode; the hardware seam is STUBBED so the port renders blank. ")
1405
+ : `The 6502 logic is TRANSLATED to ${targetIsa} (real ISA translation, not emulation); the PPU/APU seam is STUBBED so it's a LOGIC port — verify with frame({op:'compareRam'}) vs the NES original. `) +
1406
+ (written
1407
+ ? `Wrote ${Object.values(written).join(" + ")}. Build with the ${targetPlatform} toolchain; then loadMedia + frame({op:'compareRam'}) vs the NES original.`
1408
+ : `Pass outputDir to write the .asm files to disk for build({platform:'${targetPlatform}'}).`),
1409
+ ...(written ? { written } : { mainAsm, ...(runtimeOn ? { runtimeAsm } : { seamAsm }), ...(shimAsm ? { shimAsm } : {}) }),
1410
+ });
1411
+ }
1412
+
1413
+ /**
1414
+ * Boot a NES ROM in a throwaway host, read its PPU state after boot, and build
1415
+ * the NES-PPU-on-SNES shim (converted tiles/nametable/palette + the DMA
1416
+ * routine). Returns { asm, tileCount }. Throws if the core/regions are
1417
+ * unavailable.
1418
+ * @param {Uint8Array} romBytes full iNES file
1419
+ */
1420
+ async function buildNesPpuShim(romBytes) {
1421
+ const { resolveCore } = await import("../../cores/registry.js");
1422
+ const { LibretroHost } = await import("../../host/index.js");
1423
+ const { buildSnesAssets, emitPpuShim } = await import("../../analysis/nes-ppu-shim.js");
1424
+ const core = resolveCore("nes");
1425
+ if (!core) throw new Error("NES core unavailable for shim boot");
1426
+ const host = new LibretroHost();
1427
+ try {
1428
+ await host.loadCore(core.jsPath, core.wasmPath);
1429
+ await host.loadMedia({ platform: "nes", bytes: romBytes, virtualName: "/rom.nes" });
1430
+ // Boot long enough for the game to finish its initial PPU upload.
1431
+ host.stepFrames(120);
1432
+ const chr = host.readMemory("nes_chr", 0, Math.min(0x2000, host.regionSize("nes_chr") || 0x2000));
1433
+ const nt = host.readMemory("nes_nametables", 0, 0x400); // first nametable: 960 tiles + 64 attr
1434
+ const pal = host.readMemory("nes_palette", 0, 32);
1435
+ const assets = buildSnesAssets({ chr, nametable: nt, palette: pal });
1436
+ return { asm: emitPpuShim(assets), tileCount: assets.tileCount };
1437
+ } finally {
1438
+ try { host.unloadMedia(); } catch { /* best-effort */ }
1439
+ }
1440
+ }
1441
+
1147
1442
  export function registerDisasmTools(server, z) {
1148
1443
  server.tool(
1149
1444
  "disasm",
@@ -1190,7 +1485,7 @@ export function registerDisasmTools(server, z) {
1190
1485
  "LLM folds it. `address` for all four comes from target:'functions' (a CPU/virtual address; the file-offset " +
1191
1486
  "mapping is handled for you).",
1192
1487
  {
1193
- target: z.enum(["bytes", "rom", "project", "references", "cfg", "xrefs", "functions", "decompile", "resolveJumptable"]).describe("bytes = raw chunk; rom = mapper-aware ROM; project = full rebuildable disasm; references = flat da65 operand-refs to an address; functions/cfg/xrefs = Rizin RE engine (function list / control-flow graph / deep graph xrefs); decompile = Ghidra C pseudocode; resolveJumptable = recover a computed-jump dispatcher's targets (LIVE — redirects to breakpoint({on:'jumptable'}), which runs the emulator and records the real switch arms a static decompiler can't follow). See the tool description for the RE loop + the decompile altitude rule + per-CPU quality (all 14 platforms)."),
1488
+ target: z.enum(["bytes", "rom", "project", "references", "cfg", "xrefs", "functions", "decompile", "resolveJumptable", "pointerTable", "recompile"]).describe("bytes = raw chunk; rom = mapper-aware ROM; project = full rebuildable disasm; references = flat da65 operand-refs to an address; functions/cfg/xrefs = Rizin RE engine (function list / control-flow graph / deep graph xrefs); decompile = Ghidra C pseudocode; resolveJumptable = recover a computed-jump dispatcher's targets (LIVE — redirects to breakpoint({on:'jumptable'}), which runs the emulator and records the real switch arms a static decompiler can't follow); recompile = EMIT backend — statically recompile a NES ROM's reset routine to SNES 65816 asar source that builds + boots (phase 1: NROM, 6502→65816 emulation mode, PPU/APU seam STUBBED). See the tool description for the RE loop + the decompile altitude rule + per-CPU quality (all 14 platforms)."),
1194
1489
  // shared
1195
1490
  path: z.string().optional().describe("target=bytes: raw binary path. target=rom/project/references: ROM file path."),
1196
1491
  base64: z.string().optional().describe("target=bytes: base64 of the bytes (OR `path`)."),
@@ -1206,7 +1501,7 @@ export function registerDisasmTools(server, z) {
1206
1501
  symbolsText: z.string().optional().describe("target=bytes: inline symbol-file text."),
1207
1502
  symbolsFormat: z.enum(["wla", "cc65-lbl"]).optional().describe("target=bytes: explicit symbol-file format override."),
1208
1503
  // rom
1209
- bank: z.number().int().min(0).max(255).optional().describe("target=rom: switchable ROM bank to map into the windowed slot (NES mapper>0 $8000 / GB $4000; also 2600/7800/c64)."),
1504
+ bank: z.number().int().min(0).max(255).optional().describe("target=rom / pointerTable: switchable ROM bank to map into the windowed slot (NES mapper>0 $8000 / GB $4000; also 2600/7800/c64)."),
1210
1505
  thumb: z.boolean().default(false).describe("target=rom: GBA — disassemble as THUMB (16-bit) instead of ARM."),
1211
1506
  endAddress: z.number().int().min(0).max(0xffffff).optional().describe("target=rom: CPU end address (inclusive); alternative to length."),
1212
1507
  untilReturn: z.boolean().default(false).describe("target=rom: stop at the first return/unconditional-jump (rts/rti/rtl/jmp, or ret/reti/jp per CPU) — grab one routine."),
@@ -1219,10 +1514,21 @@ export function registerDisasmTools(server, z) {
1219
1514
  annotateRegisters: z.boolean().default(true).describe("target=rom: append `; PPUMASK` etc. to operands hitting a known hardware register."),
1220
1515
  annotateFileOffsets: z.boolean().default(true).describe("target=rom: append `; @0xNNNN` file offset to every line (for romPatch)."),
1221
1516
  // project
1222
- outputDir: z.string().optional().describe("target=project: directory to write the project into (one .asm per region)."),
1517
+ 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'})."),
1518
+ 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."),
1519
+ 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."),
1520
+ 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."),
1223
1521
  // references / cfg / xrefs
1224
1522
  address: z.number().int().min(0).max(0xFFFFFFFF).optional().describe("target=references: CPU address to find references TO. target=cfg: address inside the function to graph. target=xrefs: address to find cross-references TO. target=decompile: address of the function to decompile (use an address from target='functions')."),
1225
1523
  maxRefsReturned: z.number().int().min(1).max(2048).default(256).describe("target=references: cap the references returned."),
1524
+ includeTableHits: z.boolean().default(false).describe("target=references: also scan the raw ROM for the address as a 16-bit POINTER (LE/BE, + the 6502 RTS-trick addr-1) — finds inline jump-table / trampoline call sites that no jsr/jmp/branch names. Auto-on when no direct refs are found; set true to get tableHits alongside direct refs too."),
1525
+ // target=pointerTable
1526
+ loBase: z.number().int().min(0).max(0xFFFFFF).optional().describe("target=pointerTable: CPU address of the table (contiguous form) OR the LOW-byte array (split form). Required."),
1527
+ hiBase: z.number().int().min(0).max(0xFFFFFF).optional().describe("target=pointerTable: CPU address of the HIGH-byte array (SPLIT lo/hi form). Omit for a contiguous `dw` table (hi byte follows each lo byte)."),
1528
+ count: z.number().int().min(1).max(4096).optional().describe("target=pointerTable: number of entries to decode."),
1529
+ convention: z.enum(["direct", "rts+1"]).default("direct").describe("target=pointerTable: 'direct' = the stored word IS the handler; 'rts+1' = the 6502 RTS-trick (table holds handler-1; +1 is applied)."),
1530
+ endian: z.enum(["LE", "BE"]).optional().describe("target=pointerTable: byte order of the CONTIGUOUS form (split lo/hi ignores this). Default follows the CPU — Genesis/m68k is BE, everything else LE; override only if a table is stored against type."),
1531
+ reverseHandler: z.number().int().min(0).max(0xFFFF).optional().describe("target=pointerTable: also report which dispatch INDEX/indices land on this handler address (reverse lookup — 'what state triggers this routine?')."),
1226
1532
  },
1227
1533
  safeTool(async (args) => {
1228
1534
  switch (args.target) {
@@ -1234,6 +1540,8 @@ export function registerDisasmTools(server, z) {
1234
1540
  case "xrefs": return jsonContent(await analyzeXrefs(requireRomPath(args), args.address, args.platform));
1235
1541
  case "functions": return jsonContent(await analyzeFunctions(requireRomPath(args), args.platform));
1236
1542
  case "decompile": return jsonContent(await analyzeDecompile(requireRomPath(args), args.address, args.platform));
1543
+ case "recompile": return await recompileCore(args);
1544
+ case "pointerTable": return jsonContent(await pointerTableCore(args));
1237
1545
  case "resolveJumptable":
1238
1546
  // A4: jumptable recovery is fundamentally a LIVE operation (it needs a
1239
1547
  // running emulator to observe the computed targets) — disasm is static
@@ -213,7 +213,71 @@ function nesVectorRefs(data, targetAddr) {
213
213
  return refs;
214
214
  }
215
215
 
216
- export async function findReferencesCore({ path, platform, address, mapper, maxRefsReturned = 256 }) {
216
+ /**
217
+ * Scan the raw ROM bytes for the target address encoded as a 16-bit POINTER —
218
+ * the inline-jump-table / trampoline case that a control-flow operand scan can't
219
+ * see. A dispatcher reads a word table and jumps to (or rts-tricks into) the
220
+ * entry, so the handler is reached with NO jsr/jmp/branch ever naming it; da65
221
+ * also mis-decodes the table bytes as instructions, hiding them from `references`.
222
+ * We surface every byte position whose LE or BE 16-bit word equals the target —
223
+ * and ALSO target-1, the 6502 "RTS trick" (push addr-1, rts → jumps to addr).
224
+ *
225
+ * @param {Uint8Array} data full ROM image (incl. any header)
226
+ * @param {number} targetAddr CPU address of the handler
227
+ * @param {number} headerSkip bytes of file header before the code image
228
+ * @param {boolean} [rtsTrick=true] also look for target-1 (the 6502 RTS trick).
229
+ * Only meaningful on 6502-family ROMs; off elsewhere to avoid spurious hits.
230
+ * @returns {Array<{fileOffset:string, word:string, endian:"LE"|"BE", convention:"direct"|"rts+1"}>}
231
+ */
232
+ function scanPointerTableHits(data, targetAddr, headerSkip = 0, rtsTrick = true) {
233
+ const hits = [];
234
+ const t16 = targetAddr & 0xffff;
235
+ const want = [{ value: t16, convention: "direct" }];
236
+ if (rtsTrick) want.push({ value: (t16 - 1) & 0xffff, convention: "rts+1" }); // 6502 RTS-trick: table holds addr-1
237
+ for (let i = headerSkip; i + 1 < data.length; i++) {
238
+ const le = data[i] | (data[i + 1] << 8);
239
+ const be = (data[i] << 8) | data[i + 1];
240
+ for (const w of want) {
241
+ if (le === w.value) {
242
+ hits.push({ fileOffset: "0x" + i.toString(16).toUpperCase(), word: "$" + w.value.toString(16).toUpperCase().padStart(4, "0"), endian: "LE", convention: w.convention });
243
+ } else if (be === w.value) {
244
+ hits.push({ fileOffset: "0x" + i.toString(16).toUpperCase(), word: "$" + w.value.toString(16).toUpperCase().padStart(4, "0"), endian: "BE", convention: w.convention });
245
+ }
246
+ }
247
+ }
248
+ return hits;
249
+ }
250
+
251
+ /**
252
+ * Bytes of file header before the code image, per platform — so the pointer-table
253
+ * scan reports a fileOffset into the actual code, and (more importantly) doesn't
254
+ * skip real data. Most platforms are headerless; the exceptions: NES (16-byte
255
+ * iNES), SNES (512-byte copier header iff size%1024==512), Atari 7800 (.a78 has a
256
+ * 128-byte header when the "ATARI7800" magic is present), Lynx (64-byte "LYNX").
257
+ * @param {string} platform
258
+ * @param {Uint8Array} data
259
+ */
260
+ function romHeaderSkip(platform, data) {
261
+ switch (platform) {
262
+ case "nes":
263
+ return (data.length >= 4 && data[0] === 0x4e && data[1] === 0x45 && data[2] === 0x53 && data[3] === 0x1a) ? 16 : 0;
264
+ case "snes":
265
+ return (data.length % 1024) === 512 ? 512 : 0;
266
+ case "atari7800": {
267
+ // .a78 v2/v3 header is 128 bytes, magic "ATARI7800" at offset 1.
268
+ const magic = "ATARI7800";
269
+ let ok = data.length > 128;
270
+ for (let i = 0; ok && i < magic.length; i++) if (data[1 + i] !== magic.charCodeAt(i)) ok = false;
271
+ return ok ? 128 : 0;
272
+ }
273
+ case "lynx":
274
+ return (data.length >= 4 && data[0] === 0x4c && data[1] === 0x59 && data[2] === 0x4e && data[3] === 0x58) ? 64 : 0; // "LYNX"
275
+ default:
276
+ return 0; // gb/gbc/sms/gg/genesis/c64/2600/pce/msx: headerless code image
277
+ }
278
+ }
279
+
280
+ export async function findReferencesCore({ path, platform, address, mapper: _mapper, includeTableHits = false, maxRefsReturned = 256 }) {
217
281
  const data = new Uint8Array(await readFile(path));
218
282
  const resolved = platform ?? (
219
283
  /\.nes$/i.test(path) ? "nes" :
@@ -514,6 +578,21 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
514
578
  refs.push(...atariVectorRefs(data, address));
515
579
  }
516
580
 
581
+ // Pointer-table / trampoline scan. The operand scan above only finds DIRECT
582
+ // control-flow (jsr/jmp/branch naming the address). When a handler is reached
583
+ // ONLY through an inline word table (computed jump / RTS-trick dispatcher), no
584
+ // instruction names it — so when the direct scan comes up empty (or the caller
585
+ // asks), scan the raw bytes for the address as a 16-bit pointer (LE/BE, direct
586
+ // and the RTS-trick addr-1 form). This is the case the v0.41.0 feedback hit
587
+ // where the only "ref" was an inline `B5 8E` table entry da65 mis-decoded.
588
+ const headerSkip = romHeaderSkip(resolved, data);
589
+ // The RTS-trick (table holds addr-1) is a 6502 idiom; only scan for it on the
590
+ // 6502 family so we don't surface spurious addr-1 matches on Z80/m68k/etc.
591
+ const SIXTYFIVE_OH_TWO = new Set(["nes", "atari2600", "atari7800", "c64", "lynx", "pce"]);
592
+ const rtsTrick = SIXTYFIVE_OH_TWO.has(resolved);
593
+ const wantTableHits = includeTableHits || refs.length === 0;
594
+ const tableHits = wantTableHits ? scanPointerTableHits(data, address, headerSkip, rtsTrick) : null;
595
+
517
596
  return {
518
597
  path,
519
598
  platform: resolved,
@@ -523,9 +602,21 @@ export async function findReferencesCore({ path, platform, address, mapper, maxR
523
602
  truncated: refs.length > maxRefsReturned
524
603
  ? `${refs.length - maxRefsReturned} additional references not returned (raise maxRefsReturned).`
525
604
  : undefined,
605
+ ...(tableHits && tableHits.length
606
+ ? {
607
+ tableHits: tableHits.slice(0, maxRefsReturned),
608
+ tableHitsFound: tableHits.length,
609
+ }
610
+ : {}),
526
611
  notes: [
527
612
  refs.length === 0
528
- ? `No references found. Address $${address.toString(16).toUpperCase()} may be unreached, or an indirect/computed jump target.`
613
+ ? `No DIRECT control-flow references (jsr/jmp/branch naming $${address.toString(16).toUpperCase()}).` +
614
+ (tableHits && tableHits.length
615
+ ? ` BUT ${tableHits.length} pointer-table hit(s) — the address appears as a 16-bit word in the ROM (an inline jump-table / trampoline reaches it via a computed jump). See tableHits: fileOffset is where the pointer sits; convention 'rts+1' is the 6502 RTS-trick (table holds addr-1).`
616
+ : ` No pointer-table hits either — likely unreached, or a register/computed target this scan can't resolve.`)
617
+ : null,
618
+ refs.length > 0 && tableHits && tableHits.length
619
+ ? `Also ${tableHits.length} pointer-table hit(s) (the address also appears as a raw 16-bit pointer — see tableHits).`
529
620
  : null,
530
621
  segmentsCapped > 0
531
622
  ? `Scan covered the first ${SEGMENT_CAP} banks only — ${segmentsCapped} additional bank(s) were NOT scanned (very large cart).`