romdevtools 0.43.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 +29 -0
- package/package.json +1 -1
- package/src/mcp/tools/cheats.js +73 -4
- 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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ All notable changes to `romdevtools`. Dates are release dates.
|
|
|
4
4
|
(Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
|
|
5
5
|
the `romdev-mcp` bin is kept as an alias.)
|
|
6
6
|
|
|
7
|
+
## 0.44.0 — 2026-06-25
|
|
8
|
+
|
|
9
|
+
### v0.41.0 feedback (part 2) — RE-session ergonomics
|
|
10
|
+
|
|
11
|
+
- **`watch({on:'range'})` dedupe + digest.** A range watch over a churny window
|
|
12
|
+
flooded with per-frame writes (a counter inc'd at one PC → hundreds of
|
|
13
|
+
near-identical rows). New `dedupe:true` collapses identical `(pc,address,value)`
|
|
14
|
+
events to one row with an `occurrences` count (parity with `on:'dma'`), and
|
|
15
|
+
`distinctPCsOnly:true` returns JUST the per-PC digest (`byPC[{pc,count,
|
|
16
|
+
sampleAddress,sampleValue}]`) and suppresses the raw event list — the
|
|
17
|
+
token-cheap "which routines touch this range?" answer. (133737 N1)
|
|
18
|
+
- **`catalog({op:'status'}).capabilities` build-toolchain flags.** Added
|
|
19
|
+
`cc65Build`, `ld65Link`, `da65Disasm` so an agent knows before calling
|
|
20
|
+
`build`/`disasm` whether the toolchain is present (the analysis subtargets
|
|
21
|
+
cfg/xrefs/functions/decompile are all da65-backed). (184553 #1, 190223 #1)
|
|
22
|
+
- **`cheats` slot lifecycle.** `apply` now REPLACES an active freeze on the same
|
|
23
|
+
address instead of stacking a second one that fights for the byte
|
|
24
|
+
(`replacedSameAddress`), and new `op:'remove'` drops ONE cheat by slot/code
|
|
25
|
+
without `clear`'s nuke-all. (213831 #3)
|
|
26
|
+
- **`breakpoint` `settleFrames`.** Back-to-back driven runs on the same live host
|
|
27
|
+
could inherit the prior run's held-button shadow on frame 0 (false-positiving a
|
|
28
|
+
negative control). `settleFrames:N` releases the pad to neutral and steps N
|
|
29
|
+
frames before the run so it starts clean. (213831 #1)
|
|
30
|
+
- **`breakpoint({on:'pc'})` miss-note** now names the negative-control case: a
|
|
31
|
+
`hit:false` may be the DESIRED result (proving input X does NOT reach a branch),
|
|
32
|
+
not a wrong-address failure. (213831 #2)
|
|
33
|
+
- Confirmed already shipped (0.42.0): hex-string `address`/`offset` params, the
|
|
34
|
+
base capabilities map. (133737 N2, 002129 #2)
|
|
35
|
+
|
|
7
36
|
## 0.43.0 — 2026-06-25
|
|
8
37
|
|
|
9
38
|
### Port engine is now GENERIC (any source → any target), not NES→SNES-only
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "romdevtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 14 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
package/src/mcp/tools/cheats.js
CHANGED
|
@@ -204,18 +204,38 @@ export async function cheatsApplyCore({ code, desc, path: romPath, index, enable
|
|
|
204
204
|
const { code: codeToApply, appliedAs, reencodedFrom } = resolveCheatCodeForApply(rawCode, plat);
|
|
205
205
|
|
|
206
206
|
const unencodable = appliedAs === "rom-unencodable";
|
|
207
|
-
|
|
207
|
+
// Slot selection: an explicit `index` wins. Otherwise, if an active cheat
|
|
208
|
+
// already targets the SAME address, REUSE its slot — applying `005C:03` over
|
|
209
|
+
// an active `005C:02` should REPLACE the freeze, not stack a second one that
|
|
210
|
+
// fights for the same byte (v0.41.0 feedback 213831 #3). Only append a new
|
|
211
|
+
// slot when nothing targets this address yet.
|
|
212
|
+
const newAddr = cheatAddress(codeToApply);
|
|
213
|
+
let replacedSlot = null;
|
|
214
|
+
let slot;
|
|
215
|
+
if (index != null) {
|
|
216
|
+
slot = index;
|
|
217
|
+
} else if (newAddr != null) {
|
|
218
|
+
const existing = host.listActiveCheats().find((c) => cheatAddress(c.code) === newAddr);
|
|
219
|
+
if (existing) { slot = existing.index; replacedSlot = existing.index; }
|
|
220
|
+
else slot = host.listActiveCheats().length;
|
|
221
|
+
} else {
|
|
222
|
+
slot = host.listActiveCheats().length;
|
|
223
|
+
}
|
|
208
224
|
// Still install it (the raw poke may be what the user wants), but warn.
|
|
209
225
|
host.setCheat(slot, codeToApply, enabled);
|
|
210
226
|
return {
|
|
211
227
|
applied: enabled,
|
|
212
228
|
slot,
|
|
229
|
+
...(replacedSlot != null ? { replacedSameAddress: true } : {}),
|
|
213
230
|
code: codeToApply,
|
|
214
231
|
appliedAs,
|
|
215
232
|
...(reencodedFrom ? { reencodedFrom } : {}),
|
|
216
233
|
...(resolvedDesc ? { desc: resolvedDesc } : {}),
|
|
217
234
|
active: host.listActiveCheats(),
|
|
218
|
-
note: (
|
|
235
|
+
note: (replacedSlot != null
|
|
236
|
+
? `REPLACED the active cheat on the same address (slot ${replacedSlot}) — applying a new freeze on an address that already had one swaps it rather than stacking two that fight over the byte. To remove a single freeze without clearing the rest, use cheats({op:'remove', code|slot}). `
|
|
237
|
+
: "") +
|
|
238
|
+
(reencodedFrom
|
|
219
239
|
? `Raw code ${reencodedFrom} names a ROM address — re-encoded to the native ROM-patch device (${codeToApply}) so the core installs a read-intercept (a raw ADDR:VAL on a ROM address is treated as a RAM poke and would silently no-op). `
|
|
220
240
|
: unencodable
|
|
221
241
|
? `WARNING: this raw code names a ROM address but couldn't be re-encoded to a ROM-patch code (a ROM patch needs a COMPARE byte — pass ADDR:VAL:COMPARE). As applied it's a RAM poke and will likely NO-OP on this read-only ROM address. Read the current byte and supply it as the compare, or use makeCheat. `
|
|
@@ -226,6 +246,53 @@ export async function cheatsApplyCore({ code, desc, path: romPath, index, enable
|
|
|
226
246
|
};
|
|
227
247
|
}
|
|
228
248
|
|
|
249
|
+
/** The CPU/RAM address a cheat code targets, or null if undecodable. Used to
|
|
250
|
+
* detect same-address freezes (replace, not stack) + for op:'remove' by code. */
|
|
251
|
+
function cheatAddress(code) {
|
|
252
|
+
if (typeof code !== "string") return null;
|
|
253
|
+
// raw ADDR:VAL[:COMPARE] — the address is the first field.
|
|
254
|
+
if (code.includes(":")) {
|
|
255
|
+
const a = parseInt(code.split(":")[0], 16);
|
|
256
|
+
return Number.isNaN(a) ? null : (a & 0xffff);
|
|
257
|
+
}
|
|
258
|
+
// a device code (Game Genie / PAR / GameShark) — decode it platform-agnostically.
|
|
259
|
+
try {
|
|
260
|
+
const d = decodeCode(code, null);
|
|
261
|
+
return d && d.address != null ? (d.address & 0xffff) : null;
|
|
262
|
+
} catch { return null; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** op:'remove' — disable ONE active cheat (by slot index or by code/address),
|
|
266
|
+
* leaving the rest in place. The single-slot complement to op:'clear' (nuke-all).
|
|
267
|
+
* v0.41.0 feedback 213831 #3. */
|
|
268
|
+
export async function cheatsRemoveCore({ slot, code }, sessionKey) {
|
|
269
|
+
const host = getHost(sessionKey);
|
|
270
|
+
if (!host.listActiveCheats) return { removed: false, reason: "no cheat interface on this core", active: [] };
|
|
271
|
+
const active = host.listActiveCheats();
|
|
272
|
+
let target = null;
|
|
273
|
+
if (slot != null) {
|
|
274
|
+
target = active.find((c) => c.index === slot);
|
|
275
|
+
} else if (code != null) {
|
|
276
|
+
// match by exact code OR by the address the code targets (so a user can
|
|
277
|
+
// remove '005C:02' by passing '005C:03', '$5C', or the device code).
|
|
278
|
+
const addr = cheatAddress(code);
|
|
279
|
+
target = active.find((c) => c.code === code) || (addr != null ? active.find((c) => cheatAddress(c.code) === addr) : null);
|
|
280
|
+
} else {
|
|
281
|
+
throw new Error("cheats({op:'remove'}): provide `slot` (index) or `code` (the cheat to remove).");
|
|
282
|
+
}
|
|
283
|
+
if (!target) {
|
|
284
|
+
return { removed: false, reason: `no active cheat matching ${slot != null ? `slot ${slot}` : `code '${code}'`}`, active };
|
|
285
|
+
}
|
|
286
|
+
host.setCheat(target.index, target.code, false); // disable that slot only
|
|
287
|
+
return {
|
|
288
|
+
removed: true,
|
|
289
|
+
slot: target.index,
|
|
290
|
+
code: target.code,
|
|
291
|
+
active: host.listActiveCheats(),
|
|
292
|
+
note: "Disabled that one cheat (volatile); the rest stay active. Use op:'clear' to remove ALL, op:'apply' to add/swap.",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
229
296
|
/** op:'clear' — remove ALL active cheats (volatile core-side reset). */
|
|
230
297
|
export async function cheatsClearCore(_args, sessionKey) {
|
|
231
298
|
const host = getHost(sessionKey);
|
|
@@ -319,7 +386,7 @@ export function registerCheatTools(server, z, sessionKey) {
|
|
|
319
386
|
"round-trip `verified`. RAM cheat = address+value; ROM/code cheat = also `compare` (the byte currently there — " +
|
|
320
387
|
"read it first).",
|
|
321
388
|
{
|
|
322
|
-
op: z.enum(["lookup", "search", "apply", "clear", "make"]).describe("lookup THIS game's DB cheats; search the DB by game name; apply a cheat live; clear
|
|
389
|
+
op: z.enum(["lookup", "search", "apply", "remove", "clear", "make"]).describe("lookup THIS game's DB cheats; search the DB by game name; apply a cheat live (replaces an active cheat on the SAME address); remove ONE active cheat (by slot/code); clear ALL cheats; make a new code."),
|
|
323
390
|
// lookup
|
|
324
391
|
path: z.string().optional().describe("op=lookup/apply(+desc): absolute ROM path. lookup sniffs platform+name from it."),
|
|
325
392
|
filter: z.string().optional().describe("op=lookup: case-insensitive substring filter on cheat descriptions."),
|
|
@@ -330,8 +397,9 @@ export function registerCheatTools(server, z, sessionKey) {
|
|
|
330
397
|
// apply
|
|
331
398
|
code: z.string().optional().describe("op=apply: raw cheat code (ADDR:VAL or a Game Genie code). Provide code OR desc."),
|
|
332
399
|
desc: z.string().optional().describe("op=apply: description of a matched cheat (requires `path`). First substring match is applied."),
|
|
333
|
-
index: z.number().int().min(0).optional().describe("op=apply: cheat slot (default: next free
|
|
400
|
+
index: z.number().int().min(0).optional().describe("op=apply: cheat slot (default: reuse the slot of an active cheat on the same address, else next free). Reuse a slot to replace it."),
|
|
334
401
|
enabled: z.boolean().default(true).describe("op=apply: false disables the slot instead of enabling."),
|
|
402
|
+
slot: z.number().int().min(0).optional().describe("op=remove: the active-cheat slot to disable (from apply's `slot` / active[].index). Provide slot OR code."),
|
|
335
403
|
// make / search / lookup share `platform`
|
|
336
404
|
platform: z.enum([...MAKE_CHEAT_PLATFORMS]).optional().describe("op=lookup: override platform detection. op=search: OPTIONAL — omit to search ALL platforms (each match returns its own `platform`); pass one only to scope the search. op=make: REQUIRED — the target platform (all 14 tier-1)."),
|
|
337
405
|
address: z.number().int().min(0).optional().describe("op=make: address to cheat (RAM addr, or the ROM addr to patch)."),
|
|
@@ -345,6 +413,7 @@ export function registerCheatTools(server, z, sessionKey) {
|
|
|
345
413
|
case "lookup": return jsonContent(await cheatsLookupCore(args));
|
|
346
414
|
case "search": return jsonContent(await cheatsSearchCore(args));
|
|
347
415
|
case "apply": return attachObserverFrame(jsonContent(await cheatsApplyCore(args, sessionKey)), getHost(sessionKey), "cheat applied");
|
|
416
|
+
case "remove": return jsonContent(await cheatsRemoveCore(args, sessionKey));
|
|
348
417
|
case "clear": return jsonContent(await cheatsClearCore(args, sessionKey));
|
|
349
418
|
case "make": return jsonContent(await cheatsMakeCore(args));
|
|
350
419
|
default: throw new Error(`cheats: unknown op '${args.op}'`);
|
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
|
|