romdevtools 0.40.2 → 0.41.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/AGENTS.md +2 -2
- package/CHANGELOG.md +74 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/analysis/analyze.js +319 -47
- package/src/analysis/rizin.js +13 -1
- package/src/cores/capabilities.js +218 -0
- package/src/mcp/tools/disasm.js +23 -4
- package/src/mcp/tools/platform-tools.js +17 -5
- package/src/mcp/tools/platforms.js +18 -3
- package/src/mcp/tools/rendering-context.js +5 -4
- package/src/mcp/tools/watch-memory.js +144 -2
- package/src/mcp/util.js +37 -0
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +23 -8
- package/src/toolchains/_worker/pool.js +41 -3
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// capabilities.js — the platform CAPABILITY CONTRACT.
|
|
2
|
+
//
|
|
3
|
+
// One declarative entry per platform stating, in machine-readable form, what
|
|
4
|
+
// romdev's platform-sensitive tools can do on it. This is the SOURCE OF TRUTH
|
|
5
|
+
// the BUILDING.md `deep`/`shallow` legend used to encode as prose — now data,
|
|
6
|
+
// enforced by test/capability-conformance.test.js (declared MUST exactly match
|
|
7
|
+
// actual tool behavior; mismatch fails CI).
|
|
8
|
+
//
|
|
9
|
+
// Why it exists: the 14 tier-1 platforms are near-uniform, but the next-gen tier
|
|
10
|
+
// (N64/PS1/Dreamcast/PSP/DS) breaks that — 3D rendering, MIPS/SH-4 CPUs, GPU-FBO
|
|
11
|
+
// screenshots, ops that are meaningless on a polygon renderer. A declared
|
|
12
|
+
// contract + a uniform "unsupported" signal lets agents discover what a system
|
|
13
|
+
// can do BEFORE calling, and keeps the matrix honest as platforms diverge.
|
|
14
|
+
//
|
|
15
|
+
// Fields (keep MINIMAL — only what the contract / discovery / conformance use):
|
|
16
|
+
// cpuFamily primary CPU family (forward-looking; "6502","z80","sm83",
|
|
17
|
+
// "m68k","arm","65816","huc6280" today; "mips","sh4" later)
|
|
18
|
+
// renderingKind "tile" | "framebuffer" | "3d" | "none" — how the screen is
|
|
19
|
+
// produced. The current 14 are all "tile". Drives whether
|
|
20
|
+
// tile/nametable inspection ops even make sense.
|
|
21
|
+
// introspection "deep" | "shallow" — the BUILDING.md legend, as data.
|
|
22
|
+
// ops.* boolean per platform-sensitive op (see OP_KEYS below).
|
|
23
|
+
// decompileQuality "excellent"|"good"|"medium"|"rough" (from the RE engine).
|
|
24
|
+
// cpus { main, secondary[] } — what getCPUState({op:'read'}) decodes.
|
|
25
|
+
// audioChips chip ids audioDebug({op:'inspect'}) decodes ([] = no chip).
|
|
26
|
+
// memoryRegions exact region ids memory({op}) accepts beyond the generic set.
|
|
27
|
+
|
|
28
|
+
/** The platform-sensitive op keys the contract tracks. Universal tools (build's
|
|
29
|
+
* file plumbing, encodeAudio, catalog, files, ...) are NOT here — they don't
|
|
30
|
+
* vary by platform. */
|
|
31
|
+
export const OP_KEYS = /** @type {const} */ ([
|
|
32
|
+
"build", // buildSource for this platform
|
|
33
|
+
"run", // loadMedia + a real core
|
|
34
|
+
"screenshot", // frame({op:'screenshot'})
|
|
35
|
+
"inspectSprites", // sprites({op:'inspect'})
|
|
36
|
+
"inspectPalette", // palette({source:'live'})
|
|
37
|
+
"inspectBackground", // background({view:'renderState'/'map'})
|
|
38
|
+
"renderingContext", // background({view:'renderState'}) decode
|
|
39
|
+
"cpuState", // cpu({op:'read'}) main CPU
|
|
40
|
+
"audioDebug", // audioDebug({op:'inspect'})
|
|
41
|
+
"cart", // cart({op:'extract'/'wrap'})
|
|
42
|
+
"disasm", // disasm({target:'rom'/'project'/'references'})
|
|
43
|
+
"decompile", // disasm({target:'decompile'}) — RE engine, all 14
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// Generic regions every running core exposes (libretro RETRO_MEMORY_*).
|
|
47
|
+
const GENERIC_REGIONS = ["system_ram", "save_ram", "video_ram", "rtc"];
|
|
48
|
+
|
|
49
|
+
/** @typedef {{ cpuFamily:string, renderingKind:"tile"|"framebuffer"|"3d"|"none",
|
|
50
|
+
* introspection:"deep"|"shallow", ops:Record<string,boolean>,
|
|
51
|
+
* decompileQuality:string, cpus:{main:string, secondary:string[]},
|
|
52
|
+
* audioChips:string[], memoryRegions:string[] }} Capability */
|
|
53
|
+
|
|
54
|
+
const tileDeep = ({ ops: opsOverride = {}, ...rest } = {}) => ({
|
|
55
|
+
renderingKind: "tile", introspection: "deep",
|
|
56
|
+
...rest,
|
|
57
|
+
ops: {
|
|
58
|
+
build: true, run: true, screenshot: true,
|
|
59
|
+
inspectSprites: true, inspectPalette: true, inspectBackground: true,
|
|
60
|
+
renderingContext: true, cpuState: true, audioDebug: true,
|
|
61
|
+
cart: true, disasm: true, decompile: true,
|
|
62
|
+
...opsOverride,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** @type {Record<string, Capability>} */
|
|
67
|
+
export const CAPABILITIES = {
|
|
68
|
+
nes: {
|
|
69
|
+
cpuFamily: "6502", decompileQuality: "rough",
|
|
70
|
+
cpus: { main: "6502", secondary: [] },
|
|
71
|
+
audioChips: ["nes"],
|
|
72
|
+
memoryRegions: [...GENERIC_REGIONS, "nes_nametables", "nes_palette", "nes_oam",
|
|
73
|
+
"nes_chr", "nes_apu_regs", "nes_cpu_regs", "nes_ppu_regs"],
|
|
74
|
+
...tileDeep(),
|
|
75
|
+
},
|
|
76
|
+
snes: {
|
|
77
|
+
cpuFamily: "65816", decompileQuality: "medium",
|
|
78
|
+
cpus: { main: "65816", secondary: ["spc700"] },
|
|
79
|
+
audioChips: ["dsp"],
|
|
80
|
+
memoryRegions: [...GENERIC_REGIONS, "snes_oam", "snes_cgram", "snes_aram", "snes_fillram"],
|
|
81
|
+
...tileDeep(),
|
|
82
|
+
},
|
|
83
|
+
genesis: {
|
|
84
|
+
cpuFamily: "m68k", decompileQuality: "excellent",
|
|
85
|
+
cpus: { main: "m68k", secondary: [] }, // z80 not yet decoded
|
|
86
|
+
audioChips: ["ym2612", "psg"],
|
|
87
|
+
memoryRegions: [...GENERIC_REGIONS, "genesis_cram", "genesis_vsram", "genesis_vdp_regs",
|
|
88
|
+
"genesis_z80_ram", "genesis_m68k", "genesis_ym2612", "genesis_psg"],
|
|
89
|
+
...tileDeep(),
|
|
90
|
+
},
|
|
91
|
+
sms: {
|
|
92
|
+
cpuFamily: "z80", decompileQuality: "good",
|
|
93
|
+
cpus: { main: "z80", secondary: [] },
|
|
94
|
+
audioChips: ["psg"],
|
|
95
|
+
memoryRegions: [...GENERIC_REGIONS, "sms_vram", "sms_cram", "sms_vdp_regs", "sms_z80_regs"],
|
|
96
|
+
...tileDeep(),
|
|
97
|
+
},
|
|
98
|
+
gg: {
|
|
99
|
+
cpuFamily: "z80", decompileQuality: "good",
|
|
100
|
+
cpus: { main: "z80", secondary: [] },
|
|
101
|
+
audioChips: ["psg"],
|
|
102
|
+
memoryRegions: [...GENERIC_REGIONS, "gg_vram", "gg_cram"],
|
|
103
|
+
...tileDeep(),
|
|
104
|
+
},
|
|
105
|
+
gb: {
|
|
106
|
+
cpuFamily: "sm83", decompileQuality: "good",
|
|
107
|
+
cpus: { main: "sm83", secondary: [] },
|
|
108
|
+
audioChips: ["gb"],
|
|
109
|
+
memoryRegions: [...GENERIC_REGIONS, "gb_vram", "gb_oam", "gb_io", "gb_hram",
|
|
110
|
+
"gb_bgpdata", "gb_objpdata", "gb_cpu_regs"],
|
|
111
|
+
...tileDeep(),
|
|
112
|
+
},
|
|
113
|
+
gbc: {
|
|
114
|
+
cpuFamily: "sm83", decompileQuality: "good",
|
|
115
|
+
cpus: { main: "sm83", secondary: [] },
|
|
116
|
+
audioChips: ["gb"],
|
|
117
|
+
memoryRegions: [...GENERIC_REGIONS, "gb_vram", "gb_oam", "gb_io", "gb_hram",
|
|
118
|
+
"gb_bgpdata", "gb_objpdata", "gb_cpu_regs"],
|
|
119
|
+
...tileDeep(),
|
|
120
|
+
},
|
|
121
|
+
gba: {
|
|
122
|
+
cpuFamily: "arm", decompileQuality: "excellent",
|
|
123
|
+
cpus: { main: "arm", secondary: [] },
|
|
124
|
+
audioChips: ["gba"],
|
|
125
|
+
memoryRegions: [...GENERIC_REGIONS, "gba_cpu_regs", "gba_io_regs", "gba_palette", "gba_oam"],
|
|
126
|
+
// GBA: the patched mgba regions give MORE than BUILDING.md's old "shallow"
|
|
127
|
+
// prose implied — inspectSprites/Palette + renderingContext + cpuState +
|
|
128
|
+
// audioDebug are all wired (per the tool Supported lists). cart extract/wrap
|
|
129
|
+
// and inspectBackgroundMap are NOT.
|
|
130
|
+
renderingKind: "tile", introspection: "shallow",
|
|
131
|
+
ops: {
|
|
132
|
+
build: true, run: true, screenshot: true,
|
|
133
|
+
inspectSprites: true, inspectPalette: true, inspectBackground: false,
|
|
134
|
+
renderingContext: true, cpuState: true, audioDebug: true,
|
|
135
|
+
cart: false, disasm: true, decompile: true,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
atari2600: {
|
|
139
|
+
cpuFamily: "6502", decompileQuality: "rough",
|
|
140
|
+
cpus: { main: "6502", secondary: [] },
|
|
141
|
+
audioChips: [], // TIA tone, no dedicated sound chip audioDebug decodes
|
|
142
|
+
memoryRegions: [...GENERIC_REGIONS, "a26_tia_regs", "a26_cpu_regs"],
|
|
143
|
+
...tileDeep({ ops: { audioDebug: false, inspectSprites: true, inspectBackground: false } }),
|
|
144
|
+
},
|
|
145
|
+
atari7800: {
|
|
146
|
+
cpuFamily: "6502", decompileQuality: "rough",
|
|
147
|
+
cpus: { main: "6502", secondary: [] },
|
|
148
|
+
audioChips: [], // TIA; no audioDebug decode
|
|
149
|
+
memoryRegions: [...GENERIC_REGIONS, "a78_cpu_regs"],
|
|
150
|
+
...tileDeep({ ops: { audioDebug: false, inspectBackground: false } }),
|
|
151
|
+
},
|
|
152
|
+
c64: {
|
|
153
|
+
cpuFamily: "6502", decompileQuality: "rough",
|
|
154
|
+
cpus: { main: "6502", secondary: [] },
|
|
155
|
+
audioChips: ["sid"],
|
|
156
|
+
memoryRegions: [...GENERIC_REGIONS, "c64_color_ram", "c64_vic_regs", "c64_sid_regs",
|
|
157
|
+
"c64_cia1_regs", "c64_cia2_regs", "c64_cpu_regs"],
|
|
158
|
+
// C64 has no inspectBackgroundMap branch (character-mode screen is read via
|
|
159
|
+
// raw VIC regions, not a snapshotter).
|
|
160
|
+
...tileDeep({ ops: { inspectBackground: false } }),
|
|
161
|
+
},
|
|
162
|
+
lynx: {
|
|
163
|
+
cpuFamily: "65c02", decompileQuality: "rough",
|
|
164
|
+
cpus: { main: "65c02", secondary: [] },
|
|
165
|
+
audioChips: ["mikey"],
|
|
166
|
+
memoryRegions: [...GENERIC_REGIONS, "lynx_cpu_regs", "lynx_hw_regs"],
|
|
167
|
+
// Lynx: sprites return the SCB list head (no fixed OAM) — counts as wired.
|
|
168
|
+
// shallow per BUILDING.md (generic introspection + sfx/music templates).
|
|
169
|
+
renderingKind: "tile", introspection: "shallow",
|
|
170
|
+
ops: {
|
|
171
|
+
build: true, run: true, screenshot: true,
|
|
172
|
+
inspectSprites: true, inspectPalette: true, inspectBackground: false,
|
|
173
|
+
renderingContext: true, cpuState: true, audioDebug: true,
|
|
174
|
+
cart: false, disasm: true, decompile: true,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
pce: {
|
|
178
|
+
cpuFamily: "huc6280", decompileQuality: "medium",
|
|
179
|
+
cpus: { main: "", secondary: [] }, // getCPUState main NOT wired for pce
|
|
180
|
+
audioChips: ["pce"],
|
|
181
|
+
memoryRegions: [...GENERIC_REGIONS, "pce_vdc_vram", "pce_vdc_satb", "pce_vdc_regs",
|
|
182
|
+
"pce_vce_palette", "pce_cpu_regs", "pce_psg_regs"],
|
|
183
|
+
renderingKind: "tile", introspection: "deep",
|
|
184
|
+
ops: {
|
|
185
|
+
build: true, run: true, screenshot: true,
|
|
186
|
+
inspectSprites: true, inspectPalette: true, inspectBackground: false,
|
|
187
|
+
renderingContext: true, cpuState: false, audioDebug: true,
|
|
188
|
+
cart: false, disasm: true, decompile: true,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
msx: {
|
|
192
|
+
cpuFamily: "z80", decompileQuality: "good",
|
|
193
|
+
cpus: { main: "", secondary: [] }, // getCPUState main NOT wired for msx
|
|
194
|
+
audioChips: ["ay8910"],
|
|
195
|
+
memoryRegions: [...GENERIC_REGIONS, "msx_vram", "msx_vdp_regs", "msx_vdp_status",
|
|
196
|
+
"msx_palette", "msx_cpu_regs", "msx_psg_regs"],
|
|
197
|
+
renderingKind: "tile", introspection: "deep",
|
|
198
|
+
ops: {
|
|
199
|
+
build: true, run: true, screenshot: true,
|
|
200
|
+
inspectSprites: true, inspectPalette: true, inspectBackground: false,
|
|
201
|
+
renderingContext: true, cpuState: false, audioDebug: true,
|
|
202
|
+
cart: false, disasm: true, decompile: true,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** All platform ids in the contract. */
|
|
208
|
+
export const CONTRACT_PLATFORMS = Object.keys(CAPABILITIES);
|
|
209
|
+
|
|
210
|
+
/** Does `platform` support `op`? Unknown platform/op → false. */
|
|
211
|
+
export function supports(platform, op) {
|
|
212
|
+
return Boolean(CAPABILITIES[platform]?.ops?.[op]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The full capability entry for a platform (or null). */
|
|
216
|
+
export function capabilitiesFor(platform) {
|
|
217
|
+
return CAPABILITIES[platform] ?? null;
|
|
218
|
+
}
|
package/src/mcp/tools/disasm.js
CHANGED
|
@@ -1167,14 +1167,21 @@ export function registerDisasmTools(server, z) {
|
|
|
1167
1167
|
"refs carry `prgBank` (NES) / `romBank` (everything else). LIMITATION: direct addressing only " +
|
|
1168
1168
|
"(indirect/computed jumps are missed).\n" +
|
|
1169
1169
|
"── RE ENGINE (Rizin + Ghidra, all 14 platforms) ──\n" +
|
|
1170
|
-
"'functions' = Rizin auto-detected function list {address,size,nbbs,cc,callers,callees}; the
|
|
1171
|
-
"an unknown ROM.
|
|
1170
|
+
"'functions' = Rizin auto-detected function list {address,size,nbbs,cc,callers,callees,looksLikeData}; the " +
|
|
1171
|
+
"structural map of an unknown ROM. Sorted REAL CODE FIRST (by nbbs/cc) — don't rank by `size`, which is a lie " +
|
|
1172
|
+
"(rizin folds data tables into giant pseudo-functions). `looksLikeData:true` (and the top-level `dataCount`) flags " +
|
|
1173
|
+
"those data-folds so you don't waste a decompile on a graphics blob. " +
|
|
1174
|
+
"'cfg' = basic-block control-flow graph of the function at `address` (nodes + typed edges: " +
|
|
1172
1175
|
"jump/branch_true/branch_false). 'xrefs' = every cross-reference TO `address`, following Rizin's analysis graph " +
|
|
1173
1176
|
"(DEEPER than 'references', which is a flat da65 operand scan — prefer 'xrefs' once you've run a function pass, " +
|
|
1174
1177
|
"'references' for a quick header-less operand sweep). Typical RE loop: 'functions' to carve → 'cfg'/'xrefs' to " +
|
|
1175
1178
|
"trace → then the live tools (memory search, write-breakpoints, watch copy) to LABEL what you carved.\n" +
|
|
1176
1179
|
"'decompile' = Ghidra C-like PSEUDOCODE for the function at `address`, with the decompiler's own WARNINGs and a " +
|
|
1177
|
-
"`qualityNote`.
|
|
1180
|
+
"`qualityNote`. Hardware-register MMIO is NAMED in the output (e.g. `PPUMASK = 0x1e;` not `*0x2001 = 0x1e;`) " +
|
|
1181
|
+
"with a `/* hw registers: … */` legend at the top listing each substitution — on platforms with a register map " +
|
|
1182
|
+
"(NES/SNES/Genesis/GB/GBC/SMS/GG/2600/7800/C64). On the 6502 family (NES/2600/7800/C64/Lynx/PCE) a 6502-fold pass also " +
|
|
1183
|
+
"cleans the SLEIGH clutter: width types become C99 stdint (uint1→uint8_t, uint2→uint16_t), redundant nested width " +
|
|
1184
|
+
"casts collapse, and zero-page byte refs are named zp_XX — a `/* 6502 fold: … */` legend notes what was applied. ALTITUDE RULE: decompile is for UNDERSTANDING (and as a port spec when retargeting to a bigger " +
|
|
1178
1185
|
"machine) — it is NOT the same-platform edit path. To CHANGE a ROM and rebuild it, use target:'project' " +
|
|
1179
1186
|
"(byte-exact rebuildable asm); read the pseudocode as documentation alongside it. Per-CPU quality (calibrate, " +
|
|
1180
1187
|
"don't treat low quality as a bug): ARM/GBA + M68K/Genesis = excellent (mostly-C games, real stack frames); " +
|
|
@@ -1183,7 +1190,7 @@ export function registerDisasmTools(server, z) {
|
|
|
1183
1190
|
"LLM folds it. `address` for all four comes from target:'functions' (a CPU/virtual address; the file-offset " +
|
|
1184
1191
|
"mapping is handled for you).",
|
|
1185
1192
|
{
|
|
1186
|
-
target: z.enum(["bytes", "rom", "project", "references", "cfg", "xrefs", "functions", "decompile"]).describe("bytes = raw chunk; rom = mapper-aware ROM; project = full rebuildable disasm; references = flat da65 operand-refs to an address; functions/cfg/xrefs = Rizin RE engine (function list / control-flow graph / deep graph xrefs); decompile = Ghidra C pseudocode. See the tool description for the RE loop + the decompile altitude rule + per-CPU quality (all 14 platforms)."),
|
|
1193
|
+
target: z.enum(["bytes", "rom", "project", "references", "cfg", "xrefs", "functions", "decompile", "resolveJumptable"]).describe("bytes = raw chunk; rom = mapper-aware ROM; project = full rebuildable disasm; references = flat da65 operand-refs to an address; functions/cfg/xrefs = Rizin RE engine (function list / control-flow graph / deep graph xrefs); decompile = Ghidra C pseudocode; resolveJumptable = recover a computed-jump dispatcher's targets (LIVE — redirects to breakpoint({on:'jumptable'}), which runs the emulator and records the real switch arms a static decompiler can't follow). See the tool description for the RE loop + the decompile altitude rule + per-CPU quality (all 14 platforms)."),
|
|
1187
1194
|
// shared
|
|
1188
1195
|
path: z.string().optional().describe("target=bytes: raw binary path. target=rom/project/references: ROM file path."),
|
|
1189
1196
|
base64: z.string().optional().describe("target=bytes: base64 of the bytes (OR `path`)."),
|
|
@@ -1227,6 +1234,18 @@ export function registerDisasmTools(server, z) {
|
|
|
1227
1234
|
case "xrefs": return jsonContent(await analyzeXrefs(requireRomPath(args), args.address, args.platform));
|
|
1228
1235
|
case "functions": return jsonContent(await analyzeFunctions(requireRomPath(args), args.platform));
|
|
1229
1236
|
case "decompile": return jsonContent(await analyzeDecompile(requireRomPath(args), args.address, args.platform));
|
|
1237
|
+
case "resolveJumptable":
|
|
1238
|
+
// A4: jumptable recovery is fundamentally a LIVE operation (it needs a
|
|
1239
|
+
// running emulator to observe the computed targets) — disasm is static
|
|
1240
|
+
// ROM analysis with no session. Redirect to the live op rather than
|
|
1241
|
+
// silently doing nothing.
|
|
1242
|
+
return jsonContent({
|
|
1243
|
+
live: true,
|
|
1244
|
+
redirect: "breakpoint({on:'jumptable'})",
|
|
1245
|
+
address: args.address != null ? "$" + (args.address >>> 0).toString(16).toUpperCase() : null,
|
|
1246
|
+
note: "Computed-jumptable recovery is LIVE, not static: it breaks at the dispatcher in a RUNNING emulator, single-steps through the indirect JMP (table,X)/RTS-trick, and records the targets it actually lands on. Load the ROM (playtest/loadMedia), drive it to the state that runs the dispatcher, then call breakpoint({on:'jumptable', address" +
|
|
1247
|
+
(args.address != null ? `: ${args.address}` : "") + "}). That returns the distinct computed targets; decompile({address: target}) each to read the switch arms. No static-only tool can do this — it needs the live emulator.",
|
|
1248
|
+
});
|
|
1230
1249
|
default: throw new Error(`disasm: unknown target '${args.target}'`);
|
|
1231
1250
|
}
|
|
1232
1251
|
}),
|
|
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { PNG } from "pngjs";
|
|
8
8
|
import { getHost } from "../state.js";
|
|
9
|
-
import { imageContent, jsonContent } from "../util.js";
|
|
9
|
+
import { imageContent, jsonContent, unsupported } from "../util.js";
|
|
10
10
|
|
|
11
11
|
// Consolidation: several handlers in this big shared file are extracted as
|
|
12
12
|
// *Core functions that the consolidated domain tools (palette/tiles/background/
|
|
@@ -338,7 +338,10 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
338
338
|
}, png);
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
|
|
341
|
+
unsupported(p, "inspectPalette", {
|
|
342
|
+
reason: "no palette decoder for this platform",
|
|
343
|
+
alternative: "platform({op:'capabilities'}) to see what's wired",
|
|
344
|
+
});
|
|
342
345
|
};
|
|
343
346
|
|
|
344
347
|
// getCPUState → cpu({op:'read'}) (router in watch-memory.js). Live-binding core.
|
|
@@ -347,7 +350,10 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
347
350
|
const p = resolvePlatform(host, platform);
|
|
348
351
|
const state = getCPUState(host, p, cpu);
|
|
349
352
|
if (!state) {
|
|
350
|
-
|
|
353
|
+
unsupported(p, "cpuState", {
|
|
354
|
+
reason: `no ${cpu === "main" ? "main" : `'${cpu}'`}-CPU decoder for this platform`,
|
|
355
|
+
alternative: "platform({op:'capabilities'}) shows each platform's decoded CPUs (main + secondary); memory({op:'read'}) the raw CPU-register region otherwise",
|
|
356
|
+
});
|
|
351
357
|
}
|
|
352
358
|
return jsonContent({ platform: p, cpu, ...state });
|
|
353
359
|
};
|
|
@@ -694,7 +700,10 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
694
700
|
});
|
|
695
701
|
}
|
|
696
702
|
|
|
697
|
-
|
|
703
|
+
unsupported(p, "inspectSprites", {
|
|
704
|
+
reason: "no sprite decoder for this platform",
|
|
705
|
+
alternative: "memory({op:'read'}) the raw OAM/sprite-attribute region, or platform({op:'capabilities'}) to see what's wired",
|
|
706
|
+
});
|
|
698
707
|
};
|
|
699
708
|
|
|
700
709
|
// inspectBackgroundMap lives in the `background` tool (rendering-context.js)
|
|
@@ -803,7 +812,10 @@ export function registerPlatformTools(server, z, sessionKey) {
|
|
|
803
812
|
});
|
|
804
813
|
return emitImage(r.png, `SNES BG map composite (${r.width}×${r.height}, ${r.mapWidth}×${r.mapHeight} tiles, ${r.bpp}bpp, tilemap@0x${tilemapBaseByte.toString(16)}, tiles@0x${tileBaseByte.toString(16)}). ${r.note}`);
|
|
805
814
|
}
|
|
806
|
-
|
|
815
|
+
unsupported(p, "inspectBackground", {
|
|
816
|
+
reason: "no background-map snapshotter for this platform",
|
|
817
|
+
alternative: "background({view:'renderState'}) for the register-level context, or memory({op:'read'}) the raw VRAM/nametable region",
|
|
818
|
+
});
|
|
807
819
|
};
|
|
808
820
|
|
|
809
821
|
// convertImageToTiles → encodeArt({stage:'tiles'}) (router in sprite-pipeline.js).
|
|
@@ -2,6 +2,7 @@ import { CORES, listAvailableCores, resolveCore } from "../../cores/registry.js"
|
|
|
2
2
|
import { TOOLCHAINS } from "../../toolchains/registry.js";
|
|
3
3
|
import { getLanguageOptions } from "../../toolchains/index.js";
|
|
4
4
|
import { jsonContent, safeTool } from "../util.js";
|
|
5
|
+
import { CAPABILITIES, CONTRACT_PLATFORMS, capabilitiesFor } from "../../cores/capabilities.js";
|
|
5
6
|
import { listToolchainsCore, installToolchainCore } from "./toolchain.js";
|
|
6
7
|
import { listPlatformDocsCore, getPlatformDocCore } from "./platform-docs.js";
|
|
7
8
|
|
|
@@ -99,11 +100,17 @@ export function resolvePlatformCore({ platform }) {
|
|
|
99
100
|
export function registerPlatformTools(server, z) {
|
|
100
101
|
server.tool(
|
|
101
102
|
"platform",
|
|
102
|
-
"Platform/toolchain/docs discovery — what romdev can run and how. `op`: 'list' | '
|
|
103
|
-
"'docs' | 'doc'.\n" +
|
|
103
|
+
"Platform/toolchain/docs discovery — what romdev can run and how. `op`: 'list' | 'capabilities' | 'resolve' | " +
|
|
104
|
+
"'toolchains' | 'docs' | 'doc'.\n" +
|
|
104
105
|
"'list': every platform with its emulator core, toolchain(s), available languages (+ documented default), and " +
|
|
105
106
|
"platform-specific quirks. Call this FIRST to discover what's possible + check a non-default language is " +
|
|
106
107
|
"available before asking build for it.\n" +
|
|
108
|
+
"'capabilities': the CAPABILITY CONTRACT — which platform-sensitive ops a platform supports (inspectSprites/" +
|
|
109
|
+
"Palette/Background, cpuState, audioDebug, renderingContext, cart, disasm, decompile), plus its cpuFamily, " +
|
|
110
|
+
"renderingKind (tile/framebuffer/3d), introspection depth, CPUs, audio chips, and memory regions. Pass `platform` " +
|
|
111
|
+
"for one, omit it for the whole matrix. Check this BEFORE calling a platform-sensitive tool to avoid an " +
|
|
112
|
+
"'unsupported' error — every tool that can't do an op on a platform returns {unsupported:true, platform, op, " +
|
|
113
|
+
"reason, alternative}.\n" +
|
|
107
114
|
"'resolve': resolved core paths for a platform (debugging aid).\n" +
|
|
108
115
|
"'toolchains': the bundled homebrew toolchains (all Tier-1 = bundled WASM, no install). Pass `id` to confirm a " +
|
|
109
116
|
"specific toolchain's install status (a no-op in v1 — everything's bundled).\n" +
|
|
@@ -111,7 +118,7 @@ export function registerPlatformTools(server, z) {
|
|
|
111
118
|
"troubleshooting / upstream_sources; `platform:'romhacking'` + `name:'playbook'` for the RE decision tree). " +
|
|
112
119
|
"Read MENTAL_MODEL before writing code, and the romhacking playbook before a hack.",
|
|
113
120
|
{
|
|
114
|
-
op: z.enum(["list", "resolve", "toolchains", "docs", "doc"]).describe("list=platforms; resolve=core paths; toolchains; docs=a platform's doc names; doc=read one doc."),
|
|
121
|
+
op: z.enum(["list", "capabilities", "resolve", "toolchains", "docs", "doc"]).describe("list=platforms; capabilities=per-platform op support matrix; resolve=core paths; toolchains; docs=a platform's doc names; doc=read one doc."),
|
|
115
122
|
platform: z.string().optional().describe("op=resolve/docs/doc: platform id (e.g. nes, gb, genesis; 'romhacking' for the RE playbook)."),
|
|
116
123
|
id: z.string().optional().describe("op=toolchains: a specific toolchain's install status (e.g. 'cc65')."),
|
|
117
124
|
name: z.string().optional().describe("op=doc: which doc — mental_model | troubleshooting | upstream_sources | playbook."),
|
|
@@ -119,6 +126,14 @@ export function registerPlatformTools(server, z) {
|
|
|
119
126
|
safeTool(async (args) => {
|
|
120
127
|
switch (args.op) {
|
|
121
128
|
case "list": return jsonContent(listPlatformsCore());
|
|
129
|
+
case "capabilities": {
|
|
130
|
+
if (args.platform) {
|
|
131
|
+
const cap = capabilitiesFor(args.platform);
|
|
132
|
+
if (!cap) throw new Error(`platform({op:'capabilities'}): unknown platform '${args.platform}'. Known: ${CONTRACT_PLATFORMS.join(", ")}.`);
|
|
133
|
+
return jsonContent({ platform: args.platform, ...cap });
|
|
134
|
+
}
|
|
135
|
+
return jsonContent({ platforms: CONTRACT_PLATFORMS, capabilities: CAPABILITIES });
|
|
136
|
+
}
|
|
122
137
|
case "resolve": {
|
|
123
138
|
if (!args.platform) throw new Error("platform({op:'resolve'}): `platform` is required.");
|
|
124
139
|
return jsonContent(resolvePlatformCore(args));
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { getHost } from "../state.js";
|
|
12
|
-
import { jsonContent, safeTool } from "../util.js";
|
|
12
|
+
import { jsonContent, safeTool, unsupported } from "../util.js";
|
|
13
13
|
import { inspectBackgroundMapCore } from "./platform-tools.js";
|
|
14
14
|
import { whichTilesAreRenderedCore } from "./which-tiles.js";
|
|
15
15
|
|
|
@@ -530,9 +530,10 @@ export async function getRenderingContextCore({ platform, area = "all", sessionK
|
|
|
530
530
|
case "pce": return pceContext(host, area);
|
|
531
531
|
case "msx": return msxContext(host, area);
|
|
532
532
|
default:
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
533
|
+
unsupported(p, "renderingContext", {
|
|
534
|
+
reason: "no rendering-context decoder for this platform",
|
|
535
|
+
alternative: "platform({op:'capabilities'}) to see what's wired",
|
|
536
|
+
});
|
|
536
537
|
}
|
|
537
538
|
}
|
|
538
539
|
|
|
@@ -793,6 +793,138 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
793
793
|
}), host);
|
|
794
794
|
}
|
|
795
795
|
|
|
796
|
+
// A4: Computed-jumptable recovery via the LIVE emulator. Static analysis follows
|
|
797
|
+
// direct addressing only, so a `JMP (table,X)` / RTS-trick dispatcher collapses
|
|
798
|
+
// to "Could not recover jumptable" — and those dispatchers (game-state machines,
|
|
799
|
+
// script/event VMs, battle engines) are the routines you most want to read. We
|
|
800
|
+
// resolve them dynamically: break at the dispatcher, single-step THROUGH the
|
|
801
|
+
// indirect transfer, and record the PC it actually lands on. Run across many
|
|
802
|
+
// frames/inputs to accumulate the distinct target set — the real switch arms.
|
|
803
|
+
//
|
|
804
|
+
// No standalone tool (IDA/Ghidra/Binary Ninja) can do this: they have no live
|
|
805
|
+
// emulator to observe the computed target. The observed set can be fed back as
|
|
806
|
+
// analysis hints (Rizin ahi/aho) so `decompile`/`cfg` recover the switch.
|
|
807
|
+
async function bpResolveJumptable({ address, maxFrames = 1200, maxTargets = 64, stepLimit = 48, jumpThreshold = 5, pressDuring, fromState, fromStatePath }) {
|
|
808
|
+
const host = getHost(sessionKey);
|
|
809
|
+
if (!host.pcBreakSupported || !host.pcBreakSupported()) {
|
|
810
|
+
return jsonContent({
|
|
811
|
+
ok: false, notSupported: true, address: "$" + address.toString(16).toUpperCase(),
|
|
812
|
+
note: "This core build has no PC breakpoint / single-step (shipped on all 14 platforms as of 0.5.0 — update the core package if you see this).",
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
const restored = await maybeRestoreState(host, fromState, fromStatePath);
|
|
816
|
+
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
817
|
+
const pressDriver = makePressDriver(host, presses);
|
|
818
|
+
|
|
819
|
+
// We separate COMPUTED targets from FIXED trampolines by what VARIES. As we
|
|
820
|
+
// single-step out of the dispatcher, normal flow is sequential (next PC =
|
|
821
|
+
// prev + 1..4 on a 6502, wider on ARM); a computed JMP (table,X) / RTS-trick
|
|
822
|
+
// makes the PC LEAP (delta > jumpThreshold or backward). But a real dispatch
|
|
823
|
+
// path contains FIXED leaps too — cc65 lowers an indirect call to
|
|
824
|
+
// JSR<callax>; JMP(ptr), so the trampoline addresses leap identically on
|
|
825
|
+
// every hit. The handler ARM is the leap destination that DIFFERS hit-to-
|
|
826
|
+
// hit. So: per hit, collect the set of leap destinations; across all hits,
|
|
827
|
+
// a destination seen on EVERY hit is a fixed trampoline (drop it), and one
|
|
828
|
+
// seen on only SOME hits is a real computed arm (keep it). leapSeen counts
|
|
829
|
+
// how many hits each destination appeared in; perHitLeaps holds the firstFrame
|
|
830
|
+
// + a sample fromPC for the keepers.
|
|
831
|
+
const leapSeen = new Map(); // destPC -> hitCount
|
|
832
|
+
const leapMeta = new Map(); // destPC -> { firstFrame, fromPC }
|
|
833
|
+
let dispatcherHits = 0, framesRun = 0;
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
for (let i = 0; i < maxFrames && leapMeta.size < maxTargets * 4; i++) {
|
|
837
|
+
pressDriver.applyForFrame(i);
|
|
838
|
+
host.setPCBreak(address, true, false);
|
|
839
|
+
host.stepFrames(1);
|
|
840
|
+
framesRun++;
|
|
841
|
+
let st = host.getPCBreak(false);
|
|
842
|
+
if (!st.hit) { host.setPCBreak(0, false, false); continue; }
|
|
843
|
+
|
|
844
|
+
const fromPC = st.lastPC ?? address;
|
|
845
|
+
host.getPCBreak(true); // clear the hit so the next arm is clean
|
|
846
|
+
// Walk forward; collect the destination of EVERY control-flow leap in
|
|
847
|
+
// this hit (deduped within the hit), plus the PC it leapt FROM.
|
|
848
|
+
const seenThisHit = new Set();
|
|
849
|
+
let prev = fromPC, prevLeapFrom = fromPC;
|
|
850
|
+
for (let s = 0; s < stepLimit; s++) {
|
|
851
|
+
const step = host.stepInstruction(); // { pc } AFTER one instr
|
|
852
|
+
const pc = step.pc;
|
|
853
|
+
if (pc == null) break;
|
|
854
|
+
const delta = pc - prev;
|
|
855
|
+
if (delta < 0 || delta > jumpThreshold) {
|
|
856
|
+
if (!seenThisHit.has(pc)) {
|
|
857
|
+
seenThisHit.add(pc);
|
|
858
|
+
if (!leapMeta.has(pc)) leapMeta.set(pc, { firstFrame: framesRun, fromPC: prevLeapFrom });
|
|
859
|
+
}
|
|
860
|
+
prevLeapFrom = prev;
|
|
861
|
+
}
|
|
862
|
+
prev = pc;
|
|
863
|
+
}
|
|
864
|
+
for (const pc of seenThisHit) leapSeen.set(pc, (leapSeen.get(pc) ?? 0) + 1);
|
|
865
|
+
dispatcherHits++;
|
|
866
|
+
}
|
|
867
|
+
} finally {
|
|
868
|
+
pressDriver.finish();
|
|
869
|
+
host.setPCBreak(0, false, false); // disarm
|
|
870
|
+
if (host.getRegSnapshot) host.getRegSnapshot(true); // consume any stale snapshot
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const hx = (v) => "$" + (v >>> 0).toString(16).toUpperCase();
|
|
874
|
+
// Classify each leap destination. A COMPUTED arm VARIES across hits — it was
|
|
875
|
+
// reached on some hits but not all (leapSeen < dispatcherHits). A FIXED
|
|
876
|
+
// trampoline (cc65 callax, the post-handler return path) leaps identically
|
|
877
|
+
// EVERY hit (leapSeen == dispatcherHits). Keep the variers as targets.
|
|
878
|
+
const allLeaps = [...leapMeta.entries()].map(([pc, m]) => ({
|
|
879
|
+
pc, hits: leapSeen.get(pc) ?? 0, firstFrame: m.firstFrame, fromPC: m.fromPC,
|
|
880
|
+
}));
|
|
881
|
+
let arms = allLeaps.filter((l) => l.hits < dispatcherHits);
|
|
882
|
+
// Fallback: if NOTHING varied (the dispatcher only ever took one path under
|
|
883
|
+
// this input — a single observed arm), report the non-trampoline leaps as
|
|
884
|
+
// candidate targets rather than nothing. With only one hit, everything has
|
|
885
|
+
// hits==1==dispatcherHits, so report all leaps as candidates.
|
|
886
|
+
let singleArm = false;
|
|
887
|
+
if (!arms.length && allLeaps.length) { arms = allLeaps; singleArm = true; }
|
|
888
|
+
|
|
889
|
+
const sorted = arms
|
|
890
|
+
.slice(0, maxTargets)
|
|
891
|
+
.map((l) => ({ target: hx(l.pc), targetRaw: l.pc, hits: l.hits, firstFrame: l.firstFrame, fromPC: hx(l.fromPC) }))
|
|
892
|
+
.sort((a, b) => b.hits - a.hits || a.targetRaw - b.targetRaw);
|
|
893
|
+
|
|
894
|
+
if (!sorted.length) {
|
|
895
|
+
const drove = presses.length > 0;
|
|
896
|
+
return attachObserverFrame(jsonContent({
|
|
897
|
+
ok: true, resolved: false,
|
|
898
|
+
address: hx(address), framesRun, dispatcherHits,
|
|
899
|
+
...(restored ? { restoredFrom: restored } : {}),
|
|
900
|
+
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
901
|
+
note: dispatcherHits === 0
|
|
902
|
+
? (drove
|
|
903
|
+
? "The dispatcher at this address never executed within maxFrames EVEN WITH the scheduled input — likely the WRONG address (a different routine dispatches), or not an instruction boundary. Confirm with breakpoint({on:'pc'}) that the PC reaches it at all."
|
|
904
|
+
: "The dispatcher never executed — drive the game to the state that runs it (pressDuring / fromState), or increase maxFrames. Confirm reachability with breakpoint({on:'pc'}).")
|
|
905
|
+
: "The dispatcher executed but no control-flow LEAP was observed in the next " + stepLimit + " instructions — so this address isn't (or isn't reaching) a computed jump. Confirm it's the indirect-jump instruction, or raise stepLimit if the dispatch does heavy setup first.",
|
|
906
|
+
}), host);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return attachObserverFrame(jsonContent({
|
|
910
|
+
ok: true, resolved: true,
|
|
911
|
+
address: hx(address),
|
|
912
|
+
targets: sorted,
|
|
913
|
+
distinctTargets: sorted.length,
|
|
914
|
+
dispatcherHits, framesRun,
|
|
915
|
+
...(singleArm ? { singleArmObserved: true } : {}),
|
|
916
|
+
...(arms.length > maxTargets ? { truncated: true, truncatedAt: maxTargets } : {}),
|
|
917
|
+
...(restored ? { restoredFrom: restored } : {}),
|
|
918
|
+
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
919
|
+
note: (singleArm
|
|
920
|
+
? "Only ONE dispatch path ran under this input, so targets are the candidate leap destinations (couldn't separate the computed arm from fixed trampolines without a second arm to compare). Drive MORE game states (pressDuring / fromState) so the dispatcher takes different arms — then the varying one is isolated as the real target. "
|
|
921
|
+
: "targets are the COMPUTED jump destinations that VARIED across dispatches — the real switch arms a static decompiler can't see (fixed trampolines were filtered out). ") +
|
|
922
|
+
"Each is a routine the dispatcher branches to; decompile({address: target}) / disasm({target:'rom', startAddress: target}) to read them. " +
|
|
923
|
+
"hits = how many dispatches took that arm under this input; drive more states to surface rarer arms. " +
|
|
924
|
+
"This is the live-emulator advantage: no static-only tool can recover these.",
|
|
925
|
+
}), host);
|
|
926
|
+
}
|
|
927
|
+
|
|
796
928
|
async function bpRunUntilRead({ address, maxFrames = 600, pressDuring }) {
|
|
797
929
|
const host = getHost(sessionKey);
|
|
798
930
|
if (!host.readWatchSupported || !host.readWatchSupported()) {
|
|
@@ -862,11 +994,12 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
862
994
|
"use it, NOT a follow-up cpu({op:'read'}). On some cores (notably NES/fceumm) the core drains the cycle budget on hit but the frame still finishes, " +
|
|
863
995
|
"so a live cpu read afterward returns END-OF-FRAME registers, not the break instant. `registersAtHit` sidesteps that. The break PC is reported as `pc`/`pcRaw`; " +
|
|
864
996
|
"the RAM side effects are also reliable via memory({op:'read'}). frame({op:'stepInstruction'}) to single-step from the break. (on:'read'/'write' finish the frame.)\n" +
|
|
997
|
+
"• on:'jumptable' — **RESOLVE a computed-jump dispatcher the static decompiler can't follow.** Game-state machines, script/event VMs, and battle engines dispatch through `JMP (table,X)` / RTS-trick tables; `decompile`/`cfg` collapse them to `(*_IRQ)()` + 'Could not recover jumptable'. Break at the dispatcher `address`, single-step THROUGH the indirect transfer, and record the PC it lands on — repeated across frames/inputs to accumulate the DISTINCT target set (the real switch arms), ranked by hit count. Drive more game states (pressDuring / fromState) to surface rarer arms. Returns `{targets:[{target,hits,fromPC}], distinctTargets}`. **No static-only tool (IDA/Ghidra/Binary Ninja) can do this — it needs a live emulator in the loop, which is romdev's edge.** Feed the targets to decompile({address:target}) to read each arm.\n" +
|
|
865
998
|
"All supported on every CPU core. **Every hit carries `registersAtHit` — the FULL register file frozen by the core AT the hit instant, on ALL 14 platforms and all three `on` kinds.** Use it instead of a follow-up cpu({op:'read'}): the live registers keep moving after a hit (per-scanline CPU scheduling / frame completion), so a post-hit read drifts — chasing pointer registers read that way burned a real session for hours. The hit `pc` is the EXECUTING instruction's first byte (mid-instruction hooks no longer report the operand-advanced PC). Out-of-date core packages return notSupported.\n" +
|
|
866
999
|
"MENU-SCREEN INPUT TRICK: if a pressDuring schedule never registers (some menu screens poll input in a way scheduled taps miss), HOLD the button instead: input({op:'set', buttons:{...}}) BEFORE this call and OMIT pressDuring — the run inherits the held state, the menu sees the edge, and the breakpoint catches the event.",
|
|
867
1000
|
{
|
|
868
|
-
on: z.enum(["write", "read", "pc"])
|
|
869
|
-
.describe("write=break on a write to address (precision:exact=true writer PC / sampled=frame PC, a lie under IRQ); read=break on a read (exact PC, who consumes it); pc=break when PC reaches address — the hit returns `registersAtHit` (the break-instant register file, all 14 platforms) + the break PC; use registersAtHit, not a follow-up cpu read
|
|
1001
|
+
on: z.enum(["write", "read", "pc", "jumptable"])
|
|
1002
|
+
.describe("write=break on a write to address (precision:exact=true writer PC / sampled=frame PC, a lie under IRQ); read=break on a read (exact PC, who consumes it); pc=break when PC reaches address — the hit returns `registersAtHit` (the break-instant register file, all 14 platforms) + the break PC; jumptable=RESOLVE a computed-jump dispatcher (JMP (tbl,X) / RTS-trick) by breaking at `address`, single-stepping THROUGH the indirect transfer, and recording every COMPUTED target PC live across frames/inputs — the switch arms a static decompiler reports as 'Could not recover jumptable'. (use registersAtHit, not a follow-up cpu read.)"),
|
|
870
1003
|
precision: z.enum(["exact", "sampled"]).default("exact")
|
|
871
1004
|
.describe("on:'write' ONLY. exact=core watchpoint, the real writing instruction PC even under interrupts (uses `address`). sampled=cheap frame-boundary PC (uses region/offset/length) — NOT the writer under IRQ. Ignored for on:read/pc (always exact)."),
|
|
872
1005
|
address: z.number().int().min(0).optional().describe("on:'write' exact / on:'read' / on:'pc' — CPU address to break on (write target, read target, or instruction boundary). Required for those."),
|
|
@@ -893,6 +1026,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
893
1026
|
length: z.number().int().min(1).max(256).default(1).describe("bytes to read"),
|
|
894
1027
|
label: z.string().optional().describe("human name for this read (else 'region+offset')"),
|
|
895
1028
|
})).optional().describe("on:'pc' — read these memory regions AT the hit and return them inline as `capturedMemory` (collapses break→read-RAM into ONE call, the token win). Pair with `registersAtHit` to get the routine's register + RAM state in a single round trip (e.g. capture the ZP pointer bytes a decoder just wrote). NOTE: registersAtHit is the true break instant (core snapshot); these RAM reads are taken after the hit frame finishes, so on run-to-frame-end cores (fceumm) they're the routine's RAM side effects for that frame — stable + reliable, which is exactly what RE needs."),
|
|
1029
|
+
maxTargets: z.number().int().min(1).max(1024).default(64).describe("on:'jumptable' — stop once this many DISTINCT computed targets have been observed (the run also ends at maxFrames). Sets `truncated:true` if reached."),
|
|
1030
|
+
stepLimit: z.number().int().min(1).max(256).default(48).describe("on:'jumptable' — instructions to single-step after each dispatcher hit while collecting control-flow leaps. Must be deep enough to REACH the handler: a compiler-lowered indirect call (cc65 JSR<callax>; JMP(ptr)) runs the table load + trampoline + the indirect jump before the handler is entered — ~30 instructions here, so the default is 48. Too low and you only capture the fixed trampolines (the real arms never appear); raise it if a dispatch does heavy setup before the indirect jump."),
|
|
1031
|
+
jumpThreshold: z.number().int().min(1).max(64).default(5).describe("on:'jumptable' — a single-step whose PC delta exceeds this many bytes (or goes backward) counts as a control-flow LEAP (a taken jump/branch/call), vs sequential instruction flow. 5 suits 6502/Z80/SM83 (max ~3-byte instructions); raise for wider ISAs (ARM/m68k) so multi-byte sequential instructions aren't misread as leaps."),
|
|
1032
|
+
fromState: z.number().int().min(0).optional().describe("on:'jumptable'/'pc' (via the trace path) — restore this in-memory savestate SLOT before running, so the dispatcher is resolved from a known, repeatable moment (e.g. inside the battle/menu the dispatcher drives). Mutually exclusive with fromStatePath."),
|
|
1033
|
+
fromStatePath: z.string().optional().describe("on:'jumptable' — restore this savestate FILE before running (the disk equivalent of fromState). Mutually exclusive with fromState."),
|
|
896
1034
|
},
|
|
897
1035
|
safeTool(async (args) => {
|
|
898
1036
|
switch (args.on) {
|
|
@@ -912,6 +1050,10 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
912
1050
|
if (args.address == null) throw new Error("breakpoint({on:'pc'}): `address` is required.");
|
|
913
1051
|
return await bpRunUntilPC(args);
|
|
914
1052
|
}
|
|
1053
|
+
case "jumptable": {
|
|
1054
|
+
if (args.address == null) throw new Error("breakpoint({on:'jumptable'}): `address` is required (the computed-jump dispatcher).");
|
|
1055
|
+
return await bpResolveJumptable(args);
|
|
1056
|
+
}
|
|
915
1057
|
default: throw new Error(`breakpoint: unknown on '${args.on}'`);
|
|
916
1058
|
}
|
|
917
1059
|
}),
|
package/src/mcp/util.js
CHANGED
|
@@ -62,12 +62,49 @@ export function imageContent(pngBase64) {
|
|
|
62
62
|
|
|
63
63
|
/** Wrap an Error as a tool error result. */
|
|
64
64
|
export function errorContent(err) {
|
|
65
|
+
// UnsupportedError carries the structured capability-contract fields so an
|
|
66
|
+
// agent can branch on `unsupported: true` instead of string-matching the
|
|
67
|
+
// message. We surface BOTH a clean sentence and the structured fields.
|
|
68
|
+
if (err && err.name === "UnsupportedError") {
|
|
69
|
+
const text = err.message + (err.alternative ? ` (try: ${err.alternative})` : "");
|
|
70
|
+
return {
|
|
71
|
+
isError: true,
|
|
72
|
+
unsupported: true,
|
|
73
|
+
platform: err.platform,
|
|
74
|
+
op: err.op,
|
|
75
|
+
reason: err.reason ?? null,
|
|
76
|
+
alternative: err.alternative ?? null,
|
|
77
|
+
content: [{ type: "text", text }],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
65
80
|
return {
|
|
66
81
|
isError: true,
|
|
67
82
|
content: [{ type: "text", text: String(err?.message ?? err) }],
|
|
68
83
|
};
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The single, uniform "this platform doesn't support this op" signal. Throws a
|
|
88
|
+
* typed UnsupportedError that safeTool → errorContent formats consistently
|
|
89
|
+
* (and that programmatic callers / the conformance test can catch + inspect).
|
|
90
|
+
* Replaces the ad-hoc "not supported"/"not yet wired" throws.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} platform
|
|
93
|
+
* @param {string} op a capability op key (see cores/capabilities.js OP_KEYS)
|
|
94
|
+
* @param {{reason?:string, alternative?:string}} [opts]
|
|
95
|
+
* @returns {never}
|
|
96
|
+
*/
|
|
97
|
+
export function unsupported(platform, op, { reason, alternative } = {}) {
|
|
98
|
+
const base = `'${op}' is not supported on platform '${platform}'`;
|
|
99
|
+
const err = new Error(reason ? `${base}: ${reason}` : base + ".");
|
|
100
|
+
err.name = "UnsupportedError";
|
|
101
|
+
err.platform = platform;
|
|
102
|
+
err.op = op;
|
|
103
|
+
err.reason = reason ?? null;
|
|
104
|
+
err.alternative = alternative ?? null;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
|
|
71
108
|
/**
|
|
72
109
|
* Wrap a tool implementation so any thrown error becomes a structured tool
|
|
73
110
|
* error rather than a transport-layer exception.
|