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
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { resolveCore } from "../../cores/registry.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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 =
|
|
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(
|
|
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(
|
|
150
|
+
return textContent(`unloaded${slotB ? " (slot B)" : ""}`);
|
|
122
151
|
}
|
|
123
152
|
case "shutdown":
|
|
124
|
-
clearHost(sessionKey);
|
|
125
|
-
return textContent(
|
|
153
|
+
if (slotB) clearHostB(sessionKey); else clearHost(sessionKey);
|
|
154
|
+
return textContent(`shutdown complete${slotB ? " (slot B)" : ""}`);
|
|
126
155
|
case "reset": {
|
|
127
|
-
const host =
|
|
156
|
+
const host = get(sessionKey);
|
|
128
157
|
if (hard) {
|
|
129
158
|
const reloaded = await host.hardReset();
|
|
130
|
-
return
|
|
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
|
|
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
|
-
|
|
137
|
-
return textContent(
|
|
165
|
+
get(sessionKey).pause();
|
|
166
|
+
return textContent(`paused${slotB ? " (slot B)" : ""}`);
|
|
138
167
|
case "resume":
|
|
139
|
-
|
|
140
|
-
return textContent(
|
|
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
|
}
|
package/src/mcp/tools/memory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' | '
|
|
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
|
|
package/src/mcp/tools/project.js
CHANGED
|
@@ -2698,7 +2698,7 @@ ${buildBlock}
|
|
|
2698
2698
|
};
|
|
2699
2699
|
}
|
|
2700
2700
|
|
|
2701
|
-
async function
|
|
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
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
|