romdevtools 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +3 -1
  3. package/src/analysis/analyze.js +1 -1
  4. package/src/analysis/backtrace.js +198 -0
  5. package/src/analysis/nes-ppu-runtime.js +220 -0
  6. package/src/analysis/nes-ppu-shim.js +263 -0
  7. package/src/analysis/pointer-table.js +84 -0
  8. package/src/analysis/recompile-65816.js +584 -0
  9. package/src/cheats/gamegenie.js +14 -2
  10. package/src/cores/registry.js +43 -51
  11. package/src/mcp/state.js +91 -2
  12. package/src/mcp/tools/cheats.js +10 -1
  13. package/src/mcp/tools/disasm.js +309 -21
  14. package/src/mcp/tools/find-references.js +93 -2
  15. package/src/mcp/tools/frame.js +440 -4
  16. package/src/mcp/tools/index.js +34 -1
  17. package/src/mcp/tools/lifecycle.js +53 -24
  18. package/src/mcp/tools/memory.js +1 -1
  19. package/src/mcp/tools/playtest.js +24 -1
  20. package/src/mcp/tools/project.js +1 -1
  21. package/src/mcp/tools/rendering-context.js +4 -2
  22. package/src/mcp/tools/watch-memory.js +87 -9
  23. package/src/mcp/util.js +45 -0
  24. package/src/playtest/playtest.js +115 -46
  25. package/src/toolchains/cc65/da65.js +7 -0
  26. package/src/cores/wasm/bluemsx_libretro.js +0 -2
  27. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  28. package/src/cores/wasm/fceumm_libretro.js +0 -2
  29. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  30. package/src/cores/wasm/gambatte_libretro.js +0 -2
  31. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  32. package/src/cores/wasm/geargrafx_libretro.js +0 -2
  33. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  34. package/src/cores/wasm/genesis_plus_gx_libretro.js +0 -2
  35. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  36. package/src/cores/wasm/handy_libretro.js +0 -2
  37. package/src/cores/wasm/handy_libretro.wasm +0 -0
  38. package/src/cores/wasm/mgba_libretro.js +0 -2
  39. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  40. package/src/cores/wasm/prosystem_libretro.js +0 -2
  41. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  42. package/src/cores/wasm/snes9x_libretro.js +0 -2
  43. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  44. package/src/cores/wasm/stella2014_libretro.js +0 -2
  45. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  46. package/src/cores/wasm/vice_x64_libretro.js +0 -2
  47. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
@@ -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 };