romdevtools 0.41.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/package.json +3 -1
- package/src/analysis/analyze.js +1 -1
- package/src/analysis/backtrace.js +198 -0
- package/src/analysis/nes-ppu-runtime.js +220 -0
- package/src/analysis/nes-ppu-shim.js +263 -0
- package/src/analysis/pointer-table.js +84 -0
- package/src/analysis/recompile-65816.js +584 -0
- package/src/cheats/gamegenie.js +14 -2
- package/src/cores/registry.js +43 -51
- package/src/mcp/state.js +91 -2
- package/src/mcp/tools/cheats.js +10 -1
- package/src/mcp/tools/disasm.js +309 -21
- package/src/mcp/tools/find-references.js +93 -2
- package/src/mcp/tools/frame.js +440 -4
- package/src/mcp/tools/index.js +34 -1
- package/src/mcp/tools/lifecycle.js +53 -24
- package/src/mcp/tools/memory.js +1 -1
- package/src/mcp/tools/playtest.js +24 -1
- package/src/mcp/tools/project.js +1 -1
- package/src/mcp/tools/rendering-context.js +4 -2
- package/src/mcp/tools/watch-memory.js +87 -9
- package/src/mcp/util.js +45 -0
- package/src/playtest/playtest.js +115 -46
- package/src/toolchains/cc65/da65.js +7 -0
- package/src/cores/wasm/bluemsx_libretro.js +0 -2
- package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
- package/src/cores/wasm/fceumm_libretro.js +0 -2
- package/src/cores/wasm/fceumm_libretro.wasm +0 -0
- package/src/cores/wasm/gambatte_libretro.js +0 -2
- package/src/cores/wasm/gambatte_libretro.wasm +0 -0
- package/src/cores/wasm/geargrafx_libretro.js +0 -2
- package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
- package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
- package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
- package/src/cores/wasm/handy_libretro.js +0 -2
- package/src/cores/wasm/handy_libretro.wasm +0 -0
- package/src/cores/wasm/mgba_libretro.js +0 -2
- package/src/cores/wasm/mgba_libretro.wasm +0 -0
- package/src/cores/wasm/prosystem_libretro.js +0 -2
- package/src/cores/wasm/prosystem_libretro.wasm +0 -0
- package/src/cores/wasm/snes9x_libretro.js +0 -2
- package/src/cores/wasm/snes9x_libretro.wasm +0 -0
- package/src/cores/wasm/stella2014_libretro.js +0 -2
- package/src/cores/wasm/stella2014_libretro.wasm +0 -0
- package/src/cores/wasm/vice_x64_libretro.js +0 -2
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { resolveCore } from "../../cores/registry.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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 =
|
|
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(
|
|
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(
|
|
150
|
+
return textContent(`unloaded${slotB ? " (slot B)" : ""}`);
|
|
122
151
|
}
|
|
123
152
|
case "shutdown":
|
|
124
|
-
clearHost(sessionKey);
|
|
125
|
-
return textContent(
|
|
153
|
+
if (slotB) clearHostB(sessionKey); else clearHost(sessionKey);
|
|
154
|
+
return textContent(`shutdown complete${slotB ? " (slot B)" : ""}`);
|
|
126
155
|
case "reset": {
|
|
127
|
-
const host =
|
|
156
|
+
const host = get(sessionKey);
|
|
128
157
|
if (hard) {
|
|
129
158
|
const reloaded = await host.hardReset();
|
|
130
|
-
return
|
|
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
|
|
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
|
-
|
|
137
|
-
return textContent(
|
|
165
|
+
get(sessionKey).pause();
|
|
166
|
+
return textContent(`paused${slotB ? " (slot B)" : ""}`);
|
|
138
167
|
case "resume":
|
|
139
|
-
|
|
140
|
-
return textContent(
|
|
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
|
}
|
package/src/mcp/tools/memory.js
CHANGED
|
@@ -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
|
|
package/src/mcp/tools/project.js
CHANGED
|
@@ -2698,7 +2698,7 @@ ${buildBlock}
|
|
|
2698
2698
|
};
|
|
2699
2699
|
}
|
|
2700
2700
|
|
|
2701
|
-
async function
|
|
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
|
-
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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) {
|