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 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.43.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",
@@ -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
- const slot = index != null ? index : host.listActiveCheats().length;
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: (reencodedFrom
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 all cheats; make a new code."),
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 slot). Reuse a slot to replace it."),
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}'`);
@@ -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
- if (!host) return { da65Toolchain: da65 };
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
- da65Toolchain: da65, // disasm({target:'rom'/'references'/...})
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 = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
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 so this is " +
801
- "likely the WRONG ADDRESS for the path that actually ran (a different routine handles it), " +
802
- "or the address isn't an instruction boundary (mid-instruction never matches REG_PC). "
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 events = r.events.slice(0, limit).map((e) => ({
1317
- pc: "$" + e.pc.toString(16).toUpperCase(),
1318
- address: "$" + e.address.toString(16).toUpperCase(),
1319
- value: "0x" + e.value.toString(16).toUpperCase().padStart(2, "0"),
1320
- }));
1321
- // distinct PCs are the actionable summary
1322
- const distinctPCs = [...new Set(r.events.map((e) => e.pc))].slice(0, 64)
1323
- .map((p) => "$" + p.toString(16).toUpperCase());
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: "$" + start.toString(16).toUpperCase() + "..$" + end.toString(16).toUpperCase(),
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().default(true).describe("on:'dma' precision:'exact' — collapse identical DMAs (same dest+source+length+code) to one entry with an `occurrences` count (default on)."),
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