romdevtools 0.15.0 → 0.21.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 +61 -13
- package/CHANGELOG.md +289 -0
- package/README.md +1 -1
- package/examples/README.md +2 -0
- package/examples/atari2600/templates/platformer.asm +460 -0
- package/examples/atari2600/templates/racing.asm +463 -0
- package/examples/atari2600/templates/shmup.asm +386 -0
- package/examples/atari2600/templates/sports.asm +362 -0
- package/examples/atari7800/templates/default.c +49 -5
- package/examples/atari7800/templates/platformer.c +43 -4
- package/examples/atari7800/templates/puzzle.c +39 -4
- package/examples/atari7800/templates/racing.c +39 -4
- package/examples/atari7800/templates/shmup.c +40 -2
- package/examples/atari7800/templates/sports.c +36 -5
- package/examples/c64/templates/platformer.c +19 -5
- package/examples/c64/templates/puzzle.c +32 -2
- package/examples/c64/templates/shmup.c +28 -2
- package/examples/c64/templates/sports.c +30 -2
- package/examples/gb/templates/default.c +110 -16
- package/examples/gb/templates/platformer.c +25 -4
- package/examples/gb/templates/puzzle.c +32 -2
- package/examples/gb/templates/racing.c +72 -8
- package/examples/gb/templates/shmup.c +38 -1
- package/examples/gb/templates/sports.c +48 -1
- package/examples/gba/templates/gba_hello.c +29 -11
- package/examples/gba/templates/puzzle.c +15 -3
- package/examples/gba/templates/racing.c +65 -3
- package/examples/gba/templates/shmup.c +41 -4
- package/examples/gba/templates/sports.c +36 -2
- package/examples/gba/templates/tonc_hello.c +41 -5
- package/examples/gbc/templates/default.c +103 -26
- package/examples/gbc/templates/platformer.c +25 -4
- package/examples/gbc/templates/puzzle.c +32 -2
- package/examples/gbc/templates/racing.c +85 -19
- package/examples/gbc/templates/shmup.c +34 -1
- package/examples/gbc/templates/sports.c +45 -1
- package/examples/genesis/templates/puzzle.c +37 -3
- package/examples/genesis/templates/racing.c +44 -11
- package/examples/genesis/templates/sgdk_hello.c +34 -1
- package/examples/genesis/templates/shmup.c +31 -1
- package/examples/gg/templates/default.c +56 -18
- package/examples/gg/templates/platformer.c +18 -12
- package/examples/gg/templates/puzzle.c +38 -7
- package/examples/gg/templates/racing.c +51 -5
- package/examples/gg/templates/shmup.c +47 -3
- package/examples/gg/templates/sports.c +46 -3
- package/examples/lynx/templates/default.c +39 -8
- package/examples/lynx/templates/puzzle.c +28 -1
- package/examples/lynx/templates/racing.c +34 -7
- package/examples/lynx/templates/shmup.c +42 -3
- package/examples/lynx/templates/sports.c +29 -2
- package/examples/msx/platformer/main.c +213 -0
- package/examples/msx/puzzle/main.c +250 -0
- package/examples/msx/racing/main.c +249 -0
- package/examples/msx/shmup/main.c +288 -0
- package/examples/msx/sports/main.c +182 -0
- package/examples/nes/templates/default.c +67 -19
- package/examples/nes/templates/platformer.c +65 -6
- package/examples/nes/templates/puzzle.c +67 -6
- package/examples/nes/templates/racing.c +45 -13
- package/examples/nes/templates/shmup.c +51 -2
- package/examples/nes/templates/sports.c +51 -6
- package/examples/pce/platformer/main.c +283 -0
- package/examples/pce/puzzle/main.c +304 -0
- package/examples/pce/racing/main.c +304 -0
- package/examples/pce/shmup/main.c +346 -0
- package/examples/pce/sports/main.c +254 -0
- package/examples/sms/main.c +35 -6
- package/examples/sms/templates/puzzle.c +34 -5
- package/examples/sms/templates/racing.c +39 -2
- package/examples/sms/templates/shmup.c +41 -2
- package/examples/sms/templates/sports.c +43 -2
- package/examples/snes/templates/default.c +50 -28
- package/examples/snes/templates/platformer-data.asm +22 -0
- package/examples/snes/templates/platformer.c +16 -1
- package/examples/snes/templates/puzzle-data.asm +22 -0
- package/examples/snes/templates/puzzle.c +17 -1
- package/examples/snes/templates/racing-data.asm +22 -0
- package/examples/snes/templates/racing.c +17 -1
- package/examples/snes/templates/shmup-data.asm +22 -0
- package/examples/snes/templates/shmup.c +20 -1
- package/examples/snes/templates/sports-data.asm +22 -0
- package/examples/snes/templates/sports.c +16 -1
- package/package.json +1 -1
- package/src/cores/wasm/vice_x64_libretro.js +1 -1
- package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
- package/src/host/LibretroHost.js +122 -1
- package/src/host/callbacks.js +9 -1
- package/src/host/types.js +15 -8
- package/src/http/skill-doc.js +1 -1
- package/src/http/tool-registry.js +27 -2
- package/src/mcp/tools/cart-parts.js +75 -3
- package/src/mcp/tools/disasm-rebuild.js +507 -0
- package/src/mcp/tools/disasm.js +95 -6
- package/src/mcp/tools/frame.js +168 -3
- package/src/mcp/tools/index.js +4 -4
- package/src/mcp/tools/lifecycle.js +4 -2
- package/src/mcp/tools/project.js +54 -9
- package/src/mcp/tools/state.js +201 -14
- package/src/mcp/tools/toolchain.js +89 -4
- package/src/mcp/tools/watch-memory.js +125 -14
- package/src/platforms/c64/MENTAL_MODEL.md +45 -1
- package/src/platforms/c64/d64.js +281 -0
- package/src/platforms/gb/MENTAL_MODEL.md +10 -0
- package/src/platforms/msx/MENTAL_MODEL.md +10 -6
- package/src/platforms/nes/MENTAL_MODEL.md +63 -2
- package/src/platforms/pce/MENTAL_MODEL.md +9 -4
- package/src/platforms/pce/lib/c/pce_video.c +1 -1
- package/src/rom-id/identifier.js +15 -0
- package/src/toolchains/cc65/cc65.js +8 -1
- package/src/toolchains/cc65/ines.js +145 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.cfg +83 -0
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +153 -0
- package/src/toolchains/common/reassemble.js +10 -2
- package/src/toolchains/gba-c/gba-c.js +6 -1
- package/src/toolchains/genesis-c/genesis-c.js +10 -2
- package/src/toolchains/parse-errors.js +67 -5
package/src/mcp/tools/frame.js
CHANGED
|
@@ -5,7 +5,140 @@ import { PNG } from "pngjs";
|
|
|
5
5
|
import { getHost } from "../state.js";
|
|
6
6
|
import { imageContent, jsonContent, safeTool } from "../util.js";
|
|
7
7
|
import { decodeOAM, decodePpuRegs, ppuRegsPopulated } from "../../platforms/snes/ppu.js";
|
|
8
|
-
import { stepInstructionCore } from "./watch-memory.js";
|
|
8
|
+
import { stepInstructionCore, attachObserverFrame } from "./watch-memory.js";
|
|
9
|
+
import { getRenderingContextCore } from "./rendering-context.js";
|
|
10
|
+
|
|
11
|
+
// Normalize each platform's render-context into a CONSERVATIVE renderEnabled
|
|
12
|
+
// (true | false | null). null = "can't tell from the registers" — verify never
|
|
13
|
+
// asserts renderDisabled on null, so a platform we can't decode just relies on
|
|
14
|
+
// the pixel check. This is the cross-platform contract for frame({op:'verify'}).
|
|
15
|
+
function pickRenderFlags(ctx) {
|
|
16
|
+
const p = ctx.platform;
|
|
17
|
+
try {
|
|
18
|
+
if (p === "nes") {
|
|
19
|
+
const m = ctx.nes && ctx.nes.ppumask;
|
|
20
|
+
if (!m) return { renderEnabled: null };
|
|
21
|
+
return { renderEnabled: !!(m.bgVisible || m.spritesVisible) };
|
|
22
|
+
}
|
|
23
|
+
if (p === "snes") {
|
|
24
|
+
const s = ctx.snes;
|
|
25
|
+
if (!s || !s.ppuRegistersAvailable) return { renderEnabled: null }; // regs not live yet
|
|
26
|
+
if (s.forcedBlank) return { renderEnabled: false };
|
|
27
|
+
if (s.brightness === 0) return { renderEnabled: false };
|
|
28
|
+
return { renderEnabled: true };
|
|
29
|
+
}
|
|
30
|
+
if (p === "genesis" || p === "megadrive" || p === "md") {
|
|
31
|
+
return { renderEnabled: ctx.displayEnabled == null ? null : !!ctx.displayEnabled };
|
|
32
|
+
}
|
|
33
|
+
if (p === "sms" || p === "gg") {
|
|
34
|
+
return { renderEnabled: ctx.screenEnabled == null ? null : !!ctx.screenEnabled };
|
|
35
|
+
}
|
|
36
|
+
if (p === "gb" || p === "gbc") {
|
|
37
|
+
const l = ctx.gb && ctx.gb.lcdc ? ctx.gb.lcdc : ctx.lcdc;
|
|
38
|
+
if (!l) return { renderEnabled: null };
|
|
39
|
+
return { renderEnabled: !!l.lcdEnable };
|
|
40
|
+
}
|
|
41
|
+
if (p === "gba") {
|
|
42
|
+
if (ctx.forcedBlank) return { renderEnabled: false };
|
|
43
|
+
const anyBg = Array.isArray(ctx.displayBg) && ctx.displayBg.some(Boolean);
|
|
44
|
+
return { renderEnabled: !!(anyBg || ctx.displayObj) };
|
|
45
|
+
}
|
|
46
|
+
if (p === "pce") {
|
|
47
|
+
return { renderEnabled: ctx.screenEnabled == null ? null : !!ctx.screenEnabled };
|
|
48
|
+
}
|
|
49
|
+
if (p === "msx") {
|
|
50
|
+
return { renderEnabled: ctx.screenEnabled == null ? null : !!ctx.screenEnabled };
|
|
51
|
+
}
|
|
52
|
+
// atari2600 / atari7800 / lynx: no single reliable display-enable bit — let
|
|
53
|
+
// the pixel check carry it; don't false-assert.
|
|
54
|
+
return { renderEnabled: null };
|
|
55
|
+
} catch {
|
|
56
|
+
return { renderEnabled: null };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Dominant-color fraction at/above which a screen reads as "blank" to a
|
|
62
|
+
* human even though *something* technically rendered. Set to 0.92 (one color
|
|
63
|
+
* filling >=92% of the screen) — empirically the perceptual threshold where a
|
|
64
|
+
* backdrop-with-a-lone-sprite still looks empty. Below this, there's enough
|
|
65
|
+
* on-screen content that a person sees a populated frame. (Truly one/two-color
|
|
66
|
+
* frames are caught separately by the distinctColors<=1 blankScreen check.)
|
|
67
|
+
*/
|
|
68
|
+
const NEARLY_BLANK_DOMINANT = 0.92;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The cross-platform render-health computation behind frame({op:'verify'}).
|
|
72
|
+
* Exported so tests can drive it per platform without the MCP wrapper.
|
|
73
|
+
* @returns plain object {verified, frame, platform, pixels, render, issues?, note}
|
|
74
|
+
*/
|
|
75
|
+
export async function computeVerify(host, frames, sessionKey) {
|
|
76
|
+
const platform = host.status.platform;
|
|
77
|
+
if (frames && frames > 0) host.stepFrames(frames);
|
|
78
|
+
const frameCount = host.status.frameCount;
|
|
79
|
+
|
|
80
|
+
// --- pixel content check (platform-agnostic) ---
|
|
81
|
+
const { width, height, rgba } = host.screenshotRgba();
|
|
82
|
+
const counts = new Map();
|
|
83
|
+
const total = width * height;
|
|
84
|
+
for (let i = 0; i + 3 < rgba.length; i += 4) {
|
|
85
|
+
const key = (rgba[i] << 16) | (rgba[i + 1] << 8) | rgba[i + 2];
|
|
86
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
87
|
+
}
|
|
88
|
+
let topColor = 0, topCount = 0;
|
|
89
|
+
for (const [c, n] of counts) if (n > topCount) { topCount = n; topColor = c; }
|
|
90
|
+
const distinctColors = counts.size;
|
|
91
|
+
const dominantFraction = total ? topCount / total : 1;
|
|
92
|
+
const nonDominant = total - topCount;
|
|
93
|
+
const pixels = {
|
|
94
|
+
width, height,
|
|
95
|
+
distinctColors,
|
|
96
|
+
dominantColor: "#" + topColor.toString(16).padStart(6, "0"),
|
|
97
|
+
dominantPct: Math.round(dominantFraction * 1000) / 10,
|
|
98
|
+
nonDominantPixels: nonDominant,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// --- render-enable / NMI verdict (reused, per-platform) ---
|
|
102
|
+
let render;
|
|
103
|
+
try {
|
|
104
|
+
const ctx = await getRenderingContextCore({ platform, area: "all", sessionKey });
|
|
105
|
+
render = { summary: ctx.summary || [], ...pickRenderFlags(ctx) };
|
|
106
|
+
} catch (e) {
|
|
107
|
+
render = { summary: [`(render-context decode unavailable for '${platform}': ${e.message})`], renderEnabled: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- frame-0 guard: report raw, no verdict (never cry wolf on boot) ---
|
|
111
|
+
if (frameCount === 0) {
|
|
112
|
+
return {
|
|
113
|
+
verified: null, unsettled: true, frame: 0, platform,
|
|
114
|
+
note: "No frame has been stepped yet — render state is the pre-boot default and not meaningful. " +
|
|
115
|
+
"Step frames first (frame({op:'step'}) or pass `frames`), then verify.",
|
|
116
|
+
pixels, render,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- fuse into a verdict + issues[] ---
|
|
121
|
+
const issues = [];
|
|
122
|
+
if (distinctColors <= 1) {
|
|
123
|
+
issues.push({ check: "blankScreen", detail: `the entire framebuffer is one color (${pixels.dominantColor}) — nothing is being drawn.` });
|
|
124
|
+
} else if (dominantFraction >= NEARLY_BLANK_DOMINANT) {
|
|
125
|
+
issues.push({ check: "nearlyBlank", detail: `${pixels.dominantPct}% of the screen is a single color (${pixels.dominantColor}); only ${nonDominant} px differ — a backdrop with almost no content reads as blank to a human even though something rendered. Add visible content (a tilemap/background, more sprites) until <${Math.round(NEARLY_BLANK_DOMINANT * 100)}% is one color.` });
|
|
126
|
+
}
|
|
127
|
+
if (render && render.renderEnabled === false) {
|
|
128
|
+
issues.push({ check: "renderDisabled", detail: `display output is disabled per the ${platform} registers: ${render.summary[0] || "see render.summary"}.` });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ok = issues.length === 0;
|
|
132
|
+
return {
|
|
133
|
+
verified: ok,
|
|
134
|
+
frame: frameCount,
|
|
135
|
+
platform,
|
|
136
|
+
...(ok
|
|
137
|
+
? { note: `Frame ${frameCount}: rendering looks alive (${distinctColors} colors, ${Math.round((100 - pixels.dominantPct) * 10) / 10}% of the screen is non-backdrop).` }
|
|
138
|
+
: { issues, note: "Rendering looks broken — see issues[]. For per-platform thresholds + the full checklist, getPlatformDoc({platform, doc:'mental_model'})." }),
|
|
139
|
+
pixels, render,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
9
142
|
|
|
10
143
|
// Get the platform's visible sprites in the generic shape, or null if
|
|
11
144
|
// not supported. Drives the screenshot overlay AND any future agents
|
|
@@ -231,6 +364,29 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
231
364
|
return shootPng({ path: outPath, inline, overlayBoxes, scale });
|
|
232
365
|
}
|
|
233
366
|
|
|
367
|
+
// op:'verify' — one-call "did the game actually render / is it alive?" health
|
|
368
|
+
// check for agents debugging WITHOUT vision. Fuses two independent signals:
|
|
369
|
+
// 1. the render-enable/NMI verdict from getRenderingContext (per-platform
|
|
370
|
+
// register decode — already correct, reused not re-derived), and
|
|
371
|
+
// 2. a pixel-level content check on the live framebuffer (is the screen
|
|
372
|
+
// actually showing more than one flat color?).
|
|
373
|
+
// Frame-0 guard: before any frame is stepped, report the raw condition WITHOUT
|
|
374
|
+
// an editorial verdict (so the header never "cries wolf" on boot).
|
|
375
|
+
async function doVerify({ frames }) {
|
|
376
|
+
const host = getHost(sessionKey);
|
|
377
|
+
if (!host.status.platform || !host.status.loaded) {
|
|
378
|
+
throw new Error("frame({op:'verify'}): no media loaded — loadMedia or build({output:'run'}) first.");
|
|
379
|
+
}
|
|
380
|
+
const json = jsonContent(await computeVerify(host, frames, sessionKey));
|
|
381
|
+
// verify's whole job is "look at the screen" — so push the exact frame it
|
|
382
|
+
// judged to the human's /livestream. Deferred provider: the PNG encode
|
|
383
|
+
// happens async after the agent's (JSON-only) response goes out, at zero
|
|
384
|
+
// cost to the agent. computeVerify already stepped the frames, so the
|
|
385
|
+
// host's current framebuffer IS the verified frame.
|
|
386
|
+
attachObserverFrame(json, host);
|
|
387
|
+
return json;
|
|
388
|
+
}
|
|
389
|
+
|
|
234
390
|
async function doStepAndShot({ frames, path: outPath, inline }) {
|
|
235
391
|
requireImageTarget(outPath, inline, "frame({op:'stepAndShot'})");
|
|
236
392
|
const host = getHost(sessionKey);
|
|
@@ -252,7 +408,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
252
408
|
|
|
253
409
|
server.tool(
|
|
254
410
|
"frame",
|
|
255
|
-
"Advance the emulator and capture frames. `op`: 'step' | 'screenshot' | 'stepAndShot' | 'stepInstruction'.\n" +
|
|
411
|
+
"Advance the emulator and capture frames. `op`: 'step' | 'screenshot' | 'stepAndShot' | 'stepInstruction' | 'verify'.\n" +
|
|
256
412
|
"'step': advance N `frames` as fast as possible — NO pacing/audio/vsync. Cores run at WASM speed, so frames:3600 " +
|
|
257
413
|
"(1 min of game time) finishes in ~5-30ms, cheaper than a screenshot. Don't be timid — skip a title with 300, a " +
|
|
258
414
|
"level with 7200; prefer ONE big call.\n" +
|
|
@@ -265,10 +421,18 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
265
421
|
"'stepAndShot': step + screenshot in ONE round-trip — the drive-then-look loop. (No overlayBoxes/scale here — png only.)\n" +
|
|
266
422
|
"'stepInstruction': execute exactly ONE CPU instruction and stop (finer than 'step'); freezes the CPU one " +
|
|
267
423
|
"instruction later and returns { pc }. Pair with cpu({op:'read'}) to watch registers change while tracing a routine.\n" +
|
|
424
|
+
"'verify': one-call 'is the game actually rendering / alive?' health check WITHOUT vision — for the spiral where an " +
|
|
425
|
+
"agent can't see the screen and doesn't know if a black frame means broken. Pass `frames` to boot-then-check in one " +
|
|
426
|
+
"call. Fuses (1) a pixel-content scan of the live framebuffer (distinctColors, dominant-color %) and (2) the " +
|
|
427
|
+
"per-platform render-ENABLE/NMI decode (reused from the rendering-context decoder — works on all 14 platforms). " +
|
|
428
|
+
"Returns {verified:true|false|null, issues[], pixels, render}. verified:null + unsettled when no frame has been " +
|
|
429
|
+
"stepped yet (it won't cry wolf on boot — step first). issues[] flags blankScreen/nearlyBlank/renderDisabled. " +
|
|
430
|
+
"renderDisabled is only raised when the registers SAY so (never on an undecodable platform). Pass/fail with no " +
|
|
431
|
+
"image tokens; for WHAT to fix, getPlatformDoc({platform, doc:'mental_model'}).\n" +
|
|
268
432
|
"IMAGE CONTRACT (screenshot/stepAndShot): the image goes to `path` (default, returns {path}) OR inline:true — " +
|
|
269
433
|
"you MUST pass one. Keeps PNGs out of context unless asked.",
|
|
270
434
|
{
|
|
271
|
-
op: z.enum(["step", "screenshot", "stepAndShot", "stepInstruction"]).describe("step frames; capture a screenshot; step+capture in one call;
|
|
435
|
+
op: z.enum(["step", "screenshot", "stepAndShot", "stepInstruction", "verify"]).describe("step frames; capture a screenshot; step+capture in one call; single-step one CPU instruction; or verify the game is actually rendering/alive (no vision needed)."),
|
|
272
436
|
frames: z.number().int().min(1).max(1_000_000).default(1).describe("op=step/stepAndShot: frames to advance (1-1,000,000). 36000 (10 min) usually completes in <1s — don't be conservative."),
|
|
273
437
|
format: z.enum(["png", "ascii"]).default("png").describe("op=screenshot: 'png' (default, real image) or 'ascii' (lossy text render)."),
|
|
274
438
|
path: z.string().optional().describe("op=screenshot/stepAndShot: absolute path to write to (required unless inline:true)."),
|
|
@@ -286,6 +450,7 @@ export function registerFrameTools(server, z, sessionKey) {
|
|
|
286
450
|
case "screenshot": return doScreenshot(args);
|
|
287
451
|
case "stepAndShot": return doStepAndShot(args);
|
|
288
452
|
case "stepInstruction": return await stepInstructionCore(sessionKey);
|
|
453
|
+
case "verify": return await doVerify(args);
|
|
289
454
|
default: throw new Error(`frame: unknown op '${args.op}'`);
|
|
290
455
|
}
|
|
291
456
|
}),
|
package/src/mcp/tools/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Register MCP tools on the server.
|
|
2
2
|
//
|
|
3
|
-
// The surface is ~
|
|
3
|
+
// The surface is ~32 consolidated domain tools (memory({op}), build({output}),
|
|
4
4
|
// breakpoint({on}), …) and EVERY one registers at session init. There is no
|
|
5
5
|
// progressive-disclosure / lean mode anymore: the dynamic loadCategory dance
|
|
6
6
|
// never propagated reliably to clients (they don't re-read tools/list after a
|
|
@@ -208,7 +208,7 @@ export function registerTools(server, z, sessionKey) {
|
|
|
208
208
|
if (!sessionKey) sessionKey = randomUUID();
|
|
209
209
|
// Clear validation errors for EVERY tool registered below: turns the SDK's
|
|
210
210
|
// raw JSON validation dump into a plain sentence and catches unknown/misspelled
|
|
211
|
-
// params (which the SDK otherwise drops silently). One wrap, all
|
|
211
|
+
// params (which the SDK otherwise drops silently). One wrap, all 32 tools.
|
|
212
212
|
// This is what lets the param descriptions stay terse — the guidance lives in
|
|
213
213
|
// the error (paid only on a bad call), not in every agent's initial context.
|
|
214
214
|
server = withClearToolErrors(server, z);
|
|
@@ -259,7 +259,7 @@ export function registerTools(server, z, sessionKey) {
|
|
|
259
259
|
);
|
|
260
260
|
|
|
261
261
|
// loadCategory + describeTool DELETED with the progressive-disclosure path:
|
|
262
|
-
// the whole surface is ~
|
|
262
|
+
// the whole surface is ~32 tools now and every one registers at session init,
|
|
263
263
|
// so the dynamic lean-mode dance (which never worked reliably — clients don't
|
|
264
264
|
// re-read tools/list after list_changed) has no reason to exist. `catalog`
|
|
265
265
|
// still exposes the category map for orientation. (See the consolidation.)
|
|
@@ -293,7 +293,7 @@ export function registerTools(server, z, sessionKey) {
|
|
|
293
293
|
// So by default we register EVERY category at session init. listCategories
|
|
294
294
|
// / loadCategory still exist (idempotent, harmless) for clients that probe
|
|
295
295
|
// Register EVERY category now — there is no lean/deferred mode anymore. The
|
|
296
|
-
// surface is small enough (~
|
|
296
|
+
// surface is small enough (~32 tools) that loading it all up front is the
|
|
297
297
|
// right call (the dynamic loadCategory dance never propagated reliably to
|
|
298
298
|
// clients). `disclosure.loadCategory("all")` is just the internal "register
|
|
299
299
|
// all categories" helper here, not a user-facing tool.
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { resolveCore } from "../../cores/registry.js";
|
|
2
|
-
import { defaultMediaKind } from "../../host/index.js";
|
|
3
2
|
import { clearHost, getHost, getHostOrNull, resetHost } from "../state.js";
|
|
4
3
|
import { jsonContent, safeTool, textContent } from "../util.js";
|
|
5
4
|
import { resolveCheatCodeForApply } from "./cheats.js";
|
|
@@ -19,7 +18,10 @@ export function registerLifecycleTools(server, z, sessionKey) {
|
|
|
19
18
|
await host.loadMedia({
|
|
20
19
|
platform,
|
|
21
20
|
...(bytes ? { bytes, virtualName } : { path }),
|
|
22
|
-
|
|
21
|
+
// Only force a mediaKind when the caller picked one; otherwise let the host
|
|
22
|
+
// derive it from the file extension (a C64 .d64 → "disk", .tap → "tape",
|
|
23
|
+
// .prg → "program") so status reports the kind honestly.
|
|
24
|
+
...(mediaKind ? { mediaKind } : {}),
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
// Pre-seed cheats BEFORE the first frame — so a boot-time cheat (e.g. a Game
|
package/src/mcp/tools/project.js
CHANGED
|
@@ -283,6 +283,11 @@ const TEMPLATES = {
|
|
|
283
283
|
sprite_move: mk("sprite_move", "Joypad-controlled 16x16 sprite over a tiled background. d-pad moves the sprite; verified visible + responsive. Build up an action game from here."),
|
|
284
284
|
music_sfx: mk("music_sfx", "HuC6280 PSG demo: a looping melody plus a button-fired SFX. Shows psg_tone/psg_off across the PSG's wavetable channels."),
|
|
285
285
|
catch_game: mk("catch_game", "A complete tiny game: a paddle catches a falling object with the d-pad; full game loop with waitvsync(), two sprites, collision, scoring."),
|
|
286
|
+
shmup: mk("shmup", "Vertical shoot-'em-up for PC Engine. Player ship + bullet/enemy object pools, a wave spawner, AABB collisions, score HUD, scrolling-band starfield BG. d-pad flies, button I fires. The base for any action shooter."),
|
|
287
|
+
platformer: mk("platformer", "Side-scrolling platformer for PC Engine. Gravity + jump + land-on-top platform collision, a multi-screen world streamed via BG X-scroll (BXR), solid platform tiles, sub-pixel physics. d-pad moves, button I jumps."),
|
|
288
|
+
puzzle: mk("puzzle", "Match-3 / falling-block puzzle for PC Engine. A 6x12 well drawn with BG tiles, a 1x3 active piece you move/rotate/soft-drop/hard-drop, horizontal-triple clears, score. d-pad moves, I rotates, II hard-drops."),
|
|
289
|
+
sports: mk("sports", "Pong-style sports game for PC Engine. Two paddles + a bouncing ball on a netted court, score to 9, paddle-deflect physics; player 2 falls back to chase-AI when no input. d-pad moves P1."),
|
|
290
|
+
racing: mk("racing", "Top-down lane racer for PC Engine. Player car at the bottom, obstacle cars spawn from the top and slide down, LEFT/RIGHT switches lanes, speed grows with score, crash freeze + auto-reset. Scrolling road BG."),
|
|
286
291
|
};
|
|
287
292
|
})(),
|
|
288
293
|
|
|
@@ -299,6 +304,11 @@ const TEMPLATES = {
|
|
|
299
304
|
sprite_move: mk("sprite_move", "Joystick-controlled sprite on a screen-2 background. d-pad moves the sprite; verified visible + responsive. The base for any action game."),
|
|
300
305
|
music_sfx: mk("music_sfx", "AY-3-8910 PSG demo: a looping melody on channel A plus a trigger-fired SFX on channel C, with an on-screen indicator."),
|
|
301
306
|
catch_game: mk("catch_game", "A complete tiny game: a paddle catches falling fruit with the joystick; full game loop with vblank sync, two sprites, collision, scoring."),
|
|
307
|
+
shmup: mk("shmup", "Vertical-shmup scaffold for MSX (screen 2). Player ship (sprite plane 0) + 4 bullet + 4 enemy object pools, a wave spawner, AABB collision, on-screen SCORE tiles, over a banded starfield filling the whole 32x24 name table. Joystick PORT 1 moves the ship (UP/DOWN/LEFT/RIGHT), trigger A (GTTRIG) fires; PSG blip on fire, noise-ish tone on a kill. Interrupt-free vsync via VDP status S#0. Extend with enemy fire, lives, scrolling stars."),
|
|
308
|
+
platformer: mk("platformer", "Side-scrolling platformer for MSX (screen 2). Subpixel gravity/jump/land-on-top collision against a table of platforms across a 512-px (64-cell) world, drawn by COLUMN STREAMING into the wrapping screen-2 name table as the camera follows the player; the player sprite draws in screen space. Joystick LEFT/RIGHT walks, trigger A jumps (only when grounded); PSG jump blip. Interrupt-free vsync. Extend with enemies, pickups, goal."),
|
|
309
|
+
puzzle: mk("puzzle", "Match-3 / falling-block puzzle for MSX (screen 2). A 6-wide x 12-tall well drawn with the BG tilemap (distinct R/G/B cell tiles + grey border + dim field interior so the playfield is always visible). A 1x3 active piece: joystick LEFT/RIGHT shifts, trigger A rotates the colour order, DOWN soft-drops, trigger B hard-drops; horizontal-triple clears score with a PSG chime. Interrupt-free vsync. Extend with vertical/diagonal matches, gravity-collapse, levels."),
|
|
310
|
+
sports: mk("sports", "Pong-style 2-player sports for MSX (screen 2). Court (green field + white sidelines + dashed centre net) fills the 32x24 name table; two paddles (stacked sprites) + a ball. Player 1 = joystick PORT 1 UP/DOWN; Player 2 = joystick PORT 2 UP/DOWN, falling back to chase-the-ball AI when no second pad is present so it is playable solo. Wall/paddle bounces + scoring with PSG bonks. Interrupt-free vsync. Extend with serve angles, score display, win condition."),
|
|
311
|
+
racing: mk("racing", "Top-down 3-lane racing for MSX (screen 2). Grey road + green-grass shoulders fill the name table; player car at the bottom, obstacle cars (object pool) spawn at the top and slide down. Joystick LEFT/RIGHT (edge-detected) switches lanes; obstacle speed grows with score; an AABB crash triggers a ~60-frame freeze then auto-reset, with a PSG crash tone. SCORE drawn as tiles. Interrupt-free vsync. Extend with pseudo-3D road, fuel, multiple cars."),
|
|
302
312
|
};
|
|
303
313
|
})(),
|
|
304
314
|
};
|
|
@@ -1223,6 +1233,43 @@ TEMPLATES.atari2600 = {
|
|
|
1223
1233
|
ext: ".a26",
|
|
1224
1234
|
describe: "Gallery-shooter (Space-Invaders-shaped) done with the RIGHT TIA objects, not playfield 'barcode' bars: P0 = double-width cannon, P1 + NUSIZ1=%011 = a row of THREE hardware-replicated invaders (one GRP1 write draws all three), M0 = the player shot. Aliens march left/right and drop a step at the edges; fire with the joystick button. The honest 2600-idiomatic way to do this genre — extend by reusing P1 lower for shields or adding M1 as an alien bomb. Verified: marches + renders cannon/aliens/shot.",
|
|
1225
1235
|
},
|
|
1236
|
+
// ── Genre scaffolds ───────────────────────────────────────────────
|
|
1237
|
+
// The 2600 maps cleanly onto only SOME of the five canonical genres.
|
|
1238
|
+
// shmup + sports are the console's native idioms (Space Invaders /
|
|
1239
|
+
// Pong); racing (top-down) and platformer (single-screen) are honest,
|
|
1240
|
+
// period-correct fits. puzzle (match-3) is deliberately ABSENT — see
|
|
1241
|
+
// the note after this block: a 6x12 multi-colour grid is not
|
|
1242
|
+
// renderable on a tilemap-less, one-COLUPF-per-line, 2-player TIA, so
|
|
1243
|
+
// shipping a "puzzle" key would mean shipping something that isn't a
|
|
1244
|
+
// recognizable match-3. Genre id == template key (createGame maps 1:1).
|
|
1245
|
+
shmup: {
|
|
1246
|
+
main: "templates/shmup.asm",
|
|
1247
|
+
runtime: [],
|
|
1248
|
+
lang: "6507 assembly (dasm)",
|
|
1249
|
+
ext: ".a26",
|
|
1250
|
+
describe: "SHMUP — the 2600's flagship genre (Space Invaders / Galaxian / Demon Attack). Gallery shooter done with the RIGHT TIA objects: P0 = double-width cannon, P1 + NUSIZ1=%011 = a row of THREE hardware-replicated invaders (one GRP1 write draws all three), M0 = the player shot. Aliens march left/right and drop a step at the edges; fire with the joystick button. Same proven body as the `mini_invaders` template. Extend with M1 as an alien bomb or reuse P1 lower for shields.",
|
|
1251
|
+
},
|
|
1252
|
+
sports: {
|
|
1253
|
+
main: "templates/sports.asm",
|
|
1254
|
+
runtime: [],
|
|
1255
|
+
lang: "6507 assembly (dasm)",
|
|
1256
|
+
ext: ".a26",
|
|
1257
|
+
describe: "SPORTS — Pong, the 2600's archetypal sport (Combat / Video Olympics). Two 8-px paddles (P0 left, P1 right), one 2-px ball (BL), top+bottom walls via reflected playfield. Joystick UP/DOWN drives the left paddle; the right paddle is AI (chases the ball's Y). Blip on wall bounce, chime on score. Same proven body as the `paddle` template. Demonstrates multi-object positioning (RESP0/RESP1/RESBL) + the 2-line kernel. Add a real P2 on the second port (SWCHA low nibble) to make it head-to-head.",
|
|
1258
|
+
},
|
|
1259
|
+
racing: {
|
|
1260
|
+
main: "templates/racing.asm",
|
|
1261
|
+
runtime: [],
|
|
1262
|
+
lang: "6507 assembly (dasm)",
|
|
1263
|
+
ext: ".a26",
|
|
1264
|
+
describe: "RACING — top-down vertical-scroll lane racer, the honest 2600 racing idiom (Enduro-style; pseudo-3D road projection needs a per-line table the 4 KB/76-cycle starter budget can't spare). P0 = your car near the bottom (LEFT/RIGHT to weave), reflected playfield draws the two road rails + a dashed centre line that scrolls upward to convey speed, P1 + M0 = descending traffic/hazards you must dodge. Speed (and score) ramps the longer you survive; a TIA-collision crash flashes the screen red and resets your speed. Extend with M1 as a 3rd hazard or NUSIZ1 for two-abreast traffic.",
|
|
1265
|
+
},
|
|
1266
|
+
platformer: {
|
|
1267
|
+
main: "templates/platformer.asm",
|
|
1268
|
+
runtime: [],
|
|
1269
|
+
lang: "6507 assembly (dasm)",
|
|
1270
|
+
ext: ".a26",
|
|
1271
|
+
describe: "PLATFORMER — SINGLE-SCREEN (Pitfall! / Montezuma / Kangaroo idiom). The 2600 has NO hardware scroll, no tilemap, 128 B RAM — a smooth side-scroller is not the honest fit (real games flip whole screens). This ships the genre CORE: fixed-point gravity + a jump arc (FIRE button), and land-on-top collision tested in CODE (not TIA collision, since you must know WHICH surface to stand on) against a 4-entry platform table drawn as horizontal playfield bars (the only TIA object wide enough to be a platform). Joystick walks L/R. Extend with ladders (UP/DOWN over a ladder x-span), an enemy on P1, a thrown rock on M0, or Pitfall-style screen-flipping at the edges. NOT a scroller — single screen by design.",
|
|
1272
|
+
},
|
|
1226
1273
|
};
|
|
1227
1274
|
|
|
1228
1275
|
// R22: Atari 7800 promoted to multi-template platform. Each template is
|
|
@@ -1810,15 +1857,13 @@ async function createGameCore({ platform, genre, name, path: projPath, title, ov
|
|
|
1810
1857
|
? CANONICAL_GENRES.filter((g) => platformTemplates[g])
|
|
1811
1858
|
: [];
|
|
1812
1859
|
if (availableGenres.length === 0) {
|
|
1813
|
-
//
|
|
1814
|
-
// platform
|
|
1815
|
-
//
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
};
|
|
1821
|
-
const hint = PROJECT_TEMPLATE_HINTS[platform];
|
|
1860
|
+
// Reached only by a platform that ships NO canonical genre yet (every
|
|
1861
|
+
// tier-1 platform now ships at least one — atari2600 ships 4, the rest
|
|
1862
|
+
// ship all 5 — so in practice this is the bring-up / non-genre tier).
|
|
1863
|
+
// List that platform's real project templates so the agent has a
|
|
1864
|
+
// concrete next step instead of a bare "default".
|
|
1865
|
+
const projTemplates = platformTemplates ? Object.keys(platformTemplates) : [];
|
|
1866
|
+
const hint = projTemplates.length ? projTemplates.join(", ") : null;
|
|
1822
1867
|
throw new Error(
|
|
1823
1868
|
`createGame: no genre scaffolds for platform '${platform}' yet. ` +
|
|
1824
1869
|
`Supported platforms: ${genrePlatforms.join(", ") || "(none)"}. ` +
|
package/src/mcp/tools/state.js
CHANGED
|
@@ -3,6 +3,21 @@ import path from "node:path";
|
|
|
3
3
|
import { getHost } from "../state.js";
|
|
4
4
|
import { jsonContent, safeTool } from "../util.js";
|
|
5
5
|
|
|
6
|
+
// Resolve a state-file `path`. An ABSOLUTE path is used as-is. A RELATIVE path
|
|
7
|
+
// is resolved against the LOADED ROM's directory (the agent's mental model is
|
|
8
|
+
// "save states live next to my ROM") — NOT the server's CWD, which is opaque to
|
|
9
|
+
// the caller and was a silent ENOENT footgun (v0.15.0 feedback #1). Falls back
|
|
10
|
+
// to CWD only when no ROM path is known (e.g. ROM loaded from base64).
|
|
11
|
+
export function resolveStatePath(p, host) {
|
|
12
|
+
if (!p || path.isAbsolute(p)) return p;
|
|
13
|
+
const media = host?.status?.mediaPath;
|
|
14
|
+
// mediaPath is "<memory…>" for base64 loads — not a real dir; skip those.
|
|
15
|
+
if (media && !media.startsWith("<") && path.isAbsolute(media)) {
|
|
16
|
+
return path.resolve(path.dirname(media), p);
|
|
17
|
+
}
|
|
18
|
+
return path.resolve(p);
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
// Per-session state-diff baselines (op:'diff'). Module-local; keyed by sessionKey.
|
|
7
22
|
const _stateDiffSnaps = new Map();
|
|
8
23
|
function stateDiffSnapshots(key) {
|
|
@@ -19,16 +34,17 @@ async function saveStateCore({ name, path: outPath }, sessionKey) {
|
|
|
19
34
|
const host = getHost(sessionKey);
|
|
20
35
|
const done = [];
|
|
21
36
|
if (name) { host.saveState(name); done.push(`slot '${name}'`); }
|
|
22
|
-
|
|
37
|
+
const resolvedOut = outPath ? resolveStatePath(outPath, host) : null;
|
|
38
|
+
if (resolvedOut) {
|
|
23
39
|
const blob = host.serializeState();
|
|
24
|
-
await mkdir(path.dirname(
|
|
25
|
-
await writeFile(
|
|
26
|
-
done.push(`${blob.length} bytes → ${
|
|
40
|
+
await mkdir(path.dirname(resolvedOut), { recursive: true });
|
|
41
|
+
await writeFile(resolvedOut, blob);
|
|
42
|
+
done.push(`${blob.length} bytes → ${resolvedOut}`);
|
|
27
43
|
}
|
|
28
44
|
return {
|
|
29
45
|
saved: true,
|
|
30
46
|
...(name ? { name } : {}),
|
|
31
|
-
...(
|
|
47
|
+
...(resolvedOut ? { path: resolvedOut, ...(resolvedOut !== outPath ? { resolvedPath: resolvedOut } : {}) } : {}),
|
|
32
48
|
platform: host.status.platform,
|
|
33
49
|
note: `Saved ${done.join(" + ")}.` + (outPath ? " Restore across sessions with state({op:'load', path}) after loading the same ROM." : ""),
|
|
34
50
|
};
|
|
@@ -38,12 +54,14 @@ async function saveStateCore({ name, path: outPath }, sessionKey) {
|
|
|
38
54
|
async function exportStateCore({ fromSlot, path: outPath }, sessionKey) {
|
|
39
55
|
const host = getHost(sessionKey);
|
|
40
56
|
const blob = host.getStateBlob(fromSlot); // throws if the slot is missing — no host disturbance
|
|
41
|
-
|
|
42
|
-
await
|
|
57
|
+
const resolvedOut = resolveStatePath(outPath, host);
|
|
58
|
+
await mkdir(path.dirname(resolvedOut), { recursive: true });
|
|
59
|
+
await writeFile(resolvedOut, blob);
|
|
43
60
|
return {
|
|
44
61
|
exported: true,
|
|
45
62
|
fromSlot,
|
|
46
|
-
path:
|
|
63
|
+
path: resolvedOut,
|
|
64
|
+
...(resolvedOut !== outPath ? { resolvedPath: resolvedOut } : {}),
|
|
47
65
|
bytes: blob.length,
|
|
48
66
|
platform: host.status.platform,
|
|
49
67
|
note: "Copied the slot to disk; the live host was not touched (no pause/resume needed).",
|
|
@@ -56,8 +74,9 @@ async function loadStateCore({ name, path: inPath, render = true }, sessionKey)
|
|
|
56
74
|
if (name && inPath) throw new Error("state({op:'load'}): provide `name` OR `path`, not both.");
|
|
57
75
|
const host = getHost(sessionKey);
|
|
58
76
|
let cheatsCleared = 0;
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
const resolvedIn = inPath ? resolveStatePath(inPath, host) : null;
|
|
78
|
+
if (resolvedIn) {
|
|
79
|
+
const blob = new Uint8Array(await readFile(resolvedIn));
|
|
61
80
|
cheatsCleared = host.unserializeState(blob) || 0;
|
|
62
81
|
} else {
|
|
63
82
|
cheatsCleared = host.loadState(name) || 0;
|
|
@@ -66,7 +85,7 @@ async function loadStateCore({ name, path: inPath, render = true }, sessionKey)
|
|
|
66
85
|
if (render) { host.renderOneFrame(); rendered = true; }
|
|
67
86
|
return {
|
|
68
87
|
loaded: true,
|
|
69
|
-
...(
|
|
88
|
+
...(resolvedIn ? { path: resolvedIn, ...(resolvedIn !== inPath ? { resolvedPath: resolvedIn } : {}) } : { name }),
|
|
70
89
|
platform: host.status.platform,
|
|
71
90
|
rendered,
|
|
72
91
|
...(host.status.paused && rendered ? { renderedWhilePaused: true } : {}),
|
|
@@ -79,6 +98,153 @@ function listStatesCore(_args, sessionKey) {
|
|
|
79
98
|
return { states: getHost(sessionKey).listStates() };
|
|
80
99
|
}
|
|
81
100
|
|
|
101
|
+
// SRAM presence: the battery-backed cartridge save RAM size for the loaded ROM
|
|
102
|
+
// (0 = this cart/system has no battery save). Used by exportSram/importSram and
|
|
103
|
+
// surfaced so an agent knows whether a save file even exists.
|
|
104
|
+
function sramSize(host) {
|
|
105
|
+
try { return host.regionSize("save_ram"); } catch { return 0; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** op:'exportSram' — write the cartridge's battery SAVE RAM to a .sav file.
|
|
109
|
+
* This is the actual save-game file (distinct from a whole-machine savestate):
|
|
110
|
+
* the bytes a real cart keeps on its battery. Empty on a no-battery cart. */
|
|
111
|
+
async function exportSramCore({ path: outPath }, sessionKey) {
|
|
112
|
+
const host = getHost(sessionKey);
|
|
113
|
+
const size = sramSize(host);
|
|
114
|
+
if (!size) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`state({op:'exportSram'}): the loaded ROM has no battery save RAM ` +
|
|
117
|
+
`(platform '${host.status.platform}', size 0). Either this cart has no battery ` +
|
|
118
|
+
`save, or this system never had cartridge saves (Atari 2600/7800, Lynx; C64 saves ` +
|
|
119
|
+
`are disk-based). Use state({op:'save', path}) for a full-machine savestate instead.`);
|
|
120
|
+
}
|
|
121
|
+
const blob = host.readMemory("save_ram", 0, size);
|
|
122
|
+
const resolved = resolveStatePath(outPath, host);
|
|
123
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
124
|
+
await writeFile(resolved, Buffer.from(blob));
|
|
125
|
+
return {
|
|
126
|
+
exportedSram: true,
|
|
127
|
+
path: resolved,
|
|
128
|
+
...(resolved !== outPath ? { resolvedPath: resolved } : {}),
|
|
129
|
+
bytes: size,
|
|
130
|
+
platform: host.status.platform,
|
|
131
|
+
note: "Wrote the cartridge's battery SAVE RAM (the .sav save-game file). Restore with " +
|
|
132
|
+
"state({op:'importSram', path}) after loading the same ROM. This is the SAVE FILE, " +
|
|
133
|
+
"not a savestate — edit it offline (it's raw SRAM) or inject one a player made elsewhere.",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** op:'importSram' — load a .sav file back into the cartridge's battery SAVE RAM. */
|
|
138
|
+
async function importSramCore({ path: inPath }, sessionKey) {
|
|
139
|
+
const host = getHost(sessionKey);
|
|
140
|
+
const size = sramSize(host);
|
|
141
|
+
if (!size) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`state({op:'importSram'}): the loaded ROM has no battery save RAM ` +
|
|
144
|
+
`(platform '${host.status.platform}', size 0) — nowhere to load a .sav into.`);
|
|
145
|
+
}
|
|
146
|
+
const resolved = resolveStatePath(inPath, host);
|
|
147
|
+
const blob = new Uint8Array(await readFile(resolved));
|
|
148
|
+
if (blob.length !== size) {
|
|
149
|
+
// Size mismatch is the classic wrong-game/wrong-region footgun — surface it,
|
|
150
|
+
// but allow a smaller blob (zero-pad) since some dumps trim trailing zeros.
|
|
151
|
+
if (blob.length > size) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`state({op:'importSram'}): .sav is ${blob.length} bytes but this cart's SAVE RAM is ${size} ` +
|
|
154
|
+
`— too large (wrong game/region?). Refusing to truncate.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
host.writeMemory("save_ram", 0, blob);
|
|
158
|
+
return {
|
|
159
|
+
importedSram: true,
|
|
160
|
+
path: resolved,
|
|
161
|
+
...(resolved !== inPath ? { resolvedPath: resolved } : {}),
|
|
162
|
+
bytes: blob.length,
|
|
163
|
+
sramSize: size,
|
|
164
|
+
...(blob.length < size ? { zeroPadded: size - blob.length } : {}),
|
|
165
|
+
platform: host.status.platform,
|
|
166
|
+
note: "Loaded the .sav into the cartridge's battery SAVE RAM. The running game sees it " +
|
|
167
|
+
"on its next save-RAM read (some games re-read only on a load/menu). " +
|
|
168
|
+
(blob.length < size ? `Blob was smaller than SRAM (${blob.length}<${size}); the tail kept its prior bytes.` : ""),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** op:'exportDisk' — write the LIVE mounted C64 .d64 disk image to a file.
|
|
173
|
+
* The C64 analogue of exportSram: a game saves by writing files to its disk, and
|
|
174
|
+
* this snapshots the whole disk (incl. any saves the game wrote). C64/VICE only. */
|
|
175
|
+
async function exportDiskCore({ path: outPath, unit = 8 }, sessionKey) {
|
|
176
|
+
const host = getHost(sessionKey);
|
|
177
|
+
if (!host.diskImageSupported || !host.diskImageSupported()) {
|
|
178
|
+
throw new Error("state({op:'exportDisk'}): disk images are a C64 feature (VICE). " +
|
|
179
|
+
`The loaded platform is '${host.status.platform}'.`);
|
|
180
|
+
}
|
|
181
|
+
const blob = host.exportDiskImage(unit); // throws if no .d64 mounted
|
|
182
|
+
const resolved = resolveStatePath(outPath, host);
|
|
183
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
184
|
+
await writeFile(resolved, Buffer.from(blob));
|
|
185
|
+
return {
|
|
186
|
+
exportedDisk: true,
|
|
187
|
+
path: resolved,
|
|
188
|
+
...(resolved !== outPath ? { resolvedPath: resolved } : {}),
|
|
189
|
+
bytes: blob.length,
|
|
190
|
+
unit,
|
|
191
|
+
note: "Wrote the LIVE 1541 disk image (.d64) — the C64 save medium. Re-load it later " +
|
|
192
|
+
"with loadMedia({platform:'c64', path}) (it autostarts), or push it back into a " +
|
|
193
|
+
"running session with state({op:'importDisk', path}). This captures any files the " +
|
|
194
|
+
"game wrote to disk.",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** op:'importDisk' — write a .d64 file back into the LIVE mounted C64 disk image. */
|
|
199
|
+
async function importDiskCore({ path: inPath, unit = 8 }, sessionKey) {
|
|
200
|
+
const host = getHost(sessionKey);
|
|
201
|
+
if (!host.diskImageSupported || !host.diskImageSupported()) {
|
|
202
|
+
throw new Error("state({op:'importDisk'}): disk images are a C64 feature (VICE). " +
|
|
203
|
+
`The loaded platform is '${host.status.platform}'.`);
|
|
204
|
+
}
|
|
205
|
+
const resolved = resolveStatePath(inPath, host);
|
|
206
|
+
const blob = new Uint8Array(await readFile(resolved));
|
|
207
|
+
if (blob.length !== 174848) {
|
|
208
|
+
throw new Error(`state({op:'importDisk'}): '${resolved}' is ${blob.length} bytes — not a ` +
|
|
209
|
+
`standard 174848-byte 35-track .d64. Only that format round-trips through the live drive.`);
|
|
210
|
+
}
|
|
211
|
+
const n = host.importDiskImage(blob, unit);
|
|
212
|
+
return {
|
|
213
|
+
importedDisk: true,
|
|
214
|
+
path: resolved,
|
|
215
|
+
...(resolved !== inPath ? { resolvedPath: resolved } : {}),
|
|
216
|
+
bytes: n,
|
|
217
|
+
unit,
|
|
218
|
+
note: "Wrote the .d64 into the running C64's mounted disk. The game sees it on its next " +
|
|
219
|
+
"disk access (a load/menu). Use this to inject a save disk a player made elsewhere.",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** op:'putDiskFile' — write ONE PRG file into the LIVE mounted C64 disk (inject a save). */
|
|
224
|
+
async function putDiskFileCore({ path: inPath, name, unit = 8 }, sessionKey) {
|
|
225
|
+
const host = getHost(sessionKey);
|
|
226
|
+
if (!host.diskImageSupported || !host.diskImageSupported()) {
|
|
227
|
+
throw new Error("state({op:'putDiskFile'}): disk files are a C64 feature (VICE). " +
|
|
228
|
+
`The loaded platform is '${host.status.platform}'.`);
|
|
229
|
+
}
|
|
230
|
+
if (!inPath) throw new Error("state({op:'putDiskFile'}): `path` (the file to write) is required.");
|
|
231
|
+
const resolved = resolveStatePath(inPath, host);
|
|
232
|
+
const blob = new Uint8Array(await readFile(resolved));
|
|
233
|
+
// file name on disk: explicit `name`, else the source basename (sans extension), uppercased
|
|
234
|
+
const fname = (name || path.basename(resolved).replace(/\.[^.]+$/, ""))
|
|
235
|
+
.toUpperCase().replace(/[^A-Z0-9 ]/g, "").slice(0, 16) || "FILE";
|
|
236
|
+
host.putDiskFile(fname, blob, unit);
|
|
237
|
+
return {
|
|
238
|
+
wroteDiskFile: true,
|
|
239
|
+
name: fname,
|
|
240
|
+
path: resolved,
|
|
241
|
+
bytes: blob.length,
|
|
242
|
+
unit,
|
|
243
|
+
note: "Wrote one PRG file into the running C64's mounted disk via the drive. Read the " +
|
|
244
|
+
"whole disk back with state({op:'exportDisk', path}) or cart({op:'extract'}).",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
82
248
|
/** op:'dump' — raw libretro blob to disk for forensic inspection (+ optional findHex). */
|
|
83
249
|
async function dumpStateCore({ path: outPath, findHex, maxMatches = 32 }, sessionKey) {
|
|
84
250
|
const host = getHost(sessionKey);
|
|
@@ -159,9 +325,10 @@ export function registerStateTools(server, z, sessionKey) {
|
|
|
159
325
|
"'list': named in-memory slots. 'diff': whole-machine 'did ANYTHING change?' (coarser than memory diff) — " +
|
|
160
326
|
"snapOrDiff:'snapshot' captures, 'diff' compares.",
|
|
161
327
|
{
|
|
162
|
-
op: z.enum(["save", "load", "list", "export", "dump", "diff"]).describe("save/load a
|
|
163
|
-
name: z.string().min(1).optional().describe("op=save/load: in-memory slot name. op=diff: snapshot label (default 'default')."),
|
|
164
|
-
|
|
328
|
+
op: z.enum(["save", "load", "list", "export", "dump", "diff", "exportSram", "importSram", "exportDisk", "importDisk", "putDiskFile"]).describe("save/load a savestate (whole machine); list slots; export a slot to disk; dump the raw blob; diff the whole machine. SRAM (the cartridge BATTERY SAVE FILE, distinct from a savestate): exportSram writes the .sav, importSram loads one back. C64 DISK (VICE; the C64 save medium is a floppy, not battery SRAM): exportDisk writes the live .d64, importDisk pushes a .d64 back into the running drive, putDiskFile injects one PRG file into the live disk."),
|
|
329
|
+
name: z.string().min(1).optional().describe("op=save/load: in-memory slot name. op=diff: snapshot label (default 'default'). op=putDiskFile: file name on the disk (≤16 chars; default = source basename)."),
|
|
330
|
+
unit: z.number().int().min(8).max(11).default(8).describe("op=exportDisk/importDisk/putDiskFile (C64): drive unit (default 8)."),
|
|
331
|
+
path: z.string().optional().describe("op=save: also write the blob here (survives restarts). op=load: restore from this disk blob. op=export/dump: write the blob here (required). A RELATIVE path resolves against the loaded ROM's directory (NOT the server CWD); an absolute path is used as-is. The result echoes `resolvedPath` when they differ."),
|
|
165
332
|
// load
|
|
166
333
|
render: z.boolean().default(true).describe("op=load: step one frame after restoring so the framebuffer reflects it (fixes the stale-screenshot footgun). false = stay at the exact restored instant."),
|
|
167
334
|
// export
|
|
@@ -190,6 +357,26 @@ export function registerStateTools(server, z, sessionKey) {
|
|
|
190
357
|
if (!args.snapOrDiff) throw new Error("state({op:'diff'}): `snapOrDiff` ('snapshot' or 'diff') is required.");
|
|
191
358
|
return jsonContent(diffStateCore(args, sessionKey));
|
|
192
359
|
}
|
|
360
|
+
case "exportSram": {
|
|
361
|
+
if (!args.path) throw new Error("state({op:'exportSram'}): `path` (where to write the .sav) is required.");
|
|
362
|
+
return jsonContent(await exportSramCore(args, sessionKey));
|
|
363
|
+
}
|
|
364
|
+
case "importSram": {
|
|
365
|
+
if (!args.path) throw new Error("state({op:'importSram'}): `path` (the .sav to load) is required.");
|
|
366
|
+
return jsonContent(await importSramCore(args, sessionKey));
|
|
367
|
+
}
|
|
368
|
+
case "exportDisk": {
|
|
369
|
+
if (!args.path) throw new Error("state({op:'exportDisk'}): `path` (where to write the .d64) is required.");
|
|
370
|
+
return jsonContent(await exportDiskCore(args, sessionKey));
|
|
371
|
+
}
|
|
372
|
+
case "importDisk": {
|
|
373
|
+
if (!args.path) throw new Error("state({op:'importDisk'}): `path` (the .d64 to load) is required.");
|
|
374
|
+
return jsonContent(await importDiskCore(args, sessionKey));
|
|
375
|
+
}
|
|
376
|
+
case "putDiskFile": {
|
|
377
|
+
if (!args.path) throw new Error("state({op:'putDiskFile'}): `path` (the PRG file to inject) is required.");
|
|
378
|
+
return jsonContent(await putDiskFileCore(args, sessionKey));
|
|
379
|
+
}
|
|
193
380
|
default: throw new Error(`state: unknown op '${args.op}'`);
|
|
194
381
|
}
|
|
195
382
|
}),
|