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.
- package/CHANGELOG.md +70 -0
- package/package.json +3 -1
- package/src/analysis/analyze.js +1 -1
- package/src/analysis/backtrace.js +198 -0
- package/src/analysis/nes-ppu-runtime.js +220 -0
- package/src/analysis/nes-ppu-shim.js +263 -0
- package/src/analysis/pointer-table.js +84 -0
- package/src/analysis/recompile-65816.js +584 -0
- package/src/cheats/gamegenie.js +14 -2
- package/src/cores/registry.js +43 -51
- package/src/mcp/state.js +91 -2
- package/src/mcp/tools/cheats.js +10 -1
- package/src/mcp/tools/disasm.js +309 -21
- package/src/mcp/tools/find-references.js +93 -2
- package/src/mcp/tools/frame.js +440 -4
- package/src/mcp/tools/index.js +34 -1
- package/src/mcp/tools/lifecycle.js +53 -24
- package/src/mcp/tools/memory.js +1 -1
- package/src/mcp/tools/playtest.js +24 -1
- package/src/mcp/tools/project.js +1 -1
- package/src/mcp/tools/rendering-context.js +4 -2
- package/src/mcp/tools/watch-memory.js +87 -9
- package/src/mcp/util.js +45 -0
- package/src/playtest/playtest.js +115 -46
- package/src/toolchains/cc65/da65.js +7 -0
- package/src/cores/wasm/bluemsx_libretro.js +0 -2
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +0 -2
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +0 -2
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +0 -2
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +0 -2
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +0 -2
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +0 -2
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +0 -2
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +0 -2
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +0 -2
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
package/src/mcp/tools/disasm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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).`
|