romdevtools 0.41.0 → 0.42.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 (47) hide show
  1. package/CHANGELOG.md +70 -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-65816.js +584 -0
  9. package/src/cheats/gamegenie.js +14 -2
  10. package/src/cores/registry.js +43 -51
  11. package/src/mcp/state.js +91 -2
  12. package/src/mcp/tools/cheats.js +10 -1
  13. package/src/mcp/tools/disasm.js +309 -21
  14. package/src/mcp/tools/find-references.js +93 -2
  15. package/src/mcp/tools/frame.js +440 -4
  16. package/src/mcp/tools/index.js +34 -1
  17. package/src/mcp/tools/lifecycle.js +53 -24
  18. package/src/mcp/tools/memory.js +1 -1
  19. package/src/mcp/tools/playtest.js +24 -1
  20. package/src/mcp/tools/project.js +1 -1
  21. package/src/mcp/tools/rendering-context.js +4 -2
  22. package/src/mcp/tools/watch-memory.js +87 -9
  23. package/src/mcp/util.js +45 -0
  24. package/src/playtest/playtest.js +115 -46
  25. package/src/toolchains/cc65/da65.js +7 -0
  26. package/src/cores/wasm/bluemsx_libretro.js +0 -2
  27. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  28. package/src/cores/wasm/fceumm_libretro.js +0 -2
  29. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  30. package/src/cores/wasm/gambatte_libretro.js +0 -2
  31. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  32. package/src/cores/wasm/geargrafx_libretro.js +0 -2
  33. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  34. package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
  35. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  36. package/src/cores/wasm/handy_libretro.js +0 -2
  37. package/src/cores/wasm/handy_libretro.wasm +0 -0
  38. package/src/cores/wasm/mgba_libretro.js +0 -2
  39. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  40. package/src/cores/wasm/prosystem_libretro.js +0 -2
  41. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  42. package/src/cores/wasm/snes9x_libretro.js +0 -2
  43. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  44. package/src/cores/wasm/stella2014_libretro.js +0 -2
  45. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  46. package/src/cores/wasm/vice_x64_libretro.js +0 -2
  47. 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,256 @@ 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, targetPlatform } = args;
1231
+ if (platform && platform !== "nes") {
1232
+ throw new Error(`disasm({target:'recompile'}): only NES source is supported in phase 1 (got '${platform}').`);
1233
+ }
1234
+ if (targetPlatform && targetPlatform !== "snes") {
1235
+ throw new Error(`disasm({target:'recompile'}): only SNES target is supported in phase 1 (got '${targetPlatform}').`);
1236
+ }
1237
+ const romPath = requireRomPath(args);
1238
+ const rom = new Uint8Array(await readFile(romPath));
1239
+ // iNES validation: "NES\x1a" magic, then a 16-byte header, then PRG. v1 handles
1240
+ // NROM (mapper 0, 16KB or 32KB PRG). Give size vs mapper their OWN errors so the
1241
+ // message is never misleading (a 24KB mapper-0 ROM isn't a "mapped cart").
1242
+ const hasInes = rom.length >= 16 && rom[0] === 0x4e && rom[1] === 0x45 && rom[2] === 0x53 && rom[3] === 0x1a;
1243
+ if (!hasInes) {
1244
+ throw new Error("disasm({target:'recompile'}): not an iNES file (missing 'NES\\x1a' magic). Phase 1 takes a raw .nes ROM.");
1245
+ }
1246
+ const mapper = (rom[6] >> 4) | (rom[7] & 0xf0);
1247
+ if (mapper !== 0) {
1248
+ 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).`);
1249
+ }
1250
+ const prgSize = rom.length - 16;
1251
+ if (prgSize !== 0x4000 && prgSize !== 0x8000) {
1252
+ throw new Error(`disasm({target:'recompile'}): expected an NROM PRG of 16KB or 32KB; got ${prgSize} bytes. Phase 1 handles only those two sizes.`);
1253
+ }
1254
+ const prg = rom.subarray(16, 16 + prgSize);
1255
+ const prgBase = prgSize === 0x4000 ? 0xC000 : 0x8000; // 16KB mirrors into $C000
1256
+
1257
+ // Disassemble from the REAL reset vector, not blindly from the PRG base. The
1258
+ // reset routine lives wherever $FFFC/$FFFD point — often deep in PRG, with data
1259
+ // or padding ($00 = brk) at the base. Starting at the base translated that
1260
+ // padding as 6 garbage `brk`s; starting at the reset vector yields the actual
1261
+ // boot routine (robotfindskitten: 6-instr-garbage → 70-instr clean, 0 residue).
1262
+ const resetVec = prg[prg.length - 4] | (prg[prg.length - 3] << 8);
1263
+ const resetOff = resetVec - prgBase;
1264
+ if (resetOff < 0 || resetOff >= prg.length) {
1265
+ 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.`);
1266
+ }
1267
+ // Slice the PRG at the reset vector so da65 starts decoding real code there.
1268
+ const codeBytes = prg.subarray(resetOff);
1269
+ const { runDa65 } = await import("../../toolchains/cc65/da65.js");
1270
+ const da = await runDa65({ bytes: codeBytes, cpu: "6502", startAddress: resetVec, options: ["--comments", "4"] });
1271
+ const da65Full = da.asm ?? "";
1272
+ // Slice the first routine (the reset path) — a flat disasm renders data tables
1273
+ // after it as bogus code. Phase 1 = the boot routine.
1274
+ const da65Asm = sliceFirstRoutine(da65Full);
1275
+
1276
+ // Optional NES-PPU-on-SNES shim: boot the original ROM, read its PPU state,
1277
+ // convert tiles/nametable/palette to SNES formats, and emit a shim that draws
1278
+ // that static boot picture.
1279
+ //
1280
+ // STATUS (WORKING, default OFF): the JS-side conversion (NES 2bpp→SNES 4bpp
1281
+ // tiles, NES palette→BGR555 CGRAM, nametable→tilemap) is correct and
1282
+ // unit-tested, and the emitted 65816 UPLOAD routine now DMAs all three to SNES
1283
+ // VRAM/CGRAM and turns the screen on — verified end-to-end on snes9x
1284
+ // (test/recompile-shim-render.test.js). It is still OFF by default because
1285
+ // phase 1 only draws the STATIC first screen: after the shim, the recompiled
1286
+ // NES logic runs against a STUBBED PPU seam, so animation/scroll/sprites are
1287
+ // not maintained (the next layer). Opt in with withShim:true for the static
1288
+ // boot picture. (frame compareRender/findDiverge are the oracles to debug it.)
1289
+ // The phase-2 runtime (withRuntime) implies the shim (it needs the BG + tiles
1290
+ // the shim uploads); enabling it turns the static port into a LIVE one (sprites
1291
+ // animate each vblank, the game's NMI runs). withShim alone is the static path.
1292
+ const wantRuntime = args.withRuntime === true;
1293
+ const wantShim = args.withShim === true || wantRuntime;
1294
+ let shimAsm = null;
1295
+ let shimInfo = null;
1296
+ if (wantShim) {
1297
+ try {
1298
+ shimInfo = await buildNesPpuShim(rom);
1299
+ shimAsm = shimInfo.asm;
1300
+ } catch (e) {
1301
+ shimInfo = { error: e.message };
1302
+ }
1303
+ }
1304
+
1305
+ // Phase-2 runtime: disassemble the NES NMI handler ($FFFA) so the runtime's
1306
+ // vblank handler can call the game's own per-frame logic, and emit the runtime.
1307
+ let runtimeAsm = null;
1308
+ let nmiDa65Asm = null;
1309
+ let nmiInfo = null;
1310
+ if (wantRuntime) {
1311
+ try {
1312
+ const nmiVec = prg[prg.length - 6] | (prg[prg.length - 5] << 8);
1313
+ const nmiOff = nmiVec - prgBase;
1314
+ let nesNmiLabel = null;
1315
+ if (nmiOff >= 0 && nmiOff < prg.length && nmiVec !== resetVec) {
1316
+ const nmiDa = await runDa65({ bytes: prg.subarray(nmiOff), cpu: "6502", startAddress: nmiVec, options: ["--comments", "4"] });
1317
+ nmiDa65Asm = sliceFirstRoutine(nmiDa.asm ?? "");
1318
+ // The translated NMI's entry label (derive it the same way the recompiler
1319
+ // will) so the runtime calls the right routine.
1320
+ const { entry: ne } = recompileNesToSnes(nmiDa65Asm, { stubUndefined: false });
1321
+ nesNmiLabel = ne === "RECOMPILE_ENTRY" ? "RECOMPILE_NMI_ENTRY" : ne;
1322
+ }
1323
+ const { emitPpuRuntime } = await import("../../analysis/nes-ppu-runtime.js");
1324
+ runtimeAsm = emitPpuRuntime({ nesNmiLabel });
1325
+ nmiInfo = { nmiVector: "$" + nmiVec.toString(16).toUpperCase().padStart(4, "0"), gameNmi: nesNmiLabel };
1326
+ } catch (e) {
1327
+ nmiInfo = { error: e.message };
1328
+ }
1329
+ }
1330
+
1331
+ const { mainAsm, seamAsm, residue, entry, nmiEntry, instrCount, seamCount, stubbed } =
1332
+ recompileNesToSnes(da65Asm, {
1333
+ withShim: !!shimAsm,
1334
+ withRuntime: !!runtimeAsm,
1335
+ nmiDa65Asm,
1336
+ });
1337
+
1338
+ const runtimeOn = !!runtimeAsm;
1339
+ let written = null;
1340
+ if (args.outputDir) {
1341
+ await mkdir(args.outputDir, { recursive: true });
1342
+ const mainPath = nodePath.join(args.outputDir, "main.asm");
1343
+ await writeFile(mainPath, mainAsm);
1344
+ written = { mainAsm: mainPath };
1345
+ // The phase-2 runtime supplies its own seam; the phase-1 path uses nes_seam.
1346
+ if (runtimeOn) {
1347
+ const rtPath = nodePath.join(args.outputDir, "nes_ppu_runtime.asm");
1348
+ await writeFile(rtPath, runtimeAsm);
1349
+ written.runtimeAsm = rtPath;
1350
+ } else {
1351
+ const seamPath = nodePath.join(args.outputDir, "nes_seam.asm");
1352
+ await writeFile(seamPath, seamAsm);
1353
+ written.seamAsm = seamPath;
1354
+ }
1355
+ if (shimAsm) {
1356
+ const shimPath = nodePath.join(args.outputDir, "nes_ppu_shim.asm");
1357
+ await writeFile(shimPath, shimAsm);
1358
+ written.shimAsm = shimPath;
1359
+ }
1360
+ }
1361
+
1362
+ const shimDrawn = !!shimAsm;
1363
+ return jsonContent({
1364
+ ok: true,
1365
+ source: "nes", target: "snes",
1366
+ resetVector: "$" + resetVec.toString(16).toUpperCase().padStart(4, "0"),
1367
+ entry,
1368
+ ...(runtimeOn ? { nmiEntry, nmi: nmiInfo } : {}),
1369
+ instrCount, seamCount,
1370
+ stubbedCallees: stubbed,
1371
+ residue,
1372
+ shim: shimDrawn
1373
+ ? { 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." }
1374
+ : { 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." },
1375
+ runtime: runtimeOn
1376
+ ? { 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'})." }
1377
+ : { applied: false, note: "Static port (no per-frame runtime). Pass withRuntime:true to animate sprites + run the game's NMI each vblank." },
1378
+ note:
1379
+ `Recompiled the NES reset${runtimeOn ? " + NMI" : ""} routine(s) to 65816 (asar). ${instrCount} instrs, ${seamCount} PPU/APU seam calls, ` +
1380
+ `${stubbed.length} callee(s) stubbed (isolation), ${residue.length} residue line(s). ` +
1381
+ (runtimeOn
1382
+ ? "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). "
1383
+ : shimDrawn
1384
+ ? `The 6502 logic runs in 65816 EMULATION mode; the shim draws the converted static boot picture (${shimInfo.tileCount} tiles). `
1385
+ : "The 6502 logic runs in 65816 EMULATION mode; the hardware seam is STUBBED so the port renders blank. ") +
1386
+ (written
1387
+ ? `Wrote ${Object.values(written).join(" + ")}. Build all of them together with build({platform:'snes'}); then loadMedia + frame({op:'sideBySide'}) vs the NES original.`
1388
+ : `Pass outputDir to write the .asm files to disk for build({platform:'snes'}).`),
1389
+ ...(written ? { written } : { mainAsm, ...(runtimeOn ? { runtimeAsm } : { seamAsm }), ...(shimAsm ? { shimAsm } : {}) }),
1390
+ });
1391
+ }
1392
+
1393
+ /**
1394
+ * Boot a NES ROM in a throwaway host, read its PPU state after boot, and build
1395
+ * the NES-PPU-on-SNES shim (converted tiles/nametable/palette + the DMA
1396
+ * routine). Returns { asm, tileCount }. Throws if the core/regions are
1397
+ * unavailable.
1398
+ * @param {Uint8Array} romBytes full iNES file
1399
+ */
1400
+ async function buildNesPpuShim(romBytes) {
1401
+ const { resolveCore } = await import("../../cores/registry.js");
1402
+ const { LibretroHost } = await import("../../host/index.js");
1403
+ const { buildSnesAssets, emitPpuShim } = await import("../../analysis/nes-ppu-shim.js");
1404
+ const core = resolveCore("nes");
1405
+ if (!core) throw new Error("NES core unavailable for shim boot");
1406
+ const host = new LibretroHost();
1407
+ try {
1408
+ await host.loadCore(core.jsPath, core.wasmPath);
1409
+ await host.loadMedia({ platform: "nes", bytes: romBytes, virtualName: "/rom.nes" });
1410
+ // Boot long enough for the game to finish its initial PPU upload.
1411
+ host.stepFrames(120);
1412
+ const chr = host.readMemory("nes_chr", 0, Math.min(0x2000, host.regionSize("nes_chr") || 0x2000));
1413
+ const nt = host.readMemory("nes_nametables", 0, 0x400); // first nametable: 960 tiles + 64 attr
1414
+ const pal = host.readMemory("nes_palette", 0, 32);
1415
+ const assets = buildSnesAssets({ chr, nametable: nt, palette: pal });
1416
+ return { asm: emitPpuShim(assets), tileCount: assets.tileCount };
1417
+ } finally {
1418
+ try { host.unloadMedia(); } catch { /* best-effort */ }
1419
+ }
1420
+ }
1421
+
1147
1422
  export function registerDisasmTools(server, z) {
1148
1423
  server.tool(
1149
1424
  "disasm",
@@ -1190,7 +1465,7 @@ export function registerDisasmTools(server, z) {
1190
1465
  "LLM folds it. `address` for all four comes from target:'functions' (a CPU/virtual address; the file-offset " +
1191
1466
  "mapping is handled for you).",
1192
1467
  {
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)."),
1468
+ 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
1469
  // shared
1195
1470
  path: z.string().optional().describe("target=bytes: raw binary path. target=rom/project/references: ROM file path."),
1196
1471
  base64: z.string().optional().describe("target=bytes: base64 of the bytes (OR `path`)."),
@@ -1206,7 +1481,7 @@ export function registerDisasmTools(server, z) {
1206
1481
  symbolsText: z.string().optional().describe("target=bytes: inline symbol-file text."),
1207
1482
  symbolsFormat: z.enum(["wla", "cc65-lbl"]).optional().describe("target=bytes: explicit symbol-file format override."),
1208
1483
  // 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)."),
1484
+ 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
1485
  thumb: z.boolean().default(false).describe("target=rom: GBA — disassemble as THUMB (16-bit) instead of ARM."),
1211
1486
  endAddress: z.number().int().min(0).max(0xffffff).optional().describe("target=rom: CPU end address (inclusive); alternative to length."),
1212
1487
  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 +1494,21 @@ export function registerDisasmTools(server, z) {
1219
1494
  annotateRegisters: z.boolean().default(true).describe("target=rom: append `; PPUMASK` etc. to operands hitting a known hardware register."),
1220
1495
  annotateFileOffsets: z.boolean().default(true).describe("target=rom: append `; @0xNNNN` file offset to every line (for romPatch)."),
1221
1496
  // project
1222
- outputDir: z.string().optional().describe("target=project: directory to write the project into (one .asm per region)."),
1497
+ 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'})."),
1498
+ targetPlatform: z.string().optional().describe("target=recompile: the platform to emit (phase 1: only 'snes'). The source platform is `platform` (phase 1: only 'nes')."),
1499
+ 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."),
1500
+ 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
1501
  // references / cfg / xrefs
1224
1502
  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
1503
  maxRefsReturned: z.number().int().min(1).max(2048).default(256).describe("target=references: cap the references returned."),
1504
+ 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."),
1505
+ // target=pointerTable
1506
+ 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."),
1507
+ 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)."),
1508
+ count: z.number().int().min(1).max(4096).optional().describe("target=pointerTable: number of entries to decode."),
1509
+ 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)."),
1510
+ 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."),
1511
+ 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
1512
  },
1227
1513
  safeTool(async (args) => {
1228
1514
  switch (args.target) {
@@ -1234,6 +1520,8 @@ export function registerDisasmTools(server, z) {
1234
1520
  case "xrefs": return jsonContent(await analyzeXrefs(requireRomPath(args), args.address, args.platform));
1235
1521
  case "functions": return jsonContent(await analyzeFunctions(requireRomPath(args), args.platform));
1236
1522
  case "decompile": return jsonContent(await analyzeDecompile(requireRomPath(args), args.address, args.platform));
1523
+ case "recompile": return await recompileCore(args);
1524
+ case "pointerTable": return jsonContent(await pointerTableCore(args));
1237
1525
  case "resolveJumptable":
1238
1526
  // A4: jumptable recovery is fundamentally a LIVE operation (it needs a
1239
1527
  // 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).`