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
@@ -1,5 +1,8 @@
1
1
  import { resolveCore } from "../../cores/registry.js";
2
- import { clearHost, getHost, getHostOrNull, rememberLastMedia, resetHost } from "../state.js";
2
+ import {
3
+ clearHost, clearHostB, getHost, getHostB, getHostBOrNull, getHostOrNull,
4
+ rememberLastMedia, resetHost, resetHostB,
5
+ } from "../state.js";
3
6
  import { jsonContent, safeTool, textContent } from "../util.js";
4
7
  import { resolveCheatCodeForApply } from "./cheats.js";
5
8
  import { attachObserverFrame } from "./watch-memory.js";
@@ -8,12 +11,16 @@ const MEDIA_KINDS = ["cartridge", "disk", "tape", "program"];
8
11
 
9
12
  export function registerLifecycleTools(server, z, sessionKey) {
10
13
  // Shared loader: accepts a file `path` OR base64 `bytes` (exactly one).
11
- async function doLoadMedia({ platform, path, base64, mediaKind, virtualName, cheats }) {
14
+ // slot:'b' targets the secondary comparison host (for frame sideBySide);
15
+ // it gets its own fresh host and does NOT overwrite slot A's recovery
16
+ // breadcrumb, since B is transient scratch, not the session's main ROM.
17
+ async function doLoadMedia({ platform, path, base64, mediaKind, virtualName, cheats, slot }) {
12
18
  const resolved = resolveCore(platform);
13
19
  if (!resolved) throw new Error(`no core available for platform '${platform}'`);
14
20
  if (!path && !base64) throw new Error("loadMedia: provide either `path` (file on disk) or `base64` (ROM bytes).");
15
21
  if (path && base64) throw new Error("loadMedia: provide `path` OR `base64`, not both.");
16
- const host = resetHost(sessionKey);
22
+ const slotB = slot === "b";
23
+ const host = slotB ? resetHostB(sessionKey) : resetHost(sessionKey);
17
24
  await host.loadCore(resolved.jsPath, resolved.wasmPath);
18
25
  const bytes = base64 ? new Uint8Array(Buffer.from(base64, "base64")) : undefined;
19
26
  await host.loadMedia({
@@ -47,10 +54,14 @@ export function registerLifecycleTools(server, z, sessionKey) {
47
54
  }
48
55
  // Remember what we loaded so a later host eviction (restart/reconnect) can
49
56
  // tell the agent the exact loadMedia call to recover with. Survives reset.
50
- rememberLastMedia(sessionKey, {
51
- platform,
52
- ...(bytes ? { fromBase64: true } : { path: host.status.mediaPath ?? path }),
53
- });
57
+ // Only for slot A — the breadcrumb is the primary ROM's recovery anchor;
58
+ // slot B is disposable comparison scratch and must not clobber it.
59
+ if (!slotB) {
60
+ rememberLastMedia(sessionKey, {
61
+ platform,
62
+ ...(bytes ? { fromBase64: true } : { path: host.status.mediaPath ?? path }),
63
+ });
64
+ }
54
65
 
55
66
  // Framebuffer dimensions are NOT known until the core has run at least one
56
67
  // frame — before that, fbWidth/fbHeight hold a pre-boot default (e.g.
@@ -59,10 +70,10 @@ export function registerLifecycleTools(server, z, sessionKey) {
59
70
  // on dimensions, so we omit it until a frame has been stepped and point the
60
71
  // caller at stepFrames instead.
61
72
  const framebufferKnown = host.status.frameCount > 0;
62
- // Livestream: show what just loaded (the boot frame).
63
- return attachObserverFrame(jsonContent({
73
+ const payload = jsonContent({
64
74
  loaded: true,
65
75
  platform,
76
+ ...(slotB ? { slot: "b" } : {}),
66
77
  core: resolved.coreName,
67
78
  mediaKind: host.status.mediaKind,
68
79
  ...(bytes ? { bytes: bytes.length } : { path: host.status.mediaPath }),
@@ -70,7 +81,12 @@ export function registerLifecycleTools(server, z, sessionKey) {
70
81
  ? { framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight } }
71
82
  : { framebufferNote: "Framebuffer dimensions are unknown until the core runs — call stepFrames first, then getStatus (the pre-boot default does not match the real output resolution)." }),
72
83
  ...(appliedCheats ? { cheats: appliedCheats } : {}),
73
- }), host, `loaded ${host.status.mediaPath ? host.status.mediaPath.split("/").pop() : platform}`);
84
+ });
85
+ // Livestream: only slot A drives the human's view (the session's main ROM).
86
+ // Slot B is comparison scratch — surfacing it would flip the livestream
87
+ // back and forth between two ROMs. frame({op:'sideBySide'}) is what shows B.
88
+ if (slotB) return payload;
89
+ return attachObserverFrame(payload, host, `loaded ${host.status.mediaPath ? host.status.mediaPath.split("/").pop() : platform}`);
74
90
  }
75
91
 
76
92
  server.tool(
@@ -79,6 +95,9 @@ export function registerLifecycleTools(server, z, sessionKey) {
79
95
  "Pass `path` (file on disk) OR `base64` (ROM bytes — e.g. straight from buildSource, no disk write, " +
80
96
  "for a fast iteration loop). `cheats` apply BEFORE the first frame (one call instead of loadMedia + " +
81
97
  "applyCheat), so a boot-time code that changes a value the reset code reads is in effect from frame 0. " +
98
+ "`slot:'b'` loads into the SECONDARY comparison host (a different platform is fine) so two cores can run " +
99
+ "at once for frame({op:'sideBySide'}) — the original-vs-port compare loop; slot B does not affect slot A " +
100
+ "or the livestream. " +
82
101
  "NOTE: framebuffer dimensions are omitted until you stepFrames — the pre-boot default does not match the " +
83
102
  "real output resolution.",
84
103
  {
@@ -88,6 +107,7 @@ export function registerLifecycleTools(server, z, sessionKey) {
88
107
  mediaKind: z.enum(MEDIA_KINDS).optional().describe("Default 'cartridge' for consoles, 'program' for C64."),
89
108
  virtualName: z.string().optional().describe("With `base64`: virtual filename shown to cores that fopen() the path (default '/rom')."),
90
109
  cheats: z.array(z.string()).max(64).optional().describe("Codes applied before the first frame (Game Genie / raw ADDR:VAL[:COMPARE] / native device codes). A raw ROM-address code is re-encoded to a read-intercept so it doesn't silently no-op. Returns a per-code `cheats:[{code, appliedAs, applied}]` report."),
110
+ slot: z.enum(["a", "b"]).default("a").describe("'a' (default) = the session's primary host (what every other tool uses). 'b' = the secondary comparison host used by frame({op:'sideBySide'}); load the second ROM here. Slot B is independent scratch — it keeps no recovery breadcrumb and never drives the livestream."),
91
111
  },
92
112
  safeTool(doLoadMedia),
93
113
  );
@@ -103,41 +123,50 @@ export function registerLifecycleTools(server, z, sessionKey) {
103
123
  "boot-seeded variables PERSIST). Pass `hard:true` for a TRUE power-cycle that reloads the ROM from scratch and " +
104
124
  "clears RAM + re-seeds boot state — use it when re-testing boot-time behavior (a soft reset boots the PREVIOUS " +
105
125
  "state).\n" +
106
- "'pause': halt emulation (stepFrames returns 0 until resume). 'resume': continue.",
126
+ "'pause': halt emulation (stepFrames returns 0 until resume). 'resume': continue.\n" +
127
+ "`slot:'b'` targets the secondary comparison host (loaded via loadMedia({slot:'b'})). Slot-B ops never " +
128
+ "touch the livestream.",
107
129
  {
108
130
  op: z.enum(["unload", "shutdown", "reset", "pause", "resume"]).describe("unload media; shutdown the host; reset (soft/hard); pause; resume."),
109
131
  hard: z.boolean().default(false).describe("op=reset: true = full power-cycle (reload the ROM; clears work RAM + boot-seeded state). false (default) = soft RESET-button reset (RAM persists)."),
132
+ slot: z.enum(["a", "b"]).default("a").describe("'a' (default) = the primary host. 'b' = the secondary comparison host (frame sideBySide)."),
110
133
  },
111
- safeTool(async ({ op, hard }) => {
134
+ safeTool(async ({ op, hard, slot }) => {
135
+ const slotB = slot === "b";
136
+ const get = slotB ? getHostB : getHost;
137
+ const getOrNull = slotB ? getHostBOrNull : getHostOrNull;
138
+ // Slot B never drives the human's livestream — wrap attachObserverFrame so
139
+ // slot-A behavior is unchanged but slot B stays silent.
140
+ const observe = slotB ? (content) => content : attachObserverFrame;
112
141
  switch (op) {
113
142
  case "unload": {
114
- const host = getHostOrNull(sessionKey);
143
+ const host = getOrNull(sessionKey);
115
144
  if (!host || !host.status.loaded) {
116
145
  // Don't claim success when there was nothing loaded — that masks a
117
146
  // session/state mix-up (the agent thinks it unloaded media it never had).
118
- return textContent("nothing to unload — no media is loaded in this session");
147
+ return textContent(`nothing to unload — no media is loaded in ${slotB ? "comparison slot B" : "this session"}`);
119
148
  }
120
149
  host.unloadMedia();
121
- return textContent("unloaded");
150
+ return textContent(`unloaded${slotB ? " (slot B)" : ""}`);
122
151
  }
123
152
  case "shutdown":
124
- clearHost(sessionKey);
125
- return textContent("shutdown complete");
153
+ if (slotB) clearHostB(sessionKey); else clearHost(sessionKey);
154
+ return textContent(`shutdown complete${slotB ? " (slot B)" : ""}`);
126
155
  case "reset": {
127
- const host = getHost(sessionKey);
156
+ const host = get(sessionKey);
128
157
  if (hard) {
129
158
  const reloaded = await host.hardReset();
130
- return attachObserverFrame(textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)"), host, "reset (hard)");
159
+ return observe(textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)"), host, "reset (hard)");
131
160
  }
132
161
  host.reset();
133
- return attachObserverFrame(textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)"), host, "reset");
162
+ return observe(textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)"), host, "reset");
134
163
  }
135
164
  case "pause":
136
- getHost(sessionKey).pause();
137
- return textContent("paused");
165
+ get(sessionKey).pause();
166
+ return textContent(`paused${slotB ? " (slot B)" : ""}`);
138
167
  case "resume":
139
- getHost(sessionKey).resume();
140
- return textContent("resumed");
168
+ get(sessionKey).resume();
169
+ return textContent(`resumed${slotB ? " (slot B)" : ""}`);
141
170
  default:
142
171
  throw new Error(`host: unknown op '${op}'`);
143
172
  }
@@ -542,7 +542,7 @@ async function memSearch(sessionKey, { value, size = 1, as = "raw", region = "sy
542
542
  // with searchNext compare:'dec'/'inc'/'unchanged'/'changed'/'gt'/'lt'. This is
543
543
  // the canonical "find the lives/score/timer address you can't see" loop, which
544
544
  // op:'search' (requires a value) can't do. (0.28.0 feedback #1.)
545
- async function memSearchUnknown(sessionKey, { size = 1, as = "raw", region = "system_ram", name = "default", maxCandidates = 64 }) {
545
+ async function memSearchUnknown(sessionKey, { size = 1, as = "raw", region = "system_ram", name = "default", maxCandidates: _maxCandidates = 64 }) {
546
546
  const host = getHost(sessionKey);
547
547
  if (as === "digits") throw new Error("memory({op:'searchUnknown'}): as:'digits' needs a value; use as:'raw' or 'bcd' for an unknown-value hunt.");
548
548
  const info = REGION_INFO[region] ?? {};
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { PNG } from "pngjs";
8
8
  import { getHost } from "../state.js";
9
- import { imageContent, jsonContent } from "../util.js";
9
+ import { imageContent, jsonContent, unsupported } from "../util.js";
10
10
 
11
11
  // Consolidation: several handlers in this big shared file are extracted as
12
12
  // *Core functions that the consolidated domain tools (palette/tiles/background/
@@ -338,7 +338,10 @@ export function registerPlatformTools(server, z, sessionKey) {
338
338
  }, png);
339
339
  }
340
340
 
341
- throw new Error(`inspectPalette not yet wired for platform '${p}'. Supported: nes, snes, genesis, sms, gg, gb, gbc, atari2600, atari7800, c64, gba, lynx, pce, msx.`);
341
+ unsupported(p, "inspectPalette", {
342
+ reason: "no palette decoder for this platform",
343
+ alternative: "platform({op:'capabilities'}) to see what's wired",
344
+ });
342
345
  };
343
346
 
344
347
  // getCPUState → cpu({op:'read'}) (router in watch-memory.js). Live-binding core.
@@ -347,7 +350,10 @@ export function registerPlatformTools(server, z, sessionKey) {
347
350
  const p = resolvePlatform(host, platform);
348
351
  const state = getCPUState(host, p, cpu);
349
352
  if (!state) {
350
- throw new Error(`getCPUState: no decoder for platform '${p}' cpu '${cpu}'. Main CPU is wired for nes, snes, genesis, sms, gg, gb, gbc, atari2600, atari7800, c64, lynx, gba. Secondary CPUs: snes 'spc700' (genesis 'z80' not yet decoded).`);
353
+ unsupported(p, "cpuState", {
354
+ reason: `no ${cpu === "main" ? "main" : `'${cpu}'`}-CPU decoder for this platform`,
355
+ alternative: "platform({op:'capabilities'}) shows each platform's decoded CPUs (main + secondary); memory({op:'read'}) the raw CPU-register region otherwise",
356
+ });
351
357
  }
352
358
  return jsonContent({ platform: p, cpu, ...state });
353
359
  };
@@ -694,7 +700,10 @@ export function registerPlatformTools(server, z, sessionKey) {
694
700
  });
695
701
  }
696
702
 
697
- throw new Error(`inspectSprites not yet wired for platform '${p}'. Supported: nes, snes, genesis, sms, gg, gb, gbc, atari2600, atari7800, c64, gba, pce, msx. (Lynx returns the SCB list head — it has no fixed OAM.)`);
703
+ unsupported(p, "inspectSprites", {
704
+ reason: "no sprite decoder for this platform",
705
+ alternative: "memory({op:'read'}) the raw OAM/sprite-attribute region, or platform({op:'capabilities'}) to see what's wired",
706
+ });
698
707
  };
699
708
 
700
709
  // inspectBackgroundMap lives in the `background` tool (rendering-context.js)
@@ -803,7 +812,10 @@ export function registerPlatformTools(server, z, sessionKey) {
803
812
  });
804
813
  return emitImage(r.png, `SNES BG map composite (${r.width}×${r.height}, ${r.mapWidth}×${r.mapHeight} tiles, ${r.bpp}bpp, tilemap@0x${tilemapBaseByte.toString(16)}, tiles@0x${tileBaseByte.toString(16)}). ${r.note}`);
805
814
  }
806
- throw new Error(`inspectBackgroundMap not yet implemented for platform '${p}'`);
815
+ unsupported(p, "inspectBackground", {
816
+ reason: "no background-map snapshotter for this platform",
817
+ alternative: "background({view:'renderState'}) for the register-level context, or memory({op:'read'}) the raw VRAM/nametable region",
818
+ });
807
819
  };
808
820
 
809
821
  // convertImageToTiles → encodeArt({stage:'tiles'}) (router in sprite-pipeline.js).
@@ -2,6 +2,7 @@ import { CORES, listAvailableCores, resolveCore } from "../../cores/registry.js"
2
2
  import { TOOLCHAINS } from "../../toolchains/registry.js";
3
3
  import { getLanguageOptions } from "../../toolchains/index.js";
4
4
  import { jsonContent, safeTool } from "../util.js";
5
+ import { CAPABILITIES, CONTRACT_PLATFORMS, capabilitiesFor } from "../../cores/capabilities.js";
5
6
  import { listToolchainsCore, installToolchainCore } from "./toolchain.js";
6
7
  import { listPlatformDocsCore, getPlatformDocCore } from "./platform-docs.js";
7
8
 
@@ -99,11 +100,17 @@ export function resolvePlatformCore({ platform }) {
99
100
  export function registerPlatformTools(server, z) {
100
101
  server.tool(
101
102
  "platform",
102
- "Platform/toolchain/docs discovery — what romdev can run and how. `op`: 'list' | 'resolve' | 'toolchains' | " +
103
- "'docs' | 'doc'.\n" +
103
+ "Platform/toolchain/docs discovery — what romdev can run and how. `op`: 'list' | 'capabilities' | 'resolve' | " +
104
+ "'toolchains' | 'docs' | 'doc'.\n" +
104
105
  "'list': every platform with its emulator core, toolchain(s), available languages (+ documented default), and " +
105
106
  "platform-specific quirks. Call this FIRST to discover what's possible + check a non-default language is " +
106
107
  "available before asking build for it.\n" +
108
+ "'capabilities': the CAPABILITY CONTRACT — which platform-sensitive ops a platform supports (inspectSprites/" +
109
+ "Palette/Background, cpuState, audioDebug, renderingContext, cart, disasm, decompile), plus its cpuFamily, " +
110
+ "renderingKind (tile/framebuffer/3d), introspection depth, CPUs, audio chips, and memory regions. Pass `platform` " +
111
+ "for one, omit it for the whole matrix. Check this BEFORE calling a platform-sensitive tool to avoid an " +
112
+ "'unsupported' error — every tool that can't do an op on a platform returns {unsupported:true, platform, op, " +
113
+ "reason, alternative}.\n" +
107
114
  "'resolve': resolved core paths for a platform (debugging aid).\n" +
108
115
  "'toolchains': the bundled homebrew toolchains (all Tier-1 = bundled WASM, no install). Pass `id` to confirm a " +
109
116
  "specific toolchain's install status (a no-op in v1 — everything's bundled).\n" +
@@ -111,7 +118,7 @@ export function registerPlatformTools(server, z) {
111
118
  "troubleshooting / upstream_sources; `platform:'romhacking'` + `name:'playbook'` for the RE decision tree). " +
112
119
  "Read MENTAL_MODEL before writing code, and the romhacking playbook before a hack.",
113
120
  {
114
- op: z.enum(["list", "resolve", "toolchains", "docs", "doc"]).describe("list=platforms; resolve=core paths; toolchains; docs=a platform's doc names; doc=read one doc."),
121
+ op: z.enum(["list", "capabilities", "resolve", "toolchains", "docs", "doc"]).describe("list=platforms; capabilities=per-platform op support matrix; resolve=core paths; toolchains; docs=a platform's doc names; doc=read one doc."),
115
122
  platform: z.string().optional().describe("op=resolve/docs/doc: platform id (e.g. nes, gb, genesis; 'romhacking' for the RE playbook)."),
116
123
  id: z.string().optional().describe("op=toolchains: a specific toolchain's install status (e.g. 'cc65')."),
117
124
  name: z.string().optional().describe("op=doc: which doc — mental_model | troubleshooting | upstream_sources | playbook."),
@@ -119,6 +126,14 @@ export function registerPlatformTools(server, z) {
119
126
  safeTool(async (args) => {
120
127
  switch (args.op) {
121
128
  case "list": return jsonContent(listPlatformsCore());
129
+ case "capabilities": {
130
+ if (args.platform) {
131
+ const cap = capabilitiesFor(args.platform);
132
+ if (!cap) throw new Error(`platform({op:'capabilities'}): unknown platform '${args.platform}'. Known: ${CONTRACT_PLATFORMS.join(", ")}.`);
133
+ return jsonContent({ platform: args.platform, ...cap });
134
+ }
135
+ return jsonContent({ platforms: CONTRACT_PLATFORMS, capabilities: CAPABILITIES });
136
+ }
122
137
  case "resolve": {
123
138
  if (!args.platform) throw new Error("platform({op:'resolve'}): `platform` is required.");
124
139
  return jsonContent(resolvePlatformCore(args));
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { writeFile } from "node:fs/promises";
7
7
 
8
- import { getHost, getHostOrNull } from "../state.js";
8
+ import { getHost, getHostOrNull, playtestCheckpointPath } from "../state.js";
9
9
  import { imageContent, jsonContent, safeTool, textContent } from "../util.js";
10
10
  import { log } from "../log.js";
11
11
 
@@ -169,6 +169,10 @@ export function registerPlaytestTools(server, z, sessionKey) {
169
169
  }
170
170
 
171
171
  const { playtest, KEYBOARD_BINDINGS_HELP, C64_BINDINGS_HELP } = await import("../../playtest/playtest.js");
172
+ // Where the rolling auto-checkpoint (eviction survivability) is written.
173
+ // Next to the ROM when it's a real file (so it's obvious + co-located); for
174
+ // base64/in-memory loads, a stable per-session file under the OS temp dir.
175
+ const autoCheckpointPath = playtestCheckpointPath(sessionKey, loadedMediaPath);
172
176
  let session;
173
177
  try {
174
178
  // Pass a live-host accessor so the window FOLLOWS rebuilds: runSource/
@@ -181,7 +185,9 @@ export function registerPlaytestTools(server, z, sessionKey) {
181
185
  scale,
182
186
  title,
183
187
  aspect,
188
+ autoCheckpointPath,
184
189
  });
190
+ session.autoCheckpointPath = autoCheckpointPath;
185
191
  sessions.set(sessionKey, session);
186
192
  } catch (e) {
187
193
  // Branch on WHY it failed — the cause is either the @kmamal/sdl native
@@ -265,6 +271,10 @@ export function registerPlaytestTools(server, z, sessionKey) {
265
271
  scale,
266
272
  aspect,
267
273
  controllerCount: session.controllerCount,
274
+ // Eviction survivability: while this window is open we roll a .state to
275
+ // disk so the human's manual progress survives a session eviction.
276
+ autoCheckpointPath,
277
+ autoCheckpointNote: `Progress auto-saves to ${autoCheckpointPath} every ~15s while the window is open (and on F2). If the session is evicted, state({op:'load', path}) it to restore the human's playthrough instead of replaying from boot.`,
268
278
  // C64 input is non-obvious (games need keyboard keys to START), so ALWAYS
269
279
  // relay the controls — a controller alone IS enough (spare buttons/stick
270
280
  // map to F1/Run-Stop/Space/Return), and the keyboard fallback covers the
@@ -345,6 +355,19 @@ export function registerPlaytestTools(server, z, sessionKey) {
345
355
  "loadMedia swapped it). frame({op:'screenshot'}) now shows the active host, NOT " +
346
356
  "what the human sees. Call playtest({op:'framebuffer'}) to capture the human's window.",
347
357
  }),
358
+ // Eviction survivability: where the human's progress auto-saves, and
359
+ // whether the last write failed (so an agent can warn + suggest a manual
360
+ // state({op:'save', path}) if the rolling checkpoint isn't landing).
361
+ ...((() => {
362
+ const ck = session.lastCheckpoint?.();
363
+ if (!ck || !ck.path) return {};
364
+ return {
365
+ autoCheckpointPath: ck.path,
366
+ ...(ck.lastError
367
+ ? { autoCheckpointError: ck.lastError, autoCheckpointHint: "The rolling auto-checkpoint is FAILING — the human's progress is NOT being saved. Have them pick a writable spot: state({op:'save', path}) manually." }
368
+ : { autoCheckpointNote: "The human's progress auto-saves here every ~15s (and on F2); survives a session eviction via state({op:'load', path})." }),
369
+ };
370
+ })()),
348
371
  });
349
372
  }
350
373
 
@@ -2698,7 +2698,7 @@ ${buildBlock}
2698
2698
  };
2699
2699
  }
2700
2700
 
2701
- async function createGameCore({ platform, genre, name, path: projPath, title, overwrite, verbose = false }) {
2701
+ async function _createGameCore({ platform, genre, name, path: projPath, title, overwrite, verbose = false }) {
2702
2702
  // The five canonical genres. A genre is available on a platform iff
2703
2703
  // TEMPLATES[platform] has a matching template entry — we DERIVE
2704
2704
  // availability from TEMPLATES rather than maintain a parallel table,
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { readFileSync } from "node:fs";
11
11
  import { getHost } from "../state.js";
12
- import { jsonContent, safeTool } from "../util.js";
12
+ import { jsonContent, safeTool, unsupported } from "../util.js";
13
13
  import { inspectBackgroundMapCore } from "./platform-tools.js";
14
14
  import { whichTilesAreRenderedCore } from "./which-tiles.js";
15
15
 
@@ -507,8 +507,10 @@ async function gbContext(host, area, platform) {
507
507
  };
508
508
  }
509
509
 
510
- export async function getRenderingContextCore({ platform, area = "all", sessionKey }) {
511
- const host = getHost(sessionKey);
510
+ export async function getRenderingContextCore({ platform, area = "all", sessionKey, host: explicitHost }) {
511
+ // `host` lets a caller (e.g. frame compareRender) decode a SPECIFIC host —
512
+ // the slot-B comparison host — instead of the session's slot-A default.
513
+ const host = explicitHost ?? getHost(sessionKey);
512
514
  const p = platform ?? host.getStatus().platform;
513
515
  switch (p) {
514
516
  case "nes": return nesContext(host, area);
@@ -530,9 +532,10 @@ export async function getRenderingContextCore({ platform, area = "all", sessionK
530
532
  case "pce": return pceContext(host, area);
531
533
  case "msx": return msxContext(host, area);
532
534
  default:
533
- throw new Error(
534
- `getRenderingContext: unknown platform '${p}'. Supported: nes, snes, genesis, gb, gbc, sms, gg, atari2600, atari7800, c64, gba, lynx, pce, msx.`
535
- );
535
+ unsupported(p, "renderingContext", {
536
+ reason: "no rendering-context decoder for this platform",
537
+ alternative: "platform({op:'capabilities'}) to see what's wired",
538
+ });
536
539
  }
537
540
  }
538
541