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
package/src/analysis/rizin.js
CHANGED
|
@@ -77,7 +77,7 @@ export const RIZIN_ARCH = {
|
|
|
77
77
|
* @returns {Promise<{exitCode:number, output:string, log:string, crash?:object}>}
|
|
78
78
|
*/
|
|
79
79
|
export async function runRizin(opts) {
|
|
80
|
-
const { romPath, romBytes, commands, arch, bits, baddr, writeable } = opts;
|
|
80
|
+
const { romPath, romBytes, commands, arch, bits, baddr, writeable, timeoutMs } = opts;
|
|
81
81
|
if (!commands) throw new Error("runRizin: commands required");
|
|
82
82
|
const bytes = romBytes ?? new Uint8Array(await readFile(romPath));
|
|
83
83
|
|
|
@@ -102,6 +102,11 @@ export async function runRizin(opts) {
|
|
|
102
102
|
const res = await runIsolated({
|
|
103
103
|
gluePath: rizinGluePath(),
|
|
104
104
|
argv,
|
|
105
|
+
// A5: per-call timeout so a hung analysis (whole-ROM `aaa` on a multi-MB ROM)
|
|
106
|
+
// can't wedge the shared worker pool — on timeout the worker is killed +
|
|
107
|
+
// recycled and this call returns a clean { timedOut, log } result. Default
|
|
108
|
+
// 60s; callers can override (a scoped `af @ addr` pass is near-instant).
|
|
109
|
+
timeoutMs: timeoutMs ?? 60000,
|
|
105
110
|
inputFiles: [{
|
|
106
111
|
vfsPath: "/work/rom.bin",
|
|
107
112
|
encoding: "base64",
|
|
@@ -109,6 +114,13 @@ export async function runRizin(opts) {
|
|
|
109
114
|
}],
|
|
110
115
|
outputFiles: [{ vfsPath: OUT, encoding: "utf8" }],
|
|
111
116
|
});
|
|
117
|
+
// Surface a timeout as a thrown error so JSON callers get a clear signal
|
|
118
|
+
// (runRizinJson already wraps crashes; this makes the timeout explicit).
|
|
119
|
+
if (res.timedOut) {
|
|
120
|
+
const e = new Error(res.log?.trim() || "rizin analysis timed out");
|
|
121
|
+
/** @type {any} */ (e).timedOut = true;
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
112
124
|
return { ...res, output: res.outputs?.[OUT] ?? "" };
|
|
113
125
|
}
|
|
114
126
|
|
package/src/cheats/gamegenie.js
CHANGED
|
@@ -444,9 +444,21 @@ export function decodeWithDevice(code, platform) {
|
|
|
444
444
|
return { ...decoded, device };
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
/** Format a raw ADDR:VAL[:COMPARE] code from decoded parts (hex, no 0x).
|
|
447
|
+
/** Format a raw ADDR:VAL[:COMPARE] code from decoded parts (hex, no 0x).
|
|
448
|
+
* The ADDRESS is padded to a minimum of 4 hex digits. This is NOT cosmetic:
|
|
449
|
+
* libretro cheat parsers (verified on fceumm) treat a short `AA:VV` RAM code as
|
|
450
|
+
* inert — it parses but never binds (`apply` then falsely reports success). A
|
|
451
|
+
* zero-page address like $32 must be emitted as `0032:09` to actually poke; the
|
|
452
|
+
* 2-digit `32:09` is silently dropped. ROM addresses ($8000+) are already 4+
|
|
453
|
+
* digits, so padding only fixes the low-RAM case and never changes a working
|
|
454
|
+
* code. The VALUE stays 2 digits (`0009` 4-digit values also break the parser).
|
|
455
|
+
* Addresses wider than 16 bits (e.g. Genesis $FFxxxx work RAM) keep their full
|
|
456
|
+
* width, rounded up to an even number of digits. */
|
|
448
457
|
export function encodeRaw({ address, value, compare }) {
|
|
449
|
-
const
|
|
458
|
+
const rawHex = (address >>> 0).toString(16).toUpperCase();
|
|
459
|
+
// pad to >=4 digits, and to an even digit count so it's a whole number of bytes
|
|
460
|
+
const width = Math.max(4, rawHex.length + (rawHex.length & 1));
|
|
461
|
+
const addrHex = rawHex.padStart(width, "0");
|
|
450
462
|
const valHex = (value & 0xFF).toString(16).toUpperCase().padStart(2, "0");
|
|
451
463
|
return compare != null
|
|
452
464
|
? `${addrHex}:${valHex}:${(compare & 0xFF).toString(16).toUpperCase().padStart(2, "0")}`
|
|
@@ -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/cores/registry.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
// Core registry. Maps platform IDs to
|
|
1
|
+
// Core registry. Maps platform IDs to their libretro WASM core.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// The shipped core wasm lives in the per-platform binary packages
|
|
4
|
+
// (romdev-core-* / romdev-platform-*) — NOT in this package. `resolveCore`
|
|
5
|
+
// resolves each platform from the `pkg` named in its CORES entry. The local
|
|
6
|
+
// `src/cores/wasm/` dir is a gitignored BUILD-STAGING area: `scripts/build-*.sh`
|
|
7
|
+
// emit there, and it serves as a dev fallback when you're working in-tree
|
|
8
|
+
// without the satellite packages built. It is empty in a fresh clone and does
|
|
9
|
+
// NOT ship in the npm tarball.
|
|
6
10
|
|
|
7
11
|
import { existsSync } from "node:fs";
|
|
8
12
|
import { fileURLToPath } from "node:url";
|
|
@@ -11,81 +15,69 @@ import path from "node:path";
|
|
|
11
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
16
|
const __dirname = path.dirname(__filename);
|
|
13
17
|
|
|
14
|
-
/**
|
|
18
|
+
/** Build-staging dir for `.js` + `.wasm` core pairs (gitignored; dev fallback
|
|
19
|
+
* only — the shipped wasm resolves from the satellite packages). */
|
|
15
20
|
export const CORES_DIR = path.resolve(__dirname, "wasm");
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
23
|
* @typedef {Object} CoreInfo
|
|
19
24
|
* @property {string} platform short platform id ("nes", "gb", ...)
|
|
20
25
|
* @property {string} coreName filename stem (matches `<coreName>_libretro.js`)
|
|
26
|
+
* @property {string} pkg binary package that SHIPS this core's wasm.
|
|
27
|
+
* `romdev-core-*` = a standalone core (often shared: gpgx serves genesis/sms/
|
|
28
|
+
* gg, gambatte serves gb/gbc). `romdev-platform-*` = a core bundled WITH the
|
|
29
|
+
* dedicated toolchain nothing else uses (snes9x+asar+wla, mGBA+arm-gcc,
|
|
30
|
+
* stella+dasm) — they ship together because only that platform needs them.
|
|
21
31
|
* @property {string} displayName
|
|
22
32
|
* @property {string} [aka] comma-separated synonyms accepted in resolvers
|
|
23
33
|
*/
|
|
24
34
|
|
|
25
35
|
/**
|
|
36
|
+
* The single source of truth for every platform's core: its filename stem, the
|
|
37
|
+
* package that ships it, and display metadata — all in one record so there's no
|
|
38
|
+
* second map to keep in sync.
|
|
26
39
|
* @type {Record<string, CoreInfo>}
|
|
27
40
|
*/
|
|
28
41
|
export const CORES = {
|
|
29
|
-
nes: { platform: "nes", coreName: "fceumm", displayName: "Nintendo Entertainment System (fceumm)" },
|
|
30
|
-
gb: { platform: "gb", coreName: "gambatte", displayName: "Game Boy / GBC (gambatte)" },
|
|
31
|
-
gbc: { platform: "gbc", coreName: "gambatte", displayName: "Game Boy / GBC (gambatte)" },
|
|
32
|
-
atari2600: { platform: "atari2600", coreName: "stella2014", displayName: "Atari 2600 (Stella)" },
|
|
33
|
-
atari7800: { platform: "atari7800", coreName: "prosystem", displayName: "Atari 7800 (ProSystem)" },
|
|
34
|
-
lynx: { platform: "lynx", coreName: "handy", displayName: "Atari Lynx (Handy)" },
|
|
35
|
-
sms: { platform: "sms", coreName: "genesis_plus_gx", displayName: "Sega Master System (Genesis Plus GX)" },
|
|
36
|
-
gg: { platform: "gg", coreName: "genesis_plus_gx", displayName: "Sega Game Gear (Genesis Plus GX)" },
|
|
37
|
-
genesis: { platform: "genesis", coreName: "genesis_plus_gx", displayName: "Sega Genesis / Mega Drive (Genesis Plus GX)" },
|
|
38
|
-
snes: { platform: "snes", coreName: "snes9x", displayName: "SNES (snes9x)" },
|
|
39
|
-
gba: { platform: "gba", coreName: "mgba", displayName: "Game Boy Advance (mGBA)" },
|
|
40
|
-
c64: { platform: "c64", coreName: "vice_x64", displayName: "Commodore 64 (VICE x64)" },
|
|
41
|
-
pce: { platform: "pce", coreName: "geargrafx", displayName: "PC Engine / TurboGrafx-16 (Geargrafx)", aka: "turbografx,tg16,pcengine" },
|
|
42
|
-
msx: { platform: "msx", coreName: "bluemsx", displayName: "MSX / MSX2 (blueMSX)", aka: "msx2" },
|
|
42
|
+
nes: { platform: "nes", coreName: "fceumm", pkg: "romdev-core-fceumm", displayName: "Nintendo Entertainment System (fceumm)" },
|
|
43
|
+
gb: { platform: "gb", coreName: "gambatte", pkg: "romdev-core-gambatte", displayName: "Game Boy / GBC (gambatte)" },
|
|
44
|
+
gbc: { platform: "gbc", coreName: "gambatte", pkg: "romdev-core-gambatte", displayName: "Game Boy / GBC (gambatte)" },
|
|
45
|
+
atari2600: { platform: "atari2600", coreName: "stella2014", pkg: "romdev-platform-atari2600", displayName: "Atari 2600 (Stella)" },
|
|
46
|
+
atari7800: { platform: "atari7800", coreName: "prosystem", pkg: "romdev-core-prosystem", displayName: "Atari 7800 (ProSystem)" },
|
|
47
|
+
lynx: { platform: "lynx", coreName: "handy", pkg: "romdev-core-handy", displayName: "Atari Lynx (Handy)" },
|
|
48
|
+
sms: { platform: "sms", coreName: "genesis_plus_gx", pkg: "romdev-core-gpgx", displayName: "Sega Master System (Genesis Plus GX)" },
|
|
49
|
+
gg: { platform: "gg", coreName: "genesis_plus_gx", pkg: "romdev-core-gpgx", displayName: "Sega Game Gear (Genesis Plus GX)" },
|
|
50
|
+
genesis: { platform: "genesis", coreName: "genesis_plus_gx", pkg: "romdev-core-gpgx", displayName: "Sega Genesis / Mega Drive (Genesis Plus GX)" },
|
|
51
|
+
snes: { platform: "snes", coreName: "snes9x", pkg: "romdev-platform-snes", displayName: "SNES (snes9x)" },
|
|
52
|
+
gba: { platform: "gba", coreName: "mgba", pkg: "romdev-platform-gba", displayName: "Game Boy Advance (mGBA)" },
|
|
53
|
+
c64: { platform: "c64", coreName: "vice_x64", pkg: "romdev-core-vice", displayName: "Commodore 64 (VICE x64)" },
|
|
54
|
+
pce: { platform: "pce", coreName: "geargrafx", pkg: "romdev-core-geargrafx", displayName: "PC Engine / TurboGrafx-16 (Geargrafx)", aka: "turbografx,tg16,pcengine" },
|
|
55
|
+
msx: { platform: "msx", coreName: "bluemsx", pkg: "romdev-core-bluemsx", displayName: "MSX / MSX2 (blueMSX)", aka: "msx2" },
|
|
43
56
|
};
|
|
44
57
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
* Returns null if the core isn't bundled.
|
|
48
|
-
* @param {string} platform
|
|
49
|
-
* @returns {{ platform: string, coreName: string, displayName: string, jsPath: string, wasmPath: string } | null}
|
|
50
|
-
*/
|
|
51
|
-
// Platform → the @romdev binary package that ships its core wasm. Resolution
|
|
52
|
-
// tries the package first (the shipped layout), then falls back to a local
|
|
53
|
-
// src/cores/wasm copy (transition / dev). Add a row here as each platform's
|
|
54
|
-
// package is carved out; platforms not listed still resolve locally.
|
|
55
|
-
const CORE_PACKAGES = {
|
|
56
|
-
atari2600: "romdev-platform-atari2600",
|
|
57
|
-
nes: "romdev-core-fceumm",
|
|
58
|
-
gb: "romdev-core-gambatte",
|
|
59
|
-
gbc: "romdev-core-gambatte",
|
|
60
|
-
genesis: "romdev-core-gpgx",
|
|
61
|
-
sms: "romdev-core-gpgx",
|
|
62
|
-
gg: "romdev-core-gpgx",
|
|
63
|
-
c64: "romdev-core-vice",
|
|
64
|
-
lynx: "romdev-core-handy",
|
|
65
|
-
atari7800: "romdev-core-prosystem",
|
|
66
|
-
snes: "romdev-platform-snes",
|
|
67
|
-
gba: "romdev-platform-gba",
|
|
68
|
-
pce: "romdev-core-geargrafx",
|
|
69
|
-
msx: "romdev-core-bluemsx",
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
/** Try to get {jsPath,wasmPath} for a core from its @romdev package. */
|
|
73
|
-
function resolveCoreFromPackage(platform, coreName) {
|
|
74
|
-
const pkg = CORE_PACKAGES[platform];
|
|
58
|
+
/** Try to get {jsPath,wasmPath} for a core from its binary package. */
|
|
59
|
+
function resolveCoreFromPackage(pkg, coreName) {
|
|
75
60
|
if (!pkg) return null;
|
|
76
61
|
try {
|
|
77
62
|
const dir = path.dirname(fileURLToPath(import.meta.resolve(pkg)));
|
|
78
63
|
const jsPath = path.join(dir, "wasm", `${coreName}_libretro.js`);
|
|
79
64
|
const wasmPath = path.join(dir, "wasm", `${coreName}_libretro.wasm`);
|
|
80
65
|
if (existsSync(jsPath) && existsSync(wasmPath)) return { jsPath, wasmPath };
|
|
81
|
-
} catch { /* package not resolvable — fall through to
|
|
66
|
+
} catch { /* package not resolvable — fall through to the dev-staging dir */ }
|
|
82
67
|
return null;
|
|
83
68
|
}
|
|
84
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a platform id to absolute paths for its core. Tries the binary
|
|
72
|
+
* package first (the shipped layout), then the gitignored `src/cores/wasm/`
|
|
73
|
+
* build-staging dir (in-tree dev fallback). Returns null if neither has it.
|
|
74
|
+
* @param {string} platform
|
|
75
|
+
* @returns {{ platform: string, coreName: string, pkg: string, displayName: string, jsPath: string, wasmPath: string } | null}
|
|
76
|
+
*/
|
|
85
77
|
export function resolveCore(platform) {
|
|
86
78
|
const info = CORES[platform];
|
|
87
79
|
if (!info) return null;
|
|
88
|
-
const fromPkg = resolveCoreFromPackage(
|
|
80
|
+
const fromPkg = resolveCoreFromPackage(info.pkg, info.coreName);
|
|
89
81
|
if (fromPkg) return { ...info, ...fromPkg };
|
|
90
82
|
const jsPath = path.join(CORES_DIR, `${info.coreName}_libretro.js`);
|
|
91
83
|
const wasmPath = path.join(CORES_DIR, `${info.coreName}_libretro.wasm`);
|
package/src/mcp/state.js
CHANGED
|
@@ -10,10 +10,41 @@
|
|
|
10
10
|
// layer is responsible for calling clearHost(key) on session close.
|
|
11
11
|
|
|
12
12
|
import { LibretroHost } from "../host/index.js";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
13
16
|
|
|
14
17
|
/** @type {Map<string, LibretroHost>} */
|
|
15
18
|
const hosts = new Map();
|
|
16
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Disk path for a playtest session's rolling auto-checkpoint (eviction
|
|
22
|
+
* survivability). Deterministic per (session, rom) so the eviction-recovery hint
|
|
23
|
+
* can name it WITHOUT any bookkeeping. Next to the ROM when it's a real on-disk
|
|
24
|
+
* file; else a stable per-session file under the OS temp dir. (v0.41.0 feedback
|
|
25
|
+
* note 125904.) Shared by the playtest tool (writer) and getHost (recovery hint).
|
|
26
|
+
* @param {string} sessionKey
|
|
27
|
+
* @param {string|null} mediaPath
|
|
28
|
+
*/
|
|
29
|
+
export function playtestCheckpointPath(sessionKey, mediaPath) {
|
|
30
|
+
const safeSession = String(sessionKey).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40);
|
|
31
|
+
if (mediaPath && !mediaPath.startsWith("<") && path.isAbsolute(mediaPath)) {
|
|
32
|
+
const dir = path.dirname(mediaPath);
|
|
33
|
+
const base = path.basename(mediaPath, path.extname(mediaPath));
|
|
34
|
+
return path.join(dir, `${base}.playtest-autosave.state`);
|
|
35
|
+
}
|
|
36
|
+
return path.join(os.tmpdir(), `romdev-playtest-${safeSession}.autosave.state`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Secondary host slot ("B") per session. The primary slot above is what every
|
|
40
|
+
// tool uses by default; slot B exists for the ONE workflow that needs two cores
|
|
41
|
+
// live at once: side-by-side comparison (e.g. an original ROM vs. its port).
|
|
42
|
+
// loadMedia({slot:'b'}) loads here; frame({op:'sideBySide'}) captures both. It
|
|
43
|
+
// is entirely opt-in — a session that never loads slot B pays nothing, and the
|
|
44
|
+
// per-session teardown below clears both slots together.
|
|
45
|
+
/** @type {Map<string, LibretroHost>} */
|
|
46
|
+
const hostsB = new Map();
|
|
47
|
+
|
|
17
48
|
// What this session last loaded, kept OUTSIDE the host map so it SURVIVES a
|
|
18
49
|
// host eviction (server restart / session reconnect / unload). The host itself
|
|
19
50
|
// is gone in those cases, so the "No ROM loaded" error has nothing to read —
|
|
@@ -47,11 +78,18 @@ export function getHost(sessionKey) {
|
|
|
47
78
|
const recall = prev.path
|
|
48
79
|
? `loadMedia({ platform: "${prev.platform}", path: "${prev.path}" })`
|
|
49
80
|
: `loadMedia({ platform: "${prev.platform}", base64: ... }) (your ROM came from base64 — re-supply the bytes)`;
|
|
81
|
+
// If a playtest window was open, a rolling auto-checkpoint may be on disk —
|
|
82
|
+
// restoring it recovers the human's MANUAL progress, not just a fresh boot.
|
|
83
|
+
const ckpt = playtestCheckpointPath(sessionKey, prev.path ?? null);
|
|
84
|
+
const ckptHint = existsSync(ckpt)
|
|
85
|
+
? `\nA playtest auto-checkpoint is on disk (your last ~15s of play): after the load above, run\n state({ op: "load", path: "${ckpt}" })\nto restore the human's progress instead of replaying from boot.`
|
|
86
|
+
: "";
|
|
50
87
|
throw new Error(
|
|
51
88
|
"No ROM loaded in this session — the host was evicted (the server restarted, " +
|
|
52
89
|
"your session reconnected, or the media was unloaded). Emulator state lives in " +
|
|
53
90
|
"server memory only, so it did not survive. RECOVER by re-running your last load:\n " +
|
|
54
91
|
recall +
|
|
92
|
+
ckptHint +
|
|
55
93
|
"\nThen replay any boot/navigate steps to get back to where you were. " +
|
|
56
94
|
"(If instead you expected a DIFFERENT session, you may be sending an inconsistent " +
|
|
57
95
|
"`x-romdev-session` header — reuse one stable id on every call.)",
|
|
@@ -100,11 +138,57 @@ export function clearHost(sessionKey) {
|
|
|
100
138
|
try { existing.unloadMedia(); } catch {}
|
|
101
139
|
}
|
|
102
140
|
hosts.delete(sessionKey);
|
|
141
|
+
// A session shutdown tears down BOTH slots — slot B is part of the same
|
|
142
|
+
// session's footprint and must not outlive it.
|
|
143
|
+
clearHostB(sessionKey);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Secondary host slot ("B") ----------------------------------------------
|
|
147
|
+
// Same lifecycle helpers as the primary, scoped to the hostsB map. getHostB
|
|
148
|
+
// throws a slot-specific error (no recovery breadcrumb — slot B is transient
|
|
149
|
+
// scratch for a comparison, not the session's main ROM).
|
|
150
|
+
|
|
151
|
+
/** @param {string} sessionKey @returns {LibretroHost} */
|
|
152
|
+
export function getHostB(sessionKey) {
|
|
153
|
+
const host = hostsB.get(sessionKey);
|
|
154
|
+
if (!host) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
"No ROM loaded in comparison slot B for this session — load one with " +
|
|
157
|
+
"loadMedia({ slot: 'b', platform, path }). Slot B is the second core used " +
|
|
158
|
+
"by frame({op:'sideBySide'}); it is not the session's primary ROM.",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return host;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @param {string} sessionKey */
|
|
165
|
+
export function getHostBOrNull(sessionKey) {
|
|
166
|
+
return hostsB.get(sessionKey) ?? null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {string} sessionKey @returns {LibretroHost} */
|
|
170
|
+
export function resetHostB(sessionKey) {
|
|
171
|
+
const existing = hostsB.get(sessionKey);
|
|
172
|
+
if (existing && existing.status.loaded) {
|
|
173
|
+
try { existing.unloadMedia(); } catch {}
|
|
174
|
+
}
|
|
175
|
+
const fresh = new LibretroHost();
|
|
176
|
+
hostsB.set(sessionKey, fresh);
|
|
177
|
+
return fresh;
|
|
103
178
|
}
|
|
104
179
|
|
|
105
|
-
/**
|
|
180
|
+
/** @param {string} sessionKey */
|
|
181
|
+
export function clearHostB(sessionKey) {
|
|
182
|
+
const existing = hostsB.get(sessionKey);
|
|
183
|
+
if (existing && existing.status.loaded) {
|
|
184
|
+
try { existing.unloadMedia(); } catch {}
|
|
185
|
+
}
|
|
186
|
+
hostsB.delete(sessionKey);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Test-only: number of live hosts (both slots). */
|
|
106
190
|
export function _liveHostCount() {
|
|
107
|
-
return hosts.size;
|
|
191
|
+
return hosts.size + hostsB.size;
|
|
108
192
|
}
|
|
109
193
|
|
|
110
194
|
/** Test-only: inject a (possibly fake) host for a session key. */
|
|
@@ -112,6 +196,11 @@ export function _setHostForTest(sessionKey, host) {
|
|
|
112
196
|
hosts.set(sessionKey, host);
|
|
113
197
|
}
|
|
114
198
|
|
|
199
|
+
/** Test-only: inject a (possibly fake) host into comparison slot B. */
|
|
200
|
+
export function _setHostBForTest(sessionKey, host) {
|
|
201
|
+
hostsB.set(sessionKey, host);
|
|
202
|
+
}
|
|
203
|
+
|
|
115
204
|
// Shared reference to the per-session disclosure manager, so tool
|
|
116
205
|
// handlers outside index.js (toolchain.js, etc.) can call consumeHint()
|
|
117
206
|
// to emit session-scoped one-shot nudges (e.g. "loadCategory('show') to
|
package/src/mcp/tools/cheats.js
CHANGED
|
@@ -4,7 +4,7 @@ import { getHost } from "../state.js";
|
|
|
4
4
|
import { jsonContent, safeTool } from "../util.js";
|
|
5
5
|
import { attachObserverFrame } from "./watch-memory.js";
|
|
6
6
|
import { lookupCheats, searchCheatGames } from "../../cheats/lookup.js";
|
|
7
|
-
import { encodeForDevice, nativeDevicesFor, decodeCode } from "../../cheats/gamegenie.js";
|
|
7
|
+
import { encodeForDevice, nativeDevicesFor, decodeCode, encodeRaw } from "../../cheats/gamegenie.js";
|
|
8
8
|
|
|
9
9
|
// Per-platform cheat address space (for makeCheat validation). A Game Genie
|
|
10
10
|
// letter code can only address these ranges; out-of-range → raw ADDR:VAL only.
|
|
@@ -57,6 +57,15 @@ export function resolveCheatCodeForApply(rawCode, platform) {
|
|
|
57
57
|
else appliedAs = "rom-unencodable";
|
|
58
58
|
} else if (decoded) {
|
|
59
59
|
appliedAs = "ram";
|
|
60
|
+
// Normalize the raw RAM code so a short hand-typed `AA:VV` (e.g. "32:09")
|
|
61
|
+
// is re-emitted in the binding width ("0032:09"). The libretro parser
|
|
62
|
+
// (verified on fceumm) silently DROPS an under-padded RAM address — it
|
|
63
|
+
// parses but never pokes — so passing the user's short string through
|
|
64
|
+
// verbatim is the inert-cheat footgun. encodeRaw pads the address to the
|
|
65
|
+
// width the core honors. (No-op for already-padded codes.)
|
|
66
|
+
const norm = encodeRaw(decoded);
|
|
67
|
+
if (norm !== rawCode) reencodedFrom = rawCode;
|
|
68
|
+
code = norm;
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
return { code, appliedAs, reencodedFrom };
|