romdevtools 0.42.0 → 0.44.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 +53 -0
- package/package.json +1 -1
- package/src/analysis/recompile/emit-65816.js +105 -0
- package/src/analysis/recompile/emit-m68k.js +295 -0
- package/src/analysis/recompile/index.js +169 -0
- package/src/analysis/recompile/ir.js +123 -0
- package/src/analysis/recompile/lift-6502.js +176 -0
- package/src/analysis/recompile-65816.js +15 -66
- package/src/mcp/tools/cheats.js +73 -4
- package/src/mcp/tools/disasm.js +46 -26
- package/src/mcp/tools/index.js +12 -2
- package/src/mcp/tools/watch-memory.js +93 -20
- package/src/toolchains/cc65/cc65.js +11 -0
package/src/mcp/tools/disasm.js
CHANGED
|
@@ -1227,12 +1227,19 @@ async function pointerTableCore(args) {
|
|
|
1227
1227
|
}
|
|
1228
1228
|
|
|
1229
1229
|
async function recompileCore(args) {
|
|
1230
|
-
const { platform
|
|
1230
|
+
const { platform } = args;
|
|
1231
|
+
const targetPlatform = args.targetPlatform || "snes";
|
|
1231
1232
|
if (platform && platform !== "nes") {
|
|
1232
|
-
throw new Error(`disasm({target:'recompile'}): only NES source is supported
|
|
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.`);
|
|
1233
1234
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
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(", ")}.`);
|
|
1236
1243
|
}
|
|
1237
1244
|
const romPath = requireRomPath(args);
|
|
1238
1245
|
const rom = new Uint8Array(await readFile(romPath));
|
|
@@ -1289,8 +1296,12 @@ async function recompileCore(args) {
|
|
|
1289
1296
|
// The phase-2 runtime (withRuntime) implies the shim (it needs the BG + tiles
|
|
1290
1297
|
// the shim uploads); enabling it turns the static port into a LIVE one (sprites
|
|
1291
1298
|
// animate each vblank, the game's NMI runs). withShim alone is the static path.
|
|
1292
|
-
|
|
1293
|
-
|
|
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);
|
|
1294
1305
|
let shimAsm = null;
|
|
1295
1306
|
let shimInfo = null;
|
|
1296
1307
|
if (wantShim) {
|
|
@@ -1328,11 +1339,14 @@ async function recompileCore(args) {
|
|
|
1328
1339
|
}
|
|
1329
1340
|
}
|
|
1330
1341
|
|
|
1331
|
-
const {
|
|
1332
|
-
|
|
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,
|
|
1333
1347
|
withShim: !!shimAsm,
|
|
1334
1348
|
withRuntime: !!runtimeAsm,
|
|
1335
|
-
nmiDa65Asm,
|
|
1349
|
+
nmiSourceAsm: nmiDa65Asm,
|
|
1336
1350
|
});
|
|
1337
1351
|
|
|
1338
1352
|
const runtimeOn = !!runtimeAsm;
|
|
@@ -1348,7 +1362,7 @@ async function recompileCore(args) {
|
|
|
1348
1362
|
await writeFile(rtPath, runtimeAsm);
|
|
1349
1363
|
written.runtimeAsm = rtPath;
|
|
1350
1364
|
} else {
|
|
1351
|
-
const seamPath = nodePath.join(args.outputDir,
|
|
1365
|
+
const seamPath = nodePath.join(args.outputDir, seamFile);
|
|
1352
1366
|
await writeFile(seamPath, seamAsm);
|
|
1353
1367
|
written.seamAsm = seamPath;
|
|
1354
1368
|
}
|
|
@@ -1362,30 +1376,36 @@ async function recompileCore(args) {
|
|
|
1362
1376
|
const shimDrawn = !!shimAsm;
|
|
1363
1377
|
return jsonContent({
|
|
1364
1378
|
ok: true,
|
|
1365
|
-
source: "nes", target:
|
|
1379
|
+
source: "nes", target: targetPlatform, targetIsa,
|
|
1366
1380
|
resetVector: "$" + resetVec.toString(16).toUpperCase().padStart(4, "0"),
|
|
1367
1381
|
entry,
|
|
1368
1382
|
...(runtimeOn ? { nmiEntry, nmi: nmiInfo } : {}),
|
|
1369
1383
|
instrCount, seamCount,
|
|
1370
1384
|
stubbedCallees: stubbed,
|
|
1371
1385
|
residue,
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
+
}),
|
|
1378
1396
|
note:
|
|
1379
|
-
`Recompiled the NES reset${runtimeOn ? " + NMI" : ""} routine(s) to
|
|
1397
|
+
`Recompiled the NES reset${runtimeOn ? " + NMI" : ""} routine(s) to ${targetIsa}. ${instrCount} instrs, ${seamCount} PPU/APU seam calls, ` +
|
|
1380
1398
|
`${stubbed.length} callee(s) stubbed (isolation), ${residue.length} residue line(s). ` +
|
|
1381
|
-
(
|
|
1382
|
-
?
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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. `) +
|
|
1386
1406
|
(written
|
|
1387
|
-
? `Wrote ${Object.values(written).join(" + ")}. Build
|
|
1388
|
-
: `Pass outputDir to write the .asm files to disk for build({platform:'
|
|
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}'}).`),
|
|
1389
1409
|
...(written ? { written } : { mainAsm, ...(runtimeOn ? { runtimeAsm } : { seamAsm }), ...(shimAsm ? { shimAsm } : {}) }),
|
|
1390
1410
|
});
|
|
1391
1411
|
}
|
|
@@ -1495,7 +1515,7 @@ export function registerDisasmTools(server, z) {
|
|
|
1495
1515
|
annotateFileOffsets: z.boolean().default(true).describe("target=rom: append `; @0xNNNN` file offset to every line (for romPatch)."),
|
|
1496
1516
|
// project
|
|
1497
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'})."),
|
|
1498
|
-
targetPlatform: z.string().optional().describe("target=recompile: the platform to
|
|
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."),
|
|
1499
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."),
|
|
1500
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."),
|
|
1501
1521
|
// references / cfg / xrefs
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -59,6 +59,7 @@ import { createDisclosure } from "../disclosure.js";
|
|
|
59
59
|
import { jsonContent, safeTool, withClearToolErrors } from "../util.js";
|
|
60
60
|
import { getHostOrNull, setDisclosure } from "../state.js";
|
|
61
61
|
import { da65Available } from "../../toolchains/cc65/da65.js";
|
|
62
|
+
import { cc65Available } from "../../toolchains/cc65/cc65.js";
|
|
62
63
|
import { readFileSync } from "node:fs";
|
|
63
64
|
import { fileURLToPath } from "node:url";
|
|
64
65
|
import { dirname, join } from "node:path";
|
|
@@ -75,7 +76,16 @@ import { dirname, join } from "node:path";
|
|
|
75
76
|
*/
|
|
76
77
|
function hostCapabilities(host) {
|
|
77
78
|
const da65 = da65Available();
|
|
78
|
-
|
|
79
|
+
const cc65 = cc65Available();
|
|
80
|
+
// Toolchain caps don't need a loaded host — report them either way so an agent
|
|
81
|
+
// can check build/disasm availability before loading a ROM.
|
|
82
|
+
const toolchains = {
|
|
83
|
+
da65Disasm: da65, // disasm({target:'rom'/'references'/'cfg'/'xrefs'/'functions'/'decompile'}) — all da65-backed
|
|
84
|
+
cc65Build: cc65, // build({platform:'nes'/'c64'/'atari7800'/'lynx'}) — cc65/ca65
|
|
85
|
+
ld65Link: cc65, // the ld65 linker (ships with cc65 in the same package)
|
|
86
|
+
da65Toolchain: da65, // legacy alias for da65Disasm (kept for back-compat)
|
|
87
|
+
};
|
|
88
|
+
if (!host) return toolchains;
|
|
79
89
|
const has = (m) => { try { return !!host[m]?.(); } catch { return false; } };
|
|
80
90
|
return {
|
|
81
91
|
pcBreakpoint: has("pcBreakSupported"), // breakpoint({on:'pc'})
|
|
@@ -87,7 +97,7 @@ function hostCapabilities(host) {
|
|
|
87
97
|
cheats: has("cheatsSupported"), // cheats({op:'apply'}) via retro_cheat_set
|
|
88
98
|
diskImage: has("diskImageSupported"), // C64 .d64 loadMedia
|
|
89
99
|
keyboard: has("keyboardSupported"), // keyboard input (C64/MSX)
|
|
90
|
-
|
|
100
|
+
...toolchains,
|
|
91
101
|
};
|
|
92
102
|
}
|
|
93
103
|
|
|
@@ -206,6 +206,26 @@ export function makePressDriver(host, presses) {
|
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Flush any held-input shadow before a driven run. Back-to-back `pressDuring`
|
|
211
|
+
* runs on the SAME live host can leak the PRIOR run's held button into the next
|
|
212
|
+
* run's frame 0 — the game latches the pad into its own RAM each frame, and the
|
|
213
|
+
* new run's frame-0 logic reads that stale chord before the new input propagates.
|
|
214
|
+
* That makes a negative control (hold A to prove A does NOT reach a B-only branch)
|
|
215
|
+
* FALSE-POSITIVE on frame 1. (v0.41.0 feedback 213831 #1.) Calling this first sets
|
|
216
|
+
* the pad neutral and steps `n` frames so the game's input shadow settles to
|
|
217
|
+
* neutral; then the schedule drives a clean run. Only steps when a press schedule
|
|
218
|
+
* is actually given (no-op otherwise, so it never disturbs an inherited pad).
|
|
219
|
+
* @param {import("../../host/index.js").LibretroHost} host
|
|
220
|
+
* @param {number} n neutral frames to settle (0 = skip)
|
|
221
|
+
* @param {boolean} driven whether this run has a pressDuring schedule
|
|
222
|
+
*/
|
|
223
|
+
function settleHeldInput(host, n, driven) {
|
|
224
|
+
if (!driven || !n || n <= 0) return;
|
|
225
|
+
host.setInput({ ports: [{}, {}] });
|
|
226
|
+
host.stepFrames(n | 0);
|
|
227
|
+
}
|
|
228
|
+
|
|
209
229
|
// Single source of truth: the same canonical region vocabulary readMemory uses
|
|
210
230
|
// (host/types.js). Derived from the host map so new regions flow through
|
|
211
231
|
// automatically and the two tools never disagree. Used by the ONE primary
|
|
@@ -598,7 +618,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
598
618
|
|
|
599
619
|
// breakpoint({on:write|read|pc}) STOP-on-first. on:write precision:exact=bpFindWriter
|
|
600
620
|
// (core watchpoint, true PC under IRQ), precision:sampled=bpRunUntilWrite (frame PC).
|
|
601
|
-
async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf, condition, conditionValue }) {
|
|
621
|
+
async function bpFindWriter({ address, maxFrames = 600, pressDuring, settleFrames = 0, abortIf, condition, conditionValue }) {
|
|
602
622
|
const host = getHost(sessionKey);
|
|
603
623
|
if (!host.watchpointSupported || !host.watchpointSupported()) {
|
|
604
624
|
return jsonContent({
|
|
@@ -616,10 +636,14 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
616
636
|
// last write of the frame. Core support is feature-detected; if the loaded
|
|
617
637
|
// core build predates condition support, we fall back to a host-side
|
|
618
638
|
// 'equals' filter on the reported value (inc/dec need the core's old byte).
|
|
639
|
+
const presses0 = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
640
|
+
// Flush a prior run's held input BEFORE arming, so a back-to-back driven run
|
|
641
|
+
// starts from a neutral pad (see settleHeldInput / 213831 #1).
|
|
642
|
+
settleHeldInput(host, settleFrames, presses0.length > 0);
|
|
619
643
|
const wantCond = condition != null;
|
|
620
644
|
const coreCond = host.setWatchpoint(address, true, wantCond ? { condition, value: conditionValue } : undefined);
|
|
621
645
|
const coreHandledCond = wantCond && coreCond && coreCond.conditionApplied === true;
|
|
622
|
-
const presses =
|
|
646
|
+
const presses = presses0;
|
|
623
647
|
const pressDriver = makePressDriver(host, presses);
|
|
624
648
|
// Abort-guard: sample caller-named "still valid?" bytes each frame; if any
|
|
625
649
|
// changes, a driven run that DERAILED (player died → title screen, scene
|
|
@@ -761,7 +785,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
761
785
|
});
|
|
762
786
|
}
|
|
763
787
|
|
|
764
|
-
async function bpRunUntilPC({ address, maxFrames = 600, pressDuring, captureMemory }) {
|
|
788
|
+
async function bpRunUntilPC({ address, maxFrames = 600, pressDuring, settleFrames = 0, captureMemory }) {
|
|
765
789
|
const host = getHost(sessionKey);
|
|
766
790
|
if (!host.pcBreakSupported || !host.pcBreakSupported()) {
|
|
767
791
|
return jsonContent({
|
|
@@ -771,6 +795,10 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
771
795
|
});
|
|
772
796
|
}
|
|
773
797
|
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
798
|
+
// Flush a prior run's held-button shadow BEFORE arming (so the settle frames
|
|
799
|
+
// don't trip the breakpoint) — prevents a back-to-back negative control from
|
|
800
|
+
// false-positiving on frame 0. See settleHeldInput.
|
|
801
|
+
settleHeldInput(host, settleFrames, presses.length > 0);
|
|
774
802
|
const pressDriver = makePressDriver(host, presses);
|
|
775
803
|
host.setPCBreak(address, true, false);
|
|
776
804
|
let hit = false, framesRun = 0, last = null;
|
|
@@ -797,9 +825,13 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
797
825
|
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
798
826
|
...(pcNow != null ? { pcNow: "$" + pcNow.toString(16).toUpperCase() } : {}),
|
|
799
827
|
note: (drove
|
|
800
|
-
? "PC never reached that address within maxFrames EVEN WITH the scheduled input
|
|
801
|
-
"
|
|
802
|
-
"
|
|
828
|
+
? "PC never reached that address within maxFrames EVEN WITH the scheduled input. Either (a) this is " +
|
|
829
|
+
"the WRONG ADDRESS for the path that actually ran (a different routine handles it), (b) the address " +
|
|
830
|
+
"isn't an instruction boundary (mid-instruction never matches REG_PC), OR (c) the condition " +
|
|
831
|
+
"LEGITIMATELY did not occur — i.e. this hit:false is the DESIRED result of a negative control " +
|
|
832
|
+
"(you're proving input X does NOT reach this branch; e.g. an A-vs-B discriminator where only the " +
|
|
833
|
+
"other button should hit). If you confirmed the address is reachable on the positive run, (c) is " +
|
|
834
|
+
"the expected outcome, not a failure. "
|
|
803
835
|
: "PC never reached that address within maxFrames. Either the code path didn't execute (drive " +
|
|
804
836
|
"it with pressDuring to reach the right game state), or the address isn't an instruction " +
|
|
805
837
|
"boundary (mid-instruction never matches REG_PC). ") +
|
|
@@ -1091,6 +1123,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1091
1123
|
port: z.number().int().min(0).max(3).default(0),
|
|
1092
1124
|
holdFrames: z.number().int().min(1).default(2),
|
|
1093
1125
|
})).optional().describe("Schedule input while waiting (drive the game to the state that triggers the condition). If OMITTED, this run inherits whatever input({op:'set'}) last held — same as frame({op:'step'}). If GIVEN, the schedule OWNS the pad for the whole run (a prior input({op:'set'}) is ignored); use it to drive the watched window itself. Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten."),
|
|
1126
|
+
settleFrames: z.number().int().min(0).max(120).default(0).describe("on:'pc'/'write' with pressDuring — release the pad to NEUTRAL and step this many frames BEFORE the run, so the PRIOR run's held-button shadow (the game latches the pad into its own RAM each frame) doesn't bleed into this run's frame 0. Set ~10-30 for back-to-back A/B-discriminator / negative-control runs on the same live host (hold A to prove A does NOT reach a B-only branch) — without it the stale chord can false-positive on frame 1. No-op without pressDuring."),
|
|
1094
1127
|
abortIf: z.array(z.object({
|
|
1095
1128
|
region: regionStr("memory region (default system_ram)").optional(),
|
|
1096
1129
|
offset: z.number().int().min(0).describe("byte offset within the region"),
|
|
@@ -1295,7 +1328,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1295
1328
|
|
|
1296
1329
|
// ── Range watch + coverage trace (item 2, discovery) ────────────────────────
|
|
1297
1330
|
|
|
1298
|
-
async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200, fromState, fromStatePath }) {
|
|
1331
|
+
async function wRange({ start, end, kind = "both", frames = 120, pressDuring, limit = 200, dedupe = false, distinctPCsOnly = false, fromState, fromStatePath }) {
|
|
1299
1332
|
const host = getHost(sessionKey);
|
|
1300
1333
|
if (!host.rangeWatchSupported || !host.rangeWatchSupported()) {
|
|
1301
1334
|
return jsonContent({ notSupported: true, events: [],
|
|
@@ -1313,20 +1346,59 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1313
1346
|
if (presses.length) pressDriver.applyForFrame(0);
|
|
1314
1347
|
const r = host.watchRange(start, end, kind, frames);
|
|
1315
1348
|
pressDriver.finish();
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
//
|
|
1322
|
-
|
|
1323
|
-
|
|
1349
|
+
const hx = (n, w = 0) => "$" + n.toString(16).toUpperCase().padStart(w, "0");
|
|
1350
|
+
const hxv = (n) => "0x" + n.toString(16).toUpperCase().padStart(2, "0");
|
|
1351
|
+
|
|
1352
|
+
// Per-PC digest — the actionable "which routines touch this range" answer.
|
|
1353
|
+
// Each writer's hit count + a sample address/value, sorted by frequency. This
|
|
1354
|
+
// is what a "who writes here?" query actually needs; the per-event flood (a
|
|
1355
|
+
// per-frame counter inc'd at one PC → hundreds of near-identical rows) is
|
|
1356
|
+
// ~95% wasted tokens for that question. (v0.41.0 feedback 133737 N1.)
|
|
1357
|
+
const byPC = new Map();
|
|
1358
|
+
for (const e of r.events) {
|
|
1359
|
+
let g = byPC.get(e.pc);
|
|
1360
|
+
if (!g) { g = { pc: e.pc, count: 0, sampleAddress: e.address, sampleValue: e.value }; byPC.set(e.pc, g); }
|
|
1361
|
+
g.count++;
|
|
1362
|
+
}
|
|
1363
|
+
const byPCList = [...byPC.values()].sort((a, b) => b.count - a.count).slice(0, 64)
|
|
1364
|
+
.map((g) => ({ pc: hx(g.pc), count: g.count, sampleAddress: hx(g.sampleAddress), sampleValue: hxv(g.sampleValue) }));
|
|
1365
|
+
const distinctPCs = byPCList.map((g) => g.pc);
|
|
1366
|
+
|
|
1367
|
+
// distinctPCsOnly → return JUST the digest, suppress the raw event list (the
|
|
1368
|
+
// common "discover the writers" use; no per-event tokens spent).
|
|
1369
|
+
if (distinctPCsOnly) {
|
|
1370
|
+
return attachObserverFrame(jsonContent({
|
|
1371
|
+
range: hx(start) + ".." + hx(end), kind, total: r.total, truncated: r.truncated,
|
|
1372
|
+
...(stateInfo ? { restoredFrom: stateInfo } : {}),
|
|
1373
|
+
distinctPCs, byPC: byPCList,
|
|
1374
|
+
note: "distinctPCsOnly: per-PC digest only (raw events suppressed). Each PC is a routine that touches the range; `count` is how often it fired, `sampleAddress`/`sampleValue` a representative hit. disasm({target:'rom'}) a PC to identify it. Drop distinctPCsOnly (or set dedupe:true) for the events." +
|
|
1375
|
+
(r.truncated ? " TRUNCATED: more events than the buffer held — narrow start..end/frames." : ""),
|
|
1376
|
+
}), host);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// dedupe → collapse identical (pc,address,value) rows to one with an
|
|
1380
|
+
// `occurrences` count, so a byte written the same way every frame is 1 row
|
|
1381
|
+
// not hundreds (parity with on:'dma's dedupe).
|
|
1382
|
+
let events;
|
|
1383
|
+
if (dedupe) {
|
|
1384
|
+
const seen = new Map();
|
|
1385
|
+
for (const e of r.events) {
|
|
1386
|
+
const k = `${e.pc}|${e.address}|${e.value}`;
|
|
1387
|
+
const g = seen.get(k);
|
|
1388
|
+
if (g) g.occurrences++;
|
|
1389
|
+
else seen.set(k, { pc: hx(e.pc), address: hx(e.address), value: hxv(e.value), occurrences: 1 });
|
|
1390
|
+
}
|
|
1391
|
+
events = [...seen.values()].sort((a, b) => b.occurrences - a.occurrences).slice(0, limit);
|
|
1392
|
+
} else {
|
|
1393
|
+
events = r.events.slice(0, limit).map((e) => ({ pc: hx(e.pc), address: hx(e.address), value: hxv(e.value) }));
|
|
1394
|
+
}
|
|
1324
1395
|
return attachObserverFrame(jsonContent({
|
|
1325
|
-
range:
|
|
1396
|
+
range: hx(start) + ".." + hx(end),
|
|
1326
1397
|
kind, total: r.total, returned: events.length, truncated: r.truncated,
|
|
1398
|
+
...(dedupe ? { deduped: true, uniqueEvents: events.length } : {}),
|
|
1327
1399
|
...(stateInfo ? { restoredFrom: stateInfo } : {}),
|
|
1328
|
-
distinctPCs, events,
|
|
1329
|
-
note: "distinctPCs is the actionable summary — each is a routine that touches this range; disasm({target:'rom'}) one to identify the renderer/reader. " +
|
|
1400
|
+
distinctPCs, byPC: byPCList, events,
|
|
1401
|
+
note: "distinctPCs/byPC is the actionable summary — each PC is a routine that touches this range; disasm({target:'rom'}) one to identify the renderer/reader. For a 'who writes here?' query, distinctPCsOnly:true returns JUST the digest (no per-event flood); dedupe:true collapses per-frame churn to unique (pc,address,value) rows with `occurrences`. " +
|
|
1330
1402
|
(r.truncated ? "TRUNCATED: more events than the buffer held — narrow `start..end` or `frames` for the full set." : ""),
|
|
1331
1403
|
}), host);
|
|
1332
1404
|
}
|
|
@@ -1361,7 +1433,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1361
1433
|
"• on:'mem' — the power tool: answer 'what code is touching this RAM byte?' OR extract a frame-accurate event timeline (music-driver note onsets, physics arcs). Reports every frame that changed a watched byte as {frame,offset,before,after,pc}. " +
|
|
1362
1434
|
"Extras: `ranges:[{region,offset,length,label}]` watches MANY disjoint regions in ONE pass (identical frames); `onChange:'reset'|'increase'|'decrease'|'any'` edge filter (reset = counter-reload = the note-onset signal); `valueFilter:{min,max}`; `format:'series'` = compact columnar value-vs-frame curve (~10× smaller for a ramp); `sampleEvery`; `groupByPC` (collapse by sampled PC); `cheatLabels` (auto-name addresses from the cheat DB); `outputPath` streams all events as NDJSON; `stopOnFirst` exits on the first match. " +
|
|
1363
1435
|
"**CAVEAT: frame-level, not instruction-level (last value per frame); the sampled `pc` is a frame-boundary sample — for ISR-driven writes use breakpoint({on:'write', precision:'exact'}) for the real writer.**\n" +
|
|
1364
|
-
"• on:'range' — DISCOVERY: log EVERY instruction that reads or writes ANYWHERE in [start,end]. The fix for 'I don't know which PC touches this'. Returns {pc,address,value}[] + the actionable distinctPCs. (Ring-buffered: `truncated:true` if it overflows.) `fromState`/`fromStatePath` restores a savestate FIRST so the trace runs from a known moment (jump to the boss, then see what writes HP) — deterministic + repeatable.\n" +
|
|
1436
|
+
"• on:'range' — DISCOVERY: log EVERY instruction that reads or writes ANYWHERE in [start,end]. The fix for 'I don't know which PC touches this'. Returns {pc,address,value}[] + the actionable distinctPCs + a per-PC digest (byPC). For a pure 'who writes here?' query, `distinctPCsOnly:true` returns JUST the digest (no per-event flood — a per-frame counter inc'd at one PC otherwise floods hundreds of near-identical rows); `dedupe:true` collapses identical (pc,address,value) events to one row with `occurrences`. (Ring-buffered: `truncated:true` if it overflows.) `fromState`/`fromStatePath` restores a savestate FIRST so the trace runs from a known moment (jump to the boss, then see what writes HP) — deterministic + repeatable.\n" +
|
|
1365
1437
|
"• on:'pc' — DISCOVERY (coverage trace): record every DISTINCT PC executed within [start,end] — 'what code runs here?'. Log execution in the bank where you suspect the renderer lives during the moment it draws, then disassemble the PCs. Also takes `fromState`/`fromStatePath` to trace from a restored moment.\n" +
|
|
1366
1438
|
"• on:'dma' — GENESIS ONLY: trace mem→VDP DMAs (the answer to 'this name/portrait/logo is a pre-rendered bitmap DMA'd into VRAM — WHERE in ROM?', which on:'write' can't catch). `precision:'exact'` (default) logs every mem→VDP DMA with its VRAM DESTINATION + ROM SOURCE + length (filter by `vramDest`±`destWindow`; `dedupe` collapses the per-frame refresh; `sourceFilter:'rom-only'` drops RAM→VRAM noise; catches a same-frame second DMA). `precision:'sampled'` is the cheap frame-sampled source-register read (may miss two DMAs in one frame, dest-agnostic). `perFrame:true` switches to FEEL/PERF MODE: a per-frame timeline of VDP-DMA WORK ({frame,dmas,bytes,romBytes,ramBytes} + peakFrame + `spikes`) — the cheap 'why does horizontal movement feel choppy?' diagnostic (a per-frame byte spike = too much VDP work in the loop, e.g. a tilemap rewrite). On non-Genesis cores returns `notSupported`.\n" +
|
|
1367
1439
|
"• on:'copy' — ALL 14 PLATFORMS: log every write landing in a VRAM/dest address window [start,end] with the EXECUTING instruction's PC — the generic answer to 'this tile/nametable/portrait on screen: which routine uploads it?'. Port-based video memory (NES $2007, SNES $2118/19 — incl. the DMA path, PCE VWR, MSX/SMS/GG VDP data port, Genesis data port) is hooked INSIDE the core, so `start`/`end` are VRAM addresses (NES PPU $0000-$3FFF; SNES VRAM byte addr; PCE VRAM word addr; MSX/SMS/GG VRAM addr). Direct-mapped platforms (GB/GBC $8000-$9FFF, GBA 0x06000000+, C64/Lynx/7800 RAM framebuffers) route through the CPU-address range log automatically — pass CPU addresses there. Follow up with breakpoint({on:'pc', address: pc}) to get registersAtHit at the uploader.",
|
|
@@ -1385,6 +1457,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1385
1457
|
stopOnFirst: z.boolean().default(false).describe("on:'mem' — stop on the first filter-passing change instead of running the full duration. (For a true stop-on-first breakpoint, prefer the `breakpoint` tool.)"),
|
|
1386
1458
|
// on:'range'
|
|
1387
1459
|
kind: z.enum(["read", "write", "both"]).default("both").describe("on:'range' — watch reads, writes, or both."),
|
|
1460
|
+
distinctPCsOnly: z.boolean().default(false).describe("on:'range' — return JUST the per-PC digest (distinctPCs + byPC[{pc,count,sampleAddress,sampleValue}]) and SUPPRESS the raw event list. The token-cheap form of the common 'which routines touch this range?' query — a per-frame counter inc'd at one PC floods hundreds of near-identical events otherwise."),
|
|
1388
1461
|
// on:'range' / on:'pc' window
|
|
1389
1462
|
start: z.number().int().min(0).optional().describe("on:'range'/'pc' — low CPU address of the window."),
|
|
1390
1463
|
end: z.number().int().min(0).optional().describe("on:'range'/'pc' — high CPU address (inclusive)."),
|
|
@@ -1397,7 +1470,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1397
1470
|
precision: z.enum(["exact", "sampled"]).default("exact").describe("on:'dma' — exact=per-DMA core log with VRAM dest + ROM source (catches same-frame DMAs); sampled=frame-sampled source-register read (cheaper, may miss two DMAs in one frame, dest-agnostic). Ignored when perFrame:true."),
|
|
1398
1471
|
vramDest: z.number().int().min(0).optional().describe("on:'dma' precision:'exact' — keep only DMAs whose VRAM destination is within ±`destWindow` of this address."),
|
|
1399
1472
|
destWindow: z.number().int().min(0).default(0x40).describe("on:'dma' precision:'exact' — match window around vramDest (default 64 bytes ≈ 1 tile)."),
|
|
1400
|
-
dedupe: z.boolean().
|
|
1473
|
+
dedupe: z.boolean().optional().describe("Collapse identical events to one entry with an `occurrences` count. on:'dma' precision:'exact' — identical DMAs (same dest+source+length+code), DEFAULT ON. on:'range' — identical (pc,address,value) writes, DEFAULT OFF (turns per-frame churn from hundreds of rows into a few)."),
|
|
1401
1474
|
sourceFilter: z.enum(["all", "rom-only", "ram-only"]).default("all").describe("on:'dma' precision:'exact' — 'rom-only' drops the RAM→VRAM per-frame refresh noise; 'ram-only' keeps only it."),
|
|
1402
1475
|
romPreviewBytes: z.number().int().min(0).max(64).default(0).describe("on:'dma' — bytes of the ROM source to preview per DMA (exact default 0; sampled default 16)."),
|
|
1403
1476
|
minLengthBytes: z.number().int().min(0).max(65536).default(0).describe("on:'dma' precision:'sampled' — ignore DMAs shorter than this many bytes (filters tiny scroll/sprite updates so graphic uploads stand out)."),
|
|
@@ -38,6 +38,17 @@ function resolveCc65BaseDir() {
|
|
|
38
38
|
// the base dir once; derive the wasm + share dirs from it on demand.
|
|
39
39
|
let _cc65Base;
|
|
40
40
|
const cc65Base = () => (_cc65Base ??= resolveCc65BaseDir());
|
|
41
|
+
|
|
42
|
+
/** True if the cc65 build toolchain WASM (cc65 + ld65, in romdev-toolchain-cc65)
|
|
43
|
+
* is installed/resolvable, without throwing — for the catalog(status) capability
|
|
44
|
+
* probe. cc65, ca65, ld65, and da65 all ship in the SAME package, so this also
|
|
45
|
+
* reflects ld65 (the linker) availability. */
|
|
46
|
+
export function cc65Available() {
|
|
47
|
+
try {
|
|
48
|
+
const dir = resolveCc65BaseDir();
|
|
49
|
+
return existsSync(path.join(dir, "wasm", "cc65.js")) && existsSync(path.join(dir, "wasm", "ld65.js"));
|
|
50
|
+
} catch { return false; }
|
|
51
|
+
}
|
|
41
52
|
const wasmDir = () => path.join(cc65Base(), "wasm");
|
|
42
53
|
const shareDir = () => path.join(cc65Base(), "share", "cc65");
|
|
43
54
|
|