romdevtools 0.40.2 → 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/AGENTS.md +2 -2
- package/CHANGELOG.md +144 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/analysis/analyze.js +320 -48
- 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/analysis/rizin.js +13 -1
- package/src/cheats/gamegenie.js +14 -2
- package/src/cores/capabilities.js +218 -0
- 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 +331 -24
- 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/platform-tools.js +17 -5
- package/src/mcp/tools/platforms.js +18 -3
- package/src/mcp/tools/playtest.js +24 -1
- package/src/mcp/tools/project.js +1 -1
- package/src/mcp/tools/rendering-context.js +9 -6
- package/src/mcp/tools/watch-memory.js +231 -11
- package/src/mcp/util.js +82 -0
- package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +23 -8
- package/src/playtest/playtest.js +115 -46
- package/src/toolchains/_worker/pool.js +41 -3
- 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
|
@@ -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,
|
|
@@ -793,6 +869,138 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
793
869
|
}), host);
|
|
794
870
|
}
|
|
795
871
|
|
|
872
|
+
// A4: Computed-jumptable recovery via the LIVE emulator. Static analysis follows
|
|
873
|
+
// direct addressing only, so a `JMP (table,X)` / RTS-trick dispatcher collapses
|
|
874
|
+
// to "Could not recover jumptable" — and those dispatchers (game-state machines,
|
|
875
|
+
// script/event VMs, battle engines) are the routines you most want to read. We
|
|
876
|
+
// resolve them dynamically: break at the dispatcher, single-step THROUGH the
|
|
877
|
+
// indirect transfer, and record the PC it actually lands on. Run across many
|
|
878
|
+
// frames/inputs to accumulate the distinct target set — the real switch arms.
|
|
879
|
+
//
|
|
880
|
+
// No standalone tool (IDA/Ghidra/Binary Ninja) can do this: they have no live
|
|
881
|
+
// emulator to observe the computed target. The observed set can be fed back as
|
|
882
|
+
// analysis hints (Rizin ahi/aho) so `decompile`/`cfg` recover the switch.
|
|
883
|
+
async function bpResolveJumptable({ address, maxFrames = 1200, maxTargets = 64, stepLimit = 48, jumpThreshold = 5, pressDuring, fromState, fromStatePath }) {
|
|
884
|
+
const host = getHost(sessionKey);
|
|
885
|
+
if (!host.pcBreakSupported || !host.pcBreakSupported()) {
|
|
886
|
+
return jsonContent({
|
|
887
|
+
ok: false, notSupported: true, address: "$" + address.toString(16).toUpperCase(),
|
|
888
|
+
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).",
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
const restored = await maybeRestoreState(host, fromState, fromStatePath);
|
|
892
|
+
const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
|
|
893
|
+
const pressDriver = makePressDriver(host, presses);
|
|
894
|
+
|
|
895
|
+
// We separate COMPUTED targets from FIXED trampolines by what VARIES. As we
|
|
896
|
+
// single-step out of the dispatcher, normal flow is sequential (next PC =
|
|
897
|
+
// prev + 1..4 on a 6502, wider on ARM); a computed JMP (table,X) / RTS-trick
|
|
898
|
+
// makes the PC LEAP (delta > jumpThreshold or backward). But a real dispatch
|
|
899
|
+
// path contains FIXED leaps too — cc65 lowers an indirect call to
|
|
900
|
+
// JSR<callax>; JMP(ptr), so the trampoline addresses leap identically on
|
|
901
|
+
// every hit. The handler ARM is the leap destination that DIFFERS hit-to-
|
|
902
|
+
// hit. So: per hit, collect the set of leap destinations; across all hits,
|
|
903
|
+
// a destination seen on EVERY hit is a fixed trampoline (drop it), and one
|
|
904
|
+
// seen on only SOME hits is a real computed arm (keep it). leapSeen counts
|
|
905
|
+
// how many hits each destination appeared in; perHitLeaps holds the firstFrame
|
|
906
|
+
// + a sample fromPC for the keepers.
|
|
907
|
+
const leapSeen = new Map(); // destPC -> hitCount
|
|
908
|
+
const leapMeta = new Map(); // destPC -> { firstFrame, fromPC }
|
|
909
|
+
let dispatcherHits = 0, framesRun = 0;
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
for (let i = 0; i < maxFrames && leapMeta.size < maxTargets * 4; i++) {
|
|
913
|
+
pressDriver.applyForFrame(i);
|
|
914
|
+
host.setPCBreak(address, true, false);
|
|
915
|
+
host.stepFrames(1);
|
|
916
|
+
framesRun++;
|
|
917
|
+
let st = host.getPCBreak(false);
|
|
918
|
+
if (!st.hit) { host.setPCBreak(0, false, false); continue; }
|
|
919
|
+
|
|
920
|
+
const fromPC = st.lastPC ?? address;
|
|
921
|
+
host.getPCBreak(true); // clear the hit so the next arm is clean
|
|
922
|
+
// Walk forward; collect the destination of EVERY control-flow leap in
|
|
923
|
+
// this hit (deduped within the hit), plus the PC it leapt FROM.
|
|
924
|
+
const seenThisHit = new Set();
|
|
925
|
+
let prev = fromPC, prevLeapFrom = fromPC;
|
|
926
|
+
for (let s = 0; s < stepLimit; s++) {
|
|
927
|
+
const step = host.stepInstruction(); // { pc } AFTER one instr
|
|
928
|
+
const pc = step.pc;
|
|
929
|
+
if (pc == null) break;
|
|
930
|
+
const delta = pc - prev;
|
|
931
|
+
if (delta < 0 || delta > jumpThreshold) {
|
|
932
|
+
if (!seenThisHit.has(pc)) {
|
|
933
|
+
seenThisHit.add(pc);
|
|
934
|
+
if (!leapMeta.has(pc)) leapMeta.set(pc, { firstFrame: framesRun, fromPC: prevLeapFrom });
|
|
935
|
+
}
|
|
936
|
+
prevLeapFrom = prev;
|
|
937
|
+
}
|
|
938
|
+
prev = pc;
|
|
939
|
+
}
|
|
940
|
+
for (const pc of seenThisHit) leapSeen.set(pc, (leapSeen.get(pc) ?? 0) + 1);
|
|
941
|
+
dispatcherHits++;
|
|
942
|
+
}
|
|
943
|
+
} finally {
|
|
944
|
+
pressDriver.finish();
|
|
945
|
+
host.setPCBreak(0, false, false); // disarm
|
|
946
|
+
if (host.getRegSnapshot) host.getRegSnapshot(true); // consume any stale snapshot
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const hx = (v) => "$" + (v >>> 0).toString(16).toUpperCase();
|
|
950
|
+
// Classify each leap destination. A COMPUTED arm VARIES across hits — it was
|
|
951
|
+
// reached on some hits but not all (leapSeen < dispatcherHits). A FIXED
|
|
952
|
+
// trampoline (cc65 callax, the post-handler return path) leaps identically
|
|
953
|
+
// EVERY hit (leapSeen == dispatcherHits). Keep the variers as targets.
|
|
954
|
+
const allLeaps = [...leapMeta.entries()].map(([pc, m]) => ({
|
|
955
|
+
pc, hits: leapSeen.get(pc) ?? 0, firstFrame: m.firstFrame, fromPC: m.fromPC,
|
|
956
|
+
}));
|
|
957
|
+
let arms = allLeaps.filter((l) => l.hits < dispatcherHits);
|
|
958
|
+
// Fallback: if NOTHING varied (the dispatcher only ever took one path under
|
|
959
|
+
// this input — a single observed arm), report the non-trampoline leaps as
|
|
960
|
+
// candidate targets rather than nothing. With only one hit, everything has
|
|
961
|
+
// hits==1==dispatcherHits, so report all leaps as candidates.
|
|
962
|
+
let singleArm = false;
|
|
963
|
+
if (!arms.length && allLeaps.length) { arms = allLeaps; singleArm = true; }
|
|
964
|
+
|
|
965
|
+
const sorted = arms
|
|
966
|
+
.slice(0, maxTargets)
|
|
967
|
+
.map((l) => ({ target: hx(l.pc), targetRaw: l.pc, hits: l.hits, firstFrame: l.firstFrame, fromPC: hx(l.fromPC) }))
|
|
968
|
+
.sort((a, b) => b.hits - a.hits || a.targetRaw - b.targetRaw);
|
|
969
|
+
|
|
970
|
+
if (!sorted.length) {
|
|
971
|
+
const drove = presses.length > 0;
|
|
972
|
+
return attachObserverFrame(jsonContent({
|
|
973
|
+
ok: true, resolved: false,
|
|
974
|
+
address: hx(address), framesRun, dispatcherHits,
|
|
975
|
+
...(restored ? { restoredFrom: restored } : {}),
|
|
976
|
+
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
977
|
+
note: dispatcherHits === 0
|
|
978
|
+
? (drove
|
|
979
|
+
? "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."
|
|
980
|
+
: "The dispatcher never executed — drive the game to the state that runs it (pressDuring / fromState), or increase maxFrames. Confirm reachability with breakpoint({on:'pc'}).")
|
|
981
|
+
: "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.",
|
|
982
|
+
}), host);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
return attachObserverFrame(jsonContent({
|
|
986
|
+
ok: true, resolved: true,
|
|
987
|
+
address: hx(address),
|
|
988
|
+
targets: sorted,
|
|
989
|
+
distinctTargets: sorted.length,
|
|
990
|
+
dispatcherHits, framesRun,
|
|
991
|
+
...(singleArm ? { singleArmObserved: true } : {}),
|
|
992
|
+
...(arms.length > maxTargets ? { truncated: true, truncatedAt: maxTargets } : {}),
|
|
993
|
+
...(restored ? { restoredFrom: restored } : {}),
|
|
994
|
+
...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
|
|
995
|
+
note: (singleArm
|
|
996
|
+
? "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. "
|
|
997
|
+
: "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). ") +
|
|
998
|
+
"Each is a routine the dispatcher branches to; decompile({address: target}) / disasm({target:'rom', startAddress: target}) to read them. " +
|
|
999
|
+
"hits = how many dispatches took that arm under this input; drive more states to surface rarer arms. " +
|
|
1000
|
+
"This is the live-emulator advantage: no static-only tool can recover these.",
|
|
1001
|
+
}), host);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
796
1004
|
async function bpRunUntilRead({ address, maxFrames = 600, pressDuring }) {
|
|
797
1005
|
const host = getHost(sessionKey);
|
|
798
1006
|
if (!host.readWatchSupported || !host.readWatchSupported()) {
|
|
@@ -862,11 +1070,12 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
862
1070
|
"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
1071
|
"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
1072
|
"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" +
|
|
1073
|
+
"• 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
1074
|
"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
1075
|
"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
1076
|
{
|
|
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
|
|
1077
|
+
on: z.enum(["write", "read", "pc", "jumptable"])
|
|
1078
|
+
.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
1079
|
precision: z.enum(["exact", "sampled"]).default("exact")
|
|
871
1080
|
.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
1081
|
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 +1102,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
893
1102
|
length: z.number().int().min(1).max(256).default(1).describe("bytes to read"),
|
|
894
1103
|
label: z.string().optional().describe("human name for this read (else 'region+offset')"),
|
|
895
1104
|
})).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."),
|
|
1105
|
+
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."),
|
|
1106
|
+
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."),
|
|
1107
|
+
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."),
|
|
1108
|
+
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."),
|
|
1109
|
+
fromStatePath: z.string().optional().describe("on:'jumptable' — restore this savestate FILE before running (the disk equivalent of fromState). Mutually exclusive with fromState."),
|
|
896
1110
|
},
|
|
897
1111
|
safeTool(async (args) => {
|
|
898
1112
|
switch (args.on) {
|
|
@@ -912,6 +1126,10 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
912
1126
|
if (args.address == null) throw new Error("breakpoint({on:'pc'}): `address` is required.");
|
|
913
1127
|
return await bpRunUntilPC(args);
|
|
914
1128
|
}
|
|
1129
|
+
case "jumptable": {
|
|
1130
|
+
if (args.address == null) throw new Error("breakpoint({on:'jumptable'}): `address` is required (the computed-jump dispatcher).");
|
|
1131
|
+
return await bpResolveJumptable(args);
|
|
1132
|
+
}
|
|
915
1133
|
default: throw new Error(`breakpoint: unknown on '${args.on}'`);
|
|
916
1134
|
}
|
|
917
1135
|
}),
|
|
@@ -1150,7 +1368,9 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
|
|
|
1150
1368
|
{
|
|
1151
1369
|
on: z.enum(["mem", "range", "pc", "dma", "copy"])
|
|
1152
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?')."),
|
|
1153
|
-
// 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.
|
|
1154
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`."),
|
|
1155
1375
|
offset: z.number().int().min(0).default(0).describe("on:'mem' single-range — first byte of the watched range."),
|
|
1156
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
|
@@ -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.
|
|
@@ -175,15 +212,60 @@ export function withClearToolErrors(server, z) {
|
|
|
175
212
|
return server;
|
|
176
213
|
}
|
|
177
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
|
+
|
|
178
244
|
/**
|
|
179
245
|
* Build a `.strict()` z.object from a tool's shape whose validation issues each
|
|
180
246
|
* render as a clear sentence (unknown-key "did you mean", enum options, missing
|
|
181
247
|
* required, wrong type). Used by withClearToolErrors to replace the stored schema.
|
|
248
|
+
* Also wraps address-like params with hex-string coercion (see coerceHexNumber).
|
|
182
249
|
* @param {any} z
|
|
183
250
|
* @param {Record<string, any>} shape
|
|
184
251
|
* @param {string} toolName
|
|
185
252
|
*/
|
|
186
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;
|
|
187
269
|
const validKeys = Object.keys(shape);
|
|
188
270
|
const errorMap = (issue) => {
|
|
189
271
|
switch (issue.code) {
|
|
@@ -334,14 +334,27 @@ what touches an address). The **Rizin/Ghidra analysis engine** carves the progra
|
|
|
334
334
|
scan misses. Use it to answer "what calls this routine / reads this table?"
|
|
335
335
|
- **`disasm({target:'decompile', path, address})`** — Ghidra **C-like pseudocode**
|
|
336
336
|
for a function. Read it to UNDERSTAND a routine fast; it is NOT the edit path
|
|
337
|
-
(use `target:'project'`, §7b, to change and rebuild).
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
337
|
+
(use `target:'project'`, §7b, to change and rebuild). Hardware-register MMIO is
|
|
338
|
+
NAMED (`PPUMASK` not `*0x2001`) and on the 6502 family SLEIGH clutter is folded
|
|
339
|
+
to readable C99 (`uint8_t`, `zp_FD`) — see the `/* hw registers: … */` /
|
|
340
|
+
`/* 6502 fold: … */` legends. Quality tracks the CPU — see the `qualityNote` it
|
|
341
|
+
returns: excellent on ARM (GBA) / 68000 (Genesis), good on SM83 (GB) / Z80
|
|
342
|
+
(SMS/GG/MSX), medium on 65816 (SNES) / HuC6280 (PCE), rough on the 6502 family
|
|
343
|
+
(carry-flag idioms and 16-bit-math-on-8-bit decompile to noise — read the
|
|
344
|
+
disassembly there, or let an LLM fold the residual pseudocode).
|
|
345
|
+
- **`breakpoint({on:'jumptable', address})`** — when a routine decompiles to
|
|
346
|
+
`(*_IRQ)()` + "Could not recover jumptable" (the computed-jump dispatchers —
|
|
347
|
+
state machines, script/battle VMs — that static analysis structurally can't
|
|
348
|
+
follow), RESOLVE it live: this breaks at the dispatcher in the running emulator,
|
|
349
|
+
single-steps through the indirect `JMP (table,X)` / RTS-trick, and returns the
|
|
350
|
+
COMPUTED targets it actually lands on. Drive more game states (`pressDuring` /
|
|
351
|
+
`fromState`) to surface rarer arms. `disasm({target:'resolveJumptable'})` is the
|
|
352
|
+
static-side alias. No static-only tool can do this — it's romdev's live-emulator
|
|
353
|
+
edge.
|
|
342
354
|
|
|
343
355
|
**The loop:** `symbols({op:'analyze'})` or `disasm({target:'functions'})` to carve →
|
|
344
|
-
`disasm({target:'cfg'/'xrefs'/'decompile'})` to understand a candidate →
|
|
356
|
+
`disasm({target:'cfg'/'xrefs'/'decompile'})` to understand a candidate (→
|
|
357
|
+
`breakpoint({on:'jumptable'})` when it dispatches through a computed jump) → then the
|
|
345
358
|
dynamic tools (memory search, `breakpoint({on:'write'})`, `watch`) to CONFIRM and
|
|
346
359
|
label which carved function owns the value you care about. Static narrows the
|
|
347
360
|
search space; dynamic proves it.
|
|
@@ -422,8 +435,9 @@ Once you know WHAT to change, the write loop is a handful of calls — no custom
|
|
|
422
435
|
and >32KB HuCards (refs carry `romBank`) — so a hit in bank 12 of a 128KB cart shows up,
|
|
423
436
|
not just the first bank. Zero-page direct + indexed operands match, and `#$nn` immediates
|
|
424
437
|
are excluded (values, not addresses). Limitation: direct addressing only —
|
|
425
|
-
indirect/computed jumps aren't detected
|
|
426
|
-
|
|
438
|
+
indirect/computed jumps aren't detected statically; resolve those LIVE with
|
|
439
|
+
`breakpoint({on:'jumptable', address})` (runs the emulator to record the computed
|
|
440
|
+
targets), or the other runtime `watch`/`breakpoint` tools in §5/§5d.
|
|
427
441
|
- **`cart({op:'extract', path, outputDir})`** — split a ROM into standard parts (NES header/
|
|
428
442
|
prg/chr; SNES copier_header+rom+internal header; Genesis vectors/header/body; GB boot/
|
|
429
443
|
header/body) + a `manifest.json` (mapper, mirroring…). **`cart({op:'wrap'})`** is the inverse:
|
|
@@ -528,6 +542,7 @@ For sprite/tile edits (not text), don't hand-roll the tile-format math:
|
|
|
528
542
|
| Re-inject edited bytes the game accepts | `romPatch({op:'makeStored'})` (verbatim-expand block) → `romPatch({op:'findFree'})` → `romPatch({op:'relocate'})` |
|
|
529
543
|
| Find the pointer that loads an asset | `romPatch({op:'findPointer', romOffset})` |
|
|
530
544
|
| FIND the unknown routine touching X | `watch({on:'range', start,end})` (all hits) / `watch({on:'pc'})` (coverage) |
|
|
545
|
+
| Resolve a computed-jump dispatcher (decompiles to `(*_IRQ)()`) | `breakpoint({on:'jumptable', address})` (live — records the real switch arms) |
|
|
531
546
|
| Which DMA wrote a VRAM tile + its source (Genesis) | `watch({on:'dma', precision:'exact', vramDest})` |
|
|
532
547
|
| Where did a VRAM graphic come from (Genesis) | `watch({on:'dma', precision:'sampled'})` (ROM offset of the DMA source) |
|
|
533
548
|
| Drive a menu fast | `input({op:'navigate'})` (advances on screen change) |
|