romdevtools 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +3 -1
  3. package/src/analysis/analyze.js +1 -1
  4. package/src/analysis/backtrace.js +198 -0
  5. package/src/analysis/nes-ppu-runtime.js +220 -0
  6. package/src/analysis/nes-ppu-shim.js +263 -0
  7. package/src/analysis/pointer-table.js +84 -0
  8. package/src/analysis/recompile-65816.js +584 -0
  9. package/src/cheats/gamegenie.js +14 -2
  10. package/src/cores/registry.js +43 -51
  11. package/src/mcp/state.js +91 -2
  12. package/src/mcp/tools/cheats.js +10 -1
  13. package/src/mcp/tools/disasm.js +309 -21
  14. package/src/mcp/tools/find-references.js +93 -2
  15. package/src/mcp/tools/frame.js +440 -4
  16. package/src/mcp/tools/index.js +34 -1
  17. package/src/mcp/tools/lifecycle.js +53 -24
  18. package/src/mcp/tools/memory.js +1 -1
  19. package/src/mcp/tools/playtest.js +24 -1
  20. package/src/mcp/tools/project.js +1 -1
  21. package/src/mcp/tools/rendering-context.js +4 -2
  22. package/src/mcp/tools/watch-memory.js +87 -9
  23. package/src/mcp/util.js +45 -0
  24. package/src/playtest/playtest.js +115 -46
  25. package/src/toolchains/cc65/da65.js +7 -0
  26. package/src/cores/wasm/bluemsx_libretro.js +0 -2
  27. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  28. package/src/cores/wasm/fceumm_libretro.js +0 -2
  29. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  30. package/src/cores/wasm/gambatte_libretro.js +0 -2
  31. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  32. package/src/cores/wasm/geargrafx_libretro.js +0 -2
  33. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  34. package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
  35. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  36. package/src/cores/wasm/handy_libretro.js +0 -2
  37. package/src/cores/wasm/handy_libretro.wasm +0 -0
  38. package/src/cores/wasm/mgba_libretro.js +0 -2
  39. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  40. package/src/cores/wasm/prosystem_libretro.js +0 -2
  41. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  42. package/src/cores/wasm/snes9x_libretro.js +0 -2
  43. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  44. package/src/cores/wasm/stella2014_libretro.js +0 -2
  45. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  46. package/src/cores/wasm/vice_x64_libretro.js +0 -2
  47. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
@@ -1,5 +1,8 @@
1
1
  import { resolveCore } from "../../cores/registry.js";
2
- import { clearHost, getHost, getHostOrNull, rememberLastMedia, resetHost } from "../state.js";
2
+ import {
3
+ clearHost, clearHostB, getHost, getHostB, getHostBOrNull, getHostOrNull,
4
+ rememberLastMedia, resetHost, resetHostB,
5
+ } from "../state.js";
3
6
  import { jsonContent, safeTool, textContent } from "../util.js";
4
7
  import { resolveCheatCodeForApply } from "./cheats.js";
5
8
  import { attachObserverFrame } from "./watch-memory.js";
@@ -8,12 +11,16 @@ const MEDIA_KINDS = ["cartridge", "disk", "tape", "program"];
8
11
 
9
12
  export function registerLifecycleTools(server, z, sessionKey) {
10
13
  // Shared loader: accepts a file `path` OR base64 `bytes` (exactly one).
11
- async function doLoadMedia({ platform, path, base64, mediaKind, virtualName, cheats }) {
14
+ // slot:'b' targets the secondary comparison host (for frame sideBySide);
15
+ // it gets its own fresh host and does NOT overwrite slot A's recovery
16
+ // breadcrumb, since B is transient scratch, not the session's main ROM.
17
+ async function doLoadMedia({ platform, path, base64, mediaKind, virtualName, cheats, slot }) {
12
18
  const resolved = resolveCore(platform);
13
19
  if (!resolved) throw new Error(`no core available for platform '${platform}'`);
14
20
  if (!path && !base64) throw new Error("loadMedia: provide either `path` (file on disk) or `base64` (ROM bytes).");
15
21
  if (path && base64) throw new Error("loadMedia: provide `path` OR `base64`, not both.");
16
- const host = resetHost(sessionKey);
22
+ const slotB = slot === "b";
23
+ const host = slotB ? resetHostB(sessionKey) : resetHost(sessionKey);
17
24
  await host.loadCore(resolved.jsPath, resolved.wasmPath);
18
25
  const bytes = base64 ? new Uint8Array(Buffer.from(base64, "base64")) : undefined;
19
26
  await host.loadMedia({
@@ -47,10 +54,14 @@ export function registerLifecycleTools(server, z, sessionKey) {
47
54
  }
48
55
  // Remember what we loaded so a later host eviction (restart/reconnect) can
49
56
  // tell the agent the exact loadMedia call to recover with. Survives reset.
50
- rememberLastMedia(sessionKey, {
51
- platform,
52
- ...(bytes ? { fromBase64: true } : { path: host.status.mediaPath ?? path }),
53
- });
57
+ // Only for slot A — the breadcrumb is the primary ROM's recovery anchor;
58
+ // slot B is disposable comparison scratch and must not clobber it.
59
+ if (!slotB) {
60
+ rememberLastMedia(sessionKey, {
61
+ platform,
62
+ ...(bytes ? { fromBase64: true } : { path: host.status.mediaPath ?? path }),
63
+ });
64
+ }
54
65
 
55
66
  // Framebuffer dimensions are NOT known until the core has run at least one
56
67
  // frame — before that, fbWidth/fbHeight hold a pre-boot default (e.g.
@@ -59,10 +70,10 @@ export function registerLifecycleTools(server, z, sessionKey) {
59
70
  // on dimensions, so we omit it until a frame has been stepped and point the
60
71
  // caller at stepFrames instead.
61
72
  const framebufferKnown = host.status.frameCount > 0;
62
- // Livestream: show what just loaded (the boot frame).
63
- return attachObserverFrame(jsonContent({
73
+ const payload = jsonContent({
64
74
  loaded: true,
65
75
  platform,
76
+ ...(slotB ? { slot: "b" } : {}),
66
77
  core: resolved.coreName,
67
78
  mediaKind: host.status.mediaKind,
68
79
  ...(bytes ? { bytes: bytes.length } : { path: host.status.mediaPath }),
@@ -70,7 +81,12 @@ export function registerLifecycleTools(server, z, sessionKey) {
70
81
  ? { framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight } }
71
82
  : { framebufferNote: "Framebuffer dimensions are unknown until the core runs — call stepFrames first, then getStatus (the pre-boot default does not match the real output resolution)." }),
72
83
  ...(appliedCheats ? { cheats: appliedCheats } : {}),
73
- }), host, `loaded ${host.status.mediaPath ? host.status.mediaPath.split("/").pop() : platform}`);
84
+ });
85
+ // Livestream: only slot A drives the human's view (the session's main ROM).
86
+ // Slot B is comparison scratch — surfacing it would flip the livestream
87
+ // back and forth between two ROMs. frame({op:'sideBySide'}) is what shows B.
88
+ if (slotB) return payload;
89
+ return attachObserverFrame(payload, host, `loaded ${host.status.mediaPath ? host.status.mediaPath.split("/").pop() : platform}`);
74
90
  }
75
91
 
76
92
  server.tool(
@@ -79,6 +95,9 @@ export function registerLifecycleTools(server, z, sessionKey) {
79
95
  "Pass `path` (file on disk) OR `base64` (ROM bytes — e.g. straight from buildSource, no disk write, " +
80
96
  "for a fast iteration loop). `cheats` apply BEFORE the first frame (one call instead of loadMedia + " +
81
97
  "applyCheat), so a boot-time code that changes a value the reset code reads is in effect from frame 0. " +
98
+ "`slot:'b'` loads into the SECONDARY comparison host (a different platform is fine) so two cores can run " +
99
+ "at once for frame({op:'sideBySide'}) — the original-vs-port compare loop; slot B does not affect slot A " +
100
+ "or the livestream. " +
82
101
  "NOTE: framebuffer dimensions are omitted until you stepFrames — the pre-boot default does not match the " +
83
102
  "real output resolution.",
84
103
  {
@@ -88,6 +107,7 @@ export function registerLifecycleTools(server, z, sessionKey) {
88
107
  mediaKind: z.enum(MEDIA_KINDS).optional().describe("Default 'cartridge' for consoles, 'program' for C64."),
89
108
  virtualName: z.string().optional().describe("With `base64`: virtual filename shown to cores that fopen() the path (default '/rom')."),
90
109
  cheats: z.array(z.string()).max(64).optional().describe("Codes applied before the first frame (Game Genie / raw ADDR:VAL[:COMPARE] / native device codes). A raw ROM-address code is re-encoded to a read-intercept so it doesn't silently no-op. Returns a per-code `cheats:[{code, appliedAs, applied}]` report."),
110
+ slot: z.enum(["a", "b"]).default("a").describe("'a' (default) = the session's primary host (what every other tool uses). 'b' = the secondary comparison host used by frame({op:'sideBySide'}); load the second ROM here. Slot B is independent scratch — it keeps no recovery breadcrumb and never drives the livestream."),
91
111
  },
92
112
  safeTool(doLoadMedia),
93
113
  );
@@ -103,41 +123,50 @@ export function registerLifecycleTools(server, z, sessionKey) {
103
123
  "boot-seeded variables PERSIST). Pass `hard:true` for a TRUE power-cycle that reloads the ROM from scratch and " +
104
124
  "clears RAM + re-seeds boot state — use it when re-testing boot-time behavior (a soft reset boots the PREVIOUS " +
105
125
  "state).\n" +
106
- "'pause': halt emulation (stepFrames returns 0 until resume). 'resume': continue.",
126
+ "'pause': halt emulation (stepFrames returns 0 until resume). 'resume': continue.\n" +
127
+ "`slot:'b'` targets the secondary comparison host (loaded via loadMedia({slot:'b'})). Slot-B ops never " +
128
+ "touch the livestream.",
107
129
  {
108
130
  op: z.enum(["unload", "shutdown", "reset", "pause", "resume"]).describe("unload media; shutdown the host; reset (soft/hard); pause; resume."),
109
131
  hard: z.boolean().default(false).describe("op=reset: true = full power-cycle (reload the ROM; clears work RAM + boot-seeded state). false (default) = soft RESET-button reset (RAM persists)."),
132
+ slot: z.enum(["a", "b"]).default("a").describe("'a' (default) = the primary host. 'b' = the secondary comparison host (frame sideBySide)."),
110
133
  },
111
- safeTool(async ({ op, hard }) => {
134
+ safeTool(async ({ op, hard, slot }) => {
135
+ const slotB = slot === "b";
136
+ const get = slotB ? getHostB : getHost;
137
+ const getOrNull = slotB ? getHostBOrNull : getHostOrNull;
138
+ // Slot B never drives the human's livestream — wrap attachObserverFrame so
139
+ // slot-A behavior is unchanged but slot B stays silent.
140
+ const observe = slotB ? (content) => content : attachObserverFrame;
112
141
  switch (op) {
113
142
  case "unload": {
114
- const host = getHostOrNull(sessionKey);
143
+ const host = getOrNull(sessionKey);
115
144
  if (!host || !host.status.loaded) {
116
145
  // Don't claim success when there was nothing loaded — that masks a
117
146
  // session/state mix-up (the agent thinks it unloaded media it never had).
118
- return textContent("nothing to unload — no media is loaded in this session");
147
+ return textContent(`nothing to unload — no media is loaded in ${slotB ? "comparison slot B" : "this session"}`);
119
148
  }
120
149
  host.unloadMedia();
121
- return textContent("unloaded");
150
+ return textContent(`unloaded${slotB ? " (slot B)" : ""}`);
122
151
  }
123
152
  case "shutdown":
124
- clearHost(sessionKey);
125
- return textContent("shutdown complete");
153
+ if (slotB) clearHostB(sessionKey); else clearHost(sessionKey);
154
+ return textContent(`shutdown complete${slotB ? " (slot B)" : ""}`);
126
155
  case "reset": {
127
- const host = getHost(sessionKey);
156
+ const host = get(sessionKey);
128
157
  if (hard) {
129
158
  const reloaded = await host.hardReset();
130
- return attachObserverFrame(textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)"), host, "reset (hard)");
159
+ return observe(textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)"), host, "reset (hard)");
131
160
  }
132
161
  host.reset();
133
- return attachObserverFrame(textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)"), host, "reset");
162
+ return observe(textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)"), host, "reset");
134
163
  }
135
164
  case "pause":
136
- getHost(sessionKey).pause();
137
- return textContent("paused");
165
+ get(sessionKey).pause();
166
+ return textContent(`paused${slotB ? " (slot B)" : ""}`);
138
167
  case "resume":
139
- getHost(sessionKey).resume();
140
- return textContent("resumed");
168
+ get(sessionKey).resume();
169
+ return textContent(`resumed${slotB ? " (slot B)" : ""}`);
141
170
  default:
142
171
  throw new Error(`host: unknown op '${op}'`);
143
172
  }
@@ -542,7 +542,7 @@ async function memSearch(sessionKey, { value, size = 1, as = "raw", region = "sy
542
542
  // with searchNext compare:'dec'/'inc'/'unchanged'/'changed'/'gt'/'lt'. This is
543
543
  // the canonical "find the lives/score/timer address you can't see" loop, which
544
544
  // op:'search' (requires a value) can't do. (0.28.0 feedback #1.)
545
- async function memSearchUnknown(sessionKey, { size = 1, as = "raw", region = "system_ram", name = "default", maxCandidates = 64 }) {
545
+ async function memSearchUnknown(sessionKey, { size = 1, as = "raw", region = "system_ram", name = "default", maxCandidates: _maxCandidates = 64 }) {
546
546
  const host = getHost(sessionKey);
547
547
  if (as === "digits") throw new Error("memory({op:'searchUnknown'}): as:'digits' needs a value; use as:'raw' or 'bcd' for an unknown-value hunt.");
548
548
  const info = REGION_INFO[region] ?? {};
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { writeFile } from "node:fs/promises";
7
7
 
8
- import { getHost, getHostOrNull } from "../state.js";
8
+ import { getHost, getHostOrNull, playtestCheckpointPath } from "../state.js";
9
9
  import { imageContent, jsonContent, safeTool, textContent } from "../util.js";
10
10
  import { log } from "../log.js";
11
11
 
@@ -169,6 +169,10 @@ export function registerPlaytestTools(server, z, sessionKey) {
169
169
  }
170
170
 
171
171
  const { playtest, KEYBOARD_BINDINGS_HELP, C64_BINDINGS_HELP } = await import("../../playtest/playtest.js");
172
+ // Where the rolling auto-checkpoint (eviction survivability) is written.
173
+ // Next to the ROM when it's a real file (so it's obvious + co-located); for
174
+ // base64/in-memory loads, a stable per-session file under the OS temp dir.
175
+ const autoCheckpointPath = playtestCheckpointPath(sessionKey, loadedMediaPath);
172
176
  let session;
173
177
  try {
174
178
  // Pass a live-host accessor so the window FOLLOWS rebuilds: runSource/
@@ -181,7 +185,9 @@ export function registerPlaytestTools(server, z, sessionKey) {
181
185
  scale,
182
186
  title,
183
187
  aspect,
188
+ autoCheckpointPath,
184
189
  });
190
+ session.autoCheckpointPath = autoCheckpointPath;
185
191
  sessions.set(sessionKey, session);
186
192
  } catch (e) {
187
193
  // Branch on WHY it failed — the cause is either the @kmamal/sdl native
@@ -265,6 +271,10 @@ export function registerPlaytestTools(server, z, sessionKey) {
265
271
  scale,
266
272
  aspect,
267
273
  controllerCount: session.controllerCount,
274
+ // Eviction survivability: while this window is open we roll a .state to
275
+ // disk so the human's manual progress survives a session eviction.
276
+ autoCheckpointPath,
277
+ autoCheckpointNote: `Progress auto-saves to ${autoCheckpointPath} every ~15s while the window is open (and on F2). If the session is evicted, state({op:'load', path}) it to restore the human's playthrough instead of replaying from boot.`,
268
278
  // C64 input is non-obvious (games need keyboard keys to START), so ALWAYS
269
279
  // relay the controls — a controller alone IS enough (spare buttons/stick
270
280
  // map to F1/Run-Stop/Space/Return), and the keyboard fallback covers the
@@ -345,6 +355,19 @@ export function registerPlaytestTools(server, z, sessionKey) {
345
355
  "loadMedia swapped it). frame({op:'screenshot'}) now shows the active host, NOT " +
346
356
  "what the human sees. Call playtest({op:'framebuffer'}) to capture the human's window.",
347
357
  }),
358
+ // Eviction survivability: where the human's progress auto-saves, and
359
+ // whether the last write failed (so an agent can warn + suggest a manual
360
+ // state({op:'save', path}) if the rolling checkpoint isn't landing).
361
+ ...((() => {
362
+ const ck = session.lastCheckpoint?.();
363
+ if (!ck || !ck.path) return {};
364
+ return {
365
+ autoCheckpointPath: ck.path,
366
+ ...(ck.lastError
367
+ ? { autoCheckpointError: ck.lastError, autoCheckpointHint: "The rolling auto-checkpoint is FAILING — the human's progress is NOT being saved. Have them pick a writable spot: state({op:'save', path}) manually." }
368
+ : { autoCheckpointNote: "The human's progress auto-saves here every ~15s (and on F2); survives a session eviction via state({op:'load', path})." }),
369
+ };
370
+ })()),
348
371
  });
349
372
  }
350
373
 
@@ -2698,7 +2698,7 @@ ${buildBlock}
2698
2698
  };
2699
2699
  }
2700
2700
 
2701
- async function createGameCore({ platform, genre, name, path: projPath, title, overwrite, verbose = false }) {
2701
+ async function _createGameCore({ platform, genre, name, path: projPath, title, overwrite, verbose = false }) {
2702
2702
  // The five canonical genres. A genre is available on a platform iff
2703
2703
  // TEMPLATES[platform] has a matching template entry — we DERIVE
2704
2704
  // availability from TEMPLATES rather than maintain a parallel table,
@@ -507,8 +507,10 @@ async function gbContext(host, area, platform) {
507
507
  };
508
508
  }
509
509
 
510
- export async function getRenderingContextCore({ platform, area = "all", sessionKey }) {
511
- const host = getHost(sessionKey);
510
+ export async function getRenderingContextCore({ platform, area = "all", sessionKey, host: explicitHost }) {
511
+ // `host` lets a caller (e.g. frame compareRender) decode a SPECIFIC host —
512
+ // the slot-B comparison host — instead of the session's slot-A default.
513
+ const host = explicitHost ?? getHost(sessionKey);
512
514
  const p = platform ?? host.getStatus().platform;
513
515
  switch (p) {
514
516
  case "nes": return nesContext(host, area);
@@ -17,11 +17,87 @@ import path from "node:path";
17
17
  import { getHost } from "../state.js";
18
18
  import { jsonContent, safeTool } from "../util.js";
19
19
  import { getCPUState } from "../../host/cpu-state.js";
20
- import { MemoryRegionToRetro } from "../../host/types.js";
21
20
  import { resolveButtonAlias } from "./input.js";
22
21
  import { getCPUStateCore } from "./platform-tools.js";
23
22
  import { traceVramSourceCore } from "./trace-vram-source.js";
24
23
  import { resolveStatePath } from "./state.js";
24
+ import { buildBacktrace } from "../../analysis/backtrace.js";
25
+ import { mapNesAddress } from "./disasm.js";
26
+ import { MemoryRegionToRetro } from "../../host/types.js";
27
+
28
+ /**
29
+ * Build the decoded call stack for a breakpoint hit: who called the routine the
30
+ * PC is in. Uses the captured register snapshot's stack pointer + the stack RAM.
31
+ * Returns null when unavailable (unsupported ISA / no regs) — never throws.
32
+ * @param {import("../../host/index.js").LibretroHost} host
33
+ * @param {Object|null} regs the `named` register snapshot (has the stack ptr)
34
+ */
35
+ function backtraceForHit(host, regs) {
36
+ if (!regs) return null;
37
+ const platform = host.status?.platform;
38
+
39
+ // 6502: validate the $20 (JSR) opcode at each candidate caller PC by mapping the
40
+ // CPU address to a ROM byte. NES uses the cart image + the mapper.
41
+ let readByteAt = null;
42
+ if (platform === "nes") {
43
+ try {
44
+ const cart = host.getCartRom();
45
+ readByteAt = (cpuAddr) => {
46
+ try {
47
+ const { bytes } = mapNesAddress(cart.raw, cpuAddr, 1);
48
+ return bytes && bytes.length ? bytes[0] : null;
49
+ } catch { return null; }
50
+ };
51
+ } catch { /* no cart / mapping — frames stay best-effort */ }
52
+ }
53
+
54
+ // Z80/SM83 + m68k stacks live in WORK RAM at the SP (not a fixed page). Read the
55
+ // stack via system_ram using the platform's CPU-addr→RAM mask (the same mapping
56
+ // the callSubroutine watchdog uses). Best-effort: return null on any read miss.
57
+ const ramMask = platform === "genesis" ? 0xffff
58
+ : (platform === "gb" || platform === "gbc" || platform === "sms" || platform === "gg" || platform === "msx") ? 0x1fff
59
+ : 0xffff;
60
+ const readRamByte = (cpuAddr) => {
61
+ try {
62
+ const b = host.readMemory("system_ram", cpuAddr & ramMask, 1);
63
+ return b && b.length ? b[0] : null;
64
+ } catch { return null; }
65
+ };
66
+ const readCpuWord = (cpuAddr) => {
67
+ const lo = readRamByte(cpuAddr); const hi = readRamByte(cpuAddr + 1);
68
+ return (lo == null || hi == null) ? null : (lo | (hi << 8));
69
+ };
70
+ const readCpuLongBE = (cpuAddr) => {
71
+ const b0 = readRamByte(cpuAddr), b1 = readRamByte(cpuAddr + 1), b2 = readRamByte(cpuAddr + 2), b3 = readRamByte(cpuAddr + 3);
72
+ return (b0 == null || b1 == null || b2 == null || b3 == null) ? null : ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0;
73
+ };
74
+
75
+ const bt = buildBacktrace({
76
+ platform,
77
+ regs,
78
+ readMemory: (region, off, len) => host.readMemory(region, off, len),
79
+ readByteAt,
80
+ readCpuWord,
81
+ readCpuLongBE,
82
+ });
83
+ if (!bt || !bt.frames.length) return null;
84
+ const pad = bt.isa === "m68k" ? 6 : 4; // 24-bit m68k addresses print wider
85
+ const isaNote = {
86
+ "6502": "confident=true means the byte before the return target is a JSR ($20).",
87
+ sm83: "confident=true means the return address lands in a plausible code range (SM83 call frames are 2-byte LE; the call's own length isn't recovered, so callerPc IS the return address).",
88
+ z80: "confident=true means the return address lands in a plausible code range (Z80 call frames are 2-byte LE; callerPc IS the return address).",
89
+ m68k: "confident=true means the return longword is even + in-range (m68k jsr/bsr push a 4-byte BE return; callerPc IS the return address).",
90
+ }[bt.isa] || "";
91
+ return {
92
+ isa: bt.isa,
93
+ frames: bt.frames.map((f) => ({
94
+ callerPc: "$" + f.callerPc.toString(16).toUpperCase().padStart(pad, "0"),
95
+ returnAddr: "$" + f.returnAddr.toString(16).toUpperCase().padStart(pad, "0"),
96
+ confident: f.confident,
97
+ })),
98
+ note: `callStack[0] is the immediate caller (the call that reached this routine), decoded from the stack at the break instant. ${isaNote} Generated server-side — no hand stack-walking.`,
99
+ };
100
+ }
25
101
 
26
102
  // Restore a savestate (in-memory slot `fromState` OR disk file `fromStatePath`)
27
103
  // before a trace, so on:'range'/'pc' run from a known moment. Returns a small
@@ -130,13 +206,11 @@ export function makePressDriver(host, presses) {
130
206
  };
131
207
  }
132
208
 
133
- // Single source of truth: the same canonical region vocabulary readMemory
134
- // uses (host/types.js). Previously this was a hand-maintained list that had
135
- // drifted it carried DEAD Genesis `md_*` names (which throw on read) and
136
- // was MISSING nes_apu_regs / genesis_* / c64_*, so you couldn't watch the
137
- // hardware-register regions readMemory could already read. Deriving from the
138
- // host map means new regions flow through automatically and the two tools can
139
- // never disagree again.
209
+ // Single source of truth: the same canonical region vocabulary readMemory uses
210
+ // (host/types.js). Derived from the host map so new regions flow through
211
+ // automatically and the two tools never disagree. Used by the ONE primary
212
+ // `on:'mem'` region enum (kept discoverable on purpose); every SECONDARY region
213
+ // sub-param uses the lean regionStr string instead (0.28.0/0.30.0 feedback #5).
140
214
  const MEMORY_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
141
215
 
142
216
  // A region param that does NOT inline the full ~62-value enum into the JSON
@@ -624,6 +698,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
624
698
  hits: result.hits,
625
699
  framesStepped: result.framesStepped,
626
700
  ...(wpRegs ? { registersAtHit: wpRegs } : {}),
701
+ ...((() => { const bt = backtraceForHit(host, wpRegs); return bt ? { callStack: bt } : {}; })()),
627
702
  ...(bankInfo ? bankInfo : {}),
628
703
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
629
704
  note: "pc is the EXACT writing instruction (captured in the CPU write path), not a frame sample. " +
@@ -782,6 +857,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
782
857
  pc: last.lastPC != null ? "$" + last.lastPC.toString(16).toUpperCase() : null,
783
858
  pcRaw: last.lastPC,
784
859
  ...(atHit ? { registersAtHit: atHit } : {}),
860
+ ...((() => { const bt = backtraceForHit(host, atHit); return bt ? { callStack: bt } : {}; })()),
785
861
  ...(capturedMemory ? { capturedMemory } : {}),
786
862
  frame: host.status.frameCount,
787
863
  framesRun,
@@ -1292,7 +1368,9 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
1292
1368
  {
1293
1369
  on: z.enum(["mem", "range", "pc", "dma", "copy"])
1294
1370
  .describe("mem=watch a RAM byte/ranges for value changes over frames (the power tool); range=log every read/write PC in [start,end]; pc=coverage trace of distinct PCs executed in [start,end]; dma=Genesis-only mem→VDP DMA source/dest trace; copy=log every write landing in a VRAM address window with the EXECUTING instruction's PC (all 14 platforms — the generic 'where does this graphic come from?')."),
1295
- // on:'mem'
1371
+ // on:'mem' — the ONE primary region enum kept on purpose (0.30.0 design):
1372
+ // where region IS the choice, the discoverable canonical list stays in the
1373
+ // schema. The secondary region sub-params use the lean regionStr instead.
1296
1374
  region: z.enum(MEMORY_REGIONS).optional().describe("on:'mem' single-range — the region to watch (same canonical set memory uses, incl. nes_apu_regs, genesis_ym2612, c64_sid_regs). Omit when using `ranges`."),
1297
1375
  offset: z.number().int().min(0).default(0).describe("on:'mem' single-range — first byte of the watched range."),
1298
1376
  length: z.number().int().min(1).max(4096).default(1).describe("on:'mem' single-range — bytes to watch (default 1)."),
package/src/mcp/util.js CHANGED
@@ -212,15 +212,60 @@ export function withClearToolErrors(server, z) {
212
212
  return server;
213
213
  }
214
214
 
215
+ // ── Hex-string coercion on address-like params ─────────────────────
216
+ // JSON forbids `0x…` number literals, so an agent that pastes an address as hex
217
+ // (`{address: 0xC06C}`) gets a HARD parse error, and even valid JSON can't carry
218
+ // hex. We accept the STRING forms `"0x…"`, `"$…"`, and decimal strings on
219
+ // address-like params and coerce them to a number BEFORE validation, so the
220
+ // natural thing an agent reaches for just works. (Reported repeatedly in v0.41.0
221
+ // feedback as the #1 first-try-fail.)
222
+ //
223
+ // Matched by KEY NAME (not schema introspection — robust across zod versions).
224
+ // DELIBERATELY NARROW: only names that are unambiguously a numeric address/offset
225
+ // across the toolset. Names like `start`/`end`/`from`/`to`/`target`/`compare` are
226
+ // EXCLUDED because they're also booleans (the START button) or enums (`compare:'eq'`,
227
+ // `from:'aseprite'`); the coercer passes non-hex strings through, but not wrapping
228
+ // them at all keeps those schemas pristine. Address-suffixed forms (`startAddress`,
229
+ // `endAddress`) DO match and cover the range-bound case.
230
+ const ADDR_KEY_RE = /^(address|cpuAddress|addr|offset|pc|compare|startAddress|endAddress|baseAddress|targetAddress|fromAddress|toAddress|romOffset|prgOffset|vramAddr)$/i;
231
+
232
+ /** Coerce `"0x1A"` / `"$1A"` / `"26"` → number; pass through numbers/undefined/
233
+ * non-hex strings unchanged (so a non-numeric value still hits the real schema
234
+ * error). Exported for unit tests. */
235
+ export function coerceHexNumber(v) {
236
+ if (typeof v !== "string") return v;
237
+ const s = v.trim();
238
+ if (/^[$]([0-9a-fA-F]+)$/.test(s)) return parseInt(s.slice(1), 16);
239
+ if (/^0x[0-9a-fA-F]+$/i.test(s)) return parseInt(s, 16);
240
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10);
241
+ return v; // leave anything else for the inner schema to reject
242
+ }
243
+
215
244
  /**
216
245
  * Build a `.strict()` z.object from a tool's shape whose validation issues each
217
246
  * render as a clear sentence (unknown-key "did you mean", enum options, missing
218
247
  * required, wrong type). Used by withClearToolErrors to replace the stored schema.
248
+ * Also wraps address-like params with hex-string coercion (see coerceHexNumber).
219
249
  * @param {any} z
220
250
  * @param {Record<string, any>} shape
221
251
  * @param {string} toolName
222
252
  */
223
253
  function strictFriendlyObject(z, shape, toolName) {
254
+ // Wrap address-like fields with a hex-string→number preprocessor. z.preprocess
255
+ // runs the coercion first, then the field's own schema (number().int()…)
256
+ // validates the result — so descriptions, optionality, and ranges are preserved.
257
+ const coercedShape = {};
258
+ for (const [key, schema] of Object.entries(shape)) {
259
+ if (ADDR_KEY_RE.test(key)) {
260
+ // z.preprocess drops the wrapper's .description (which tools/list needs),
261
+ // so re-attach the field's own description to the wrapped schema.
262
+ const wrapped = z.preprocess(coerceHexNumber, schema);
263
+ coercedShape[key] = schema.description ? wrapped.describe(schema.description) : wrapped;
264
+ } else {
265
+ coercedShape[key] = schema;
266
+ }
267
+ }
268
+ shape = coercedShape;
224
269
  const validKeys = Object.keys(shape);
225
270
  const errorMap = (issue) => {
226
271
  switch (issue.code) {