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.
Files changed (55) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +1 -1
  4. package/package.json +3 -1
  5. package/src/analysis/analyze.js +320 -48
  6. package/src/analysis/backtrace.js +198 -0
  7. package/src/analysis/nes-ppu-runtime.js +220 -0
  8. package/src/analysis/nes-ppu-shim.js +263 -0
  9. package/src/analysis/pointer-table.js +84 -0
  10. package/src/analysis/recompile-65816.js +584 -0
  11. package/src/analysis/rizin.js +13 -1
  12. package/src/cheats/gamegenie.js +14 -2
  13. package/src/cores/capabilities.js +218 -0
  14. package/src/cores/registry.js +43 -51
  15. package/src/mcp/state.js +91 -2
  16. package/src/mcp/tools/cheats.js +10 -1
  17. package/src/mcp/tools/disasm.js +331 -24
  18. package/src/mcp/tools/find-references.js +93 -2
  19. package/src/mcp/tools/frame.js +440 -4
  20. package/src/mcp/tools/index.js +34 -1
  21. package/src/mcp/tools/lifecycle.js +53 -24
  22. package/src/mcp/tools/memory.js +1 -1
  23. package/src/mcp/tools/platform-tools.js +17 -5
  24. package/src/mcp/tools/platforms.js +18 -3
  25. package/src/mcp/tools/playtest.js +24 -1
  26. package/src/mcp/tools/project.js +1 -1
  27. package/src/mcp/tools/rendering-context.js +9 -6
  28. package/src/mcp/tools/watch-memory.js +231 -11
  29. package/src/mcp/util.js +82 -0
  30. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +23 -8
  31. package/src/playtest/playtest.js +115 -46
  32. package/src/toolchains/_worker/pool.js +41 -3
  33. package/src/toolchains/cc65/da65.js +7 -0
  34. package/src/cores/wasm/bluemsx_libretro.js +0 -2
  35. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  36. package/src/cores/wasm/fceumm_libretro.js +0 -2
  37. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  38. package/src/cores/wasm/gambatte_libretro.js +0 -2
  39. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  40. package/src/cores/wasm/geargrafx_libretro.js +0 -2
  41. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  42. package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
  43. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  44. package/src/cores/wasm/handy_libretro.js +0 -2
  45. package/src/cores/wasm/handy_libretro.wasm +0 -0
  46. package/src/cores/wasm/mgba_libretro.js +0 -2
  47. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  48. package/src/cores/wasm/prosystem_libretro.js +0 -2
  49. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  50. package/src/cores/wasm/snes9x_libretro.js +0 -2
  51. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  52. package/src/cores/wasm/stella2014_libretro.js +0 -2
  53. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  54. package/src/cores/wasm/vice_x64_libretro.js +0 -2
  55. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
@@ -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
 
@@ -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 addrHex = (address >>> 0).toString(16).toUpperCase();
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
+ }
@@ -1,8 +1,12 @@
1
- // Core registry. Maps platform IDs to bundled libretro WASM cores.
1
+ // Core registry. Maps platform IDs to their libretro WASM core.
2
2
  //
3
- // All v1 cores ship inside the npm package under src/cores/wasm/. Resolution
4
- // is a simple path lookup there is no fetcher because there is nothing to
5
- // fetch.
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
- /** Directory holding all bundled `.js` + `.wasm` libretro core pairs. */
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
- * Resolve a platform id to absolute paths for its core.
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 local */ }
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(platform, info.coreName);
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
- /** Test-only: number of live hosts. */
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
@@ -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 };