c64bridge 0.7.4 → 0.8.1

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 (109) hide show
  1. package/.github/prompts/README.md +14 -4
  2. package/.github/prompts/assembly-routine-session.prompt.md +9 -9
  3. package/.github/prompts/basic-program-session.prompt.md +11 -9
  4. package/.github/prompts/drive-management-session.prompt.md +9 -9
  5. package/.github/prompts/hello-world-session.prompt.md +17 -0
  6. package/.github/prompts/petscii-art-session.prompt.md +9 -9
  7. package/.github/prompts/play-classic-game.prompt.md +9 -9
  8. package/.github/prompts/pr-converge.prompt.md +13 -0
  9. package/.github/prompts/sid-music-session.prompt.md +9 -9
  10. package/AGENTS.md +59 -60
  11. package/README.md +550 -191
  12. package/data/assembly/assembly-spec.md +290 -1
  13. package/data/context/bootstrap.md +26 -2
  14. package/data/context/fast-paths.md +44 -0
  15. package/data/graphics/petscii-style-guide.md +6 -2
  16. package/data/graphics/sprite-charset-best-practices.md +5 -5
  17. package/dist/.tsbuildinfo +1 -1
  18. package/dist/c64Client.js +990 -26
  19. package/dist/config.js +5 -1
  20. package/dist/device.js +680 -57
  21. package/dist/diagnostics.js +371 -0
  22. package/dist/mcp/metadata.js +39 -0
  23. package/dist/mcp-server.js +170 -20
  24. package/dist/platform.js +10 -2
  25. package/dist/prompts/registry.js +166 -309
  26. package/dist/rag/init.js +37 -1
  27. package/dist/rag/knowledgeIndex.js +18 -6
  28. package/dist/rag/rateLimiter.js +2 -2
  29. package/dist/streamCapture.js +135 -0
  30. package/dist/tools/audio.js +3 -1
  31. package/dist/tools/debug.js +470 -0
  32. package/dist/tools/developer.js +10 -4
  33. package/dist/tools/graphics.js +183 -17
  34. package/dist/tools/machineControl.js +11 -5
  35. package/dist/tools/memory.js +19 -10
  36. package/dist/tools/meta/audio.js +240 -1
  37. package/dist/tools/meta/config.js +20 -28
  38. package/dist/tools/meta/configInventory.js +104 -0
  39. package/dist/tools/meta/diagnostics.js +45 -2
  40. package/dist/tools/meta/graphics.js +14 -6
  41. package/dist/tools/meta/index.js +1 -0
  42. package/dist/tools/meta/memory.js +20 -6
  43. package/dist/tools/meta/program.js +473 -0
  44. package/dist/tools/pollValidator.js +4 -2
  45. package/dist/tools/programRunners.js +8 -4
  46. package/dist/tools/registry/config.js +17 -0
  47. package/dist/tools/registry/disk.js +7 -0
  48. package/dist/tools/registry/drive.js +3 -0
  49. package/dist/tools/registry/graphics.js +111 -27
  50. package/dist/tools/registry/index.js +6 -0
  51. package/dist/tools/registry/memory.js +1 -0
  52. package/dist/tools/registry/platform.js +121 -0
  53. package/dist/tools/registry/program.js +26 -8
  54. package/dist/tools/registry/sound.js +96 -0
  55. package/dist/tools/registry/system.js +15 -2
  56. package/dist/tools/storage.js +1 -0
  57. package/dist/tools/streaming.js +1 -0
  58. package/dist/tools/types.js +38 -4
  59. package/dist/tools/vice.js +160 -0
  60. package/dist/vicBitmap.js +317 -0
  61. package/dist/vice/mockServer.js +439 -0
  62. package/dist/vice/process.js +310 -0
  63. package/dist/vice/readiness.js +18 -2
  64. package/dist/vice/viceClient.js +382 -22
  65. package/doc/developer.md +71 -25
  66. package/doc/img/backend-switch/hello-c64u.png +0 -0
  67. package/doc/img/backend-switch/hello-vice-and-c64u.png +0 -0
  68. package/doc/img/backend-switch/hello-vice.png +0 -0
  69. package/doc/img/backend-switch/metadata.json +50 -0
  70. package/doc/plans/vice-backend-switch/plan.md +486 -0
  71. package/doc/plans/vice-backend-switch-2/plan.md +48 -0
  72. package/doc/plans/vice-support/notes.md +1 -0
  73. package/doc/plans/vice-support/plan.md +5 -0
  74. package/doc/plans/vice-support/rollout.md +23 -17
  75. package/doc/reviews/vice-support-review.md +185 -0
  76. package/doc/troubleshooting-mcp.md +35 -6
  77. package/doc/vice/support-matrix.md +54 -0
  78. package/mcp/prompts.json +292 -0
  79. package/mcp/protocol-examples.json +46 -0
  80. package/mcp/resources.json +940 -0
  81. package/mcp/schemas/c64_config.schema.json +350 -0
  82. package/mcp/schemas/c64_debug.schema.json +380 -0
  83. package/mcp/schemas/c64_disk.schema.json +278 -0
  84. package/mcp/schemas/c64_drive.schema.json +120 -0
  85. package/mcp/schemas/c64_extract.schema.json +302 -0
  86. package/mcp/schemas/c64_graphics.schema.json +333 -0
  87. package/mcp/schemas/c64_memory.schema.json +155 -0
  88. package/mcp/schemas/c64_printer.schema.json +218 -0
  89. package/mcp/schemas/c64_program.schema.json +391 -0
  90. package/mcp/schemas/c64_rag.schema.json +67 -0
  91. package/mcp/schemas/c64_select_backend.schema.json +32 -0
  92. package/mcp/schemas/c64_sound.schema.json +644 -0
  93. package/mcp/schemas/c64_stream.schema.json +61 -0
  94. package/mcp/schemas/c64_system.schema.json +227 -0
  95. package/mcp/schemas/c64_vice.schema.json +63 -0
  96. package/mcp/server.json +15 -0
  97. package/mcp/tools.json +4517 -0
  98. package/mcp.json +90 -15
  99. package/package.json +11 -4
  100. package/scripts/capture-backend-switch-screenshots.mjs +276 -0
  101. package/scripts/generate-mcp-interface.ts +289 -0
  102. package/scripts/invoke-bun.mjs +67 -21
  103. package/scripts/mockC64Server.mjs +162 -6
  104. package/scripts/prepare-release.mjs +59 -2
  105. package/scripts/run-coverage.mjs +284 -0
  106. package/scripts/run-tests.mjs +95 -39
  107. package/scripts/run-tests.ts +378 -73
  108. package/scripts/update-readme.ts +147 -13
  109. package/server.json +94 -0
@@ -5,6 +5,178 @@ import { promises as fs } from "node:fs";
5
5
  import { resolve as resolvePath, join as joinPath } from "node:path";
6
6
  import { sleep, formatTimestampSpec } from "./util.js";
7
7
  import { getTasksHomeDir } from "./background.js";
8
+ import { Jimp } from "jimp";
9
+ import { withDiagnosticSpan } from "../../diagnostics.js";
10
+ const C64_SCREENSHOT_PALETTE = [
11
+ 0x000000ff,
12
+ 0xffffffff,
13
+ 0x813338ff,
14
+ 0x75cec8ff,
15
+ 0x8e3c97ff,
16
+ 0x56ac4dff,
17
+ 0x2e2c9bff,
18
+ 0xedf171ff,
19
+ 0x8e5029ff,
20
+ 0x553800ff,
21
+ 0xc46c71ff,
22
+ 0x4a4a4aff,
23
+ 0x7b7b7bff,
24
+ 0xa9ff9fff,
25
+ 0x706debff,
26
+ 0xb2b2b2ff,
27
+ ];
28
+ function hasOwnProperty(value, key) {
29
+ return Object.prototype.hasOwnProperty.call(value, key);
30
+ }
31
+ function isVisibleViceSessionPreferred() {
32
+ const visible = String(process.env.VICE_VISIBLE ?? "true").trim().toLowerCase();
33
+ const forceXvfb = String(process.env.FORCE_XVFB ?? "0").trim().toLowerCase();
34
+ const disableXvfb = String(process.env.DISABLE_XVFB ?? "0").trim().toLowerCase();
35
+ if (forceXvfb === "1" || forceXvfb === "true" || forceXvfb === "yes") {
36
+ return false;
37
+ }
38
+ if (disableXvfb === "1" || disableXvfb === "true" || disableXvfb === "yes") {
39
+ return visible !== "0" && visible !== "false" && visible !== "no";
40
+ }
41
+ return visible !== "0" && visible !== "false" && visible !== "no";
42
+ }
43
+ function shouldUseVisibleViceGreetingFastPath(rawArgs, requestedBackends) {
44
+ if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) {
45
+ return false;
46
+ }
47
+ if (requestedBackends.length !== 1 || requestedBackends[0] !== "vice") {
48
+ return false;
49
+ }
50
+ if (!isVisibleViceSessionPreferred()) {
51
+ return false;
52
+ }
53
+ const args = rawArgs;
54
+ return !hasOwnProperty(args, "verify")
55
+ && !hasOwnProperty(args, "captureScreenshot")
56
+ && !hasOwnProperty(args, "outputPath");
57
+ }
58
+ function canonicalGreetingBackends() {
59
+ return ["vice", "c64u"];
60
+ }
61
+ function uniqueGreetingBackends(backends) {
62
+ return backends.filter((backend, index) => backends.indexOf(backend) === index);
63
+ }
64
+ function normaliseScreenText(screen) {
65
+ return screen.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim().toUpperCase();
66
+ }
67
+ function screenContainsExpectedText(screen, expectedText) {
68
+ return normaliseScreenText(screen).includes(normaliseScreenText(expectedText));
69
+ }
70
+ function applyGreetingTemplate(template, backend) {
71
+ const upper = backend.toUpperCase();
72
+ const lower = backend.toLowerCase();
73
+ return template
74
+ .replace(/\{PLATFORM\}/g, upper)
75
+ .replace(/\{BACKEND\}/g, upper)
76
+ .replace(/\{platform\}/g, lower)
77
+ .replace(/\{backend\}/g, lower);
78
+ }
79
+ function escapeBasicString(value) {
80
+ return value.replace(/"/g, '""');
81
+ }
82
+ function buildGreetingProgram(message) {
83
+ return [
84
+ "10 PRINT CHR$(147)",
85
+ `20 PRINT \"${escapeBasicString(message)}\"`,
86
+ "30 END",
87
+ ].join("\n");
88
+ }
89
+ function analyseCapturedFrame(frame) {
90
+ const totalPixels = Math.max(1, frame.width * frame.height);
91
+ let nonBackgroundPixels = 0;
92
+ const uniqueColors = new Set();
93
+ if (frame.bitsPerPixel === 4 || frame.bitsPerPixel === 8) {
94
+ for (let index = 0; index < totalPixels; index += 1) {
95
+ const value = frame.pixels[index] ?? 0;
96
+ uniqueColors.add(value);
97
+ if (value !== 0) {
98
+ nonBackgroundPixels += 1;
99
+ }
100
+ }
101
+ }
102
+ else {
103
+ const bytesPerPixel = frame.bitsPerPixel >= 24 && frame.bitsPerPixel % 8 === 0
104
+ ? frame.bitsPerPixel / 8
105
+ : 0;
106
+ if (bytesPerPixel > 0) {
107
+ for (let index = 0; index < totalPixels; index += 1) {
108
+ const offset = index * bytesPerPixel;
109
+ const red = frame.pixels[offset] ?? 0;
110
+ const green = frame.pixels[offset + 1] ?? 0;
111
+ const blue = frame.pixels[offset + 2] ?? 0;
112
+ const alpha = bytesPerPixel >= 4 ? (frame.pixels[offset + 3] ?? 255) : 255;
113
+ uniqueColors.add(((red & 0xff) << 24) | ((green & 0xff) << 16) | ((blue & 0xff) << 8) | (alpha & 0xff));
114
+ if (red !== 0 || green !== 0 || blue !== 0) {
115
+ nonBackgroundPixels += 1;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ const nonBackgroundRatio = Number((nonBackgroundPixels / totalPixels).toFixed(4));
121
+ return {
122
+ width: frame.width,
123
+ height: frame.height,
124
+ bitsPerPixel: frame.bitsPerPixel,
125
+ complete: frame.complete,
126
+ totalPixels,
127
+ nonBackgroundPixels,
128
+ nonBackgroundRatio,
129
+ uniqueColorCount: uniqueColors.size,
130
+ looksNonBlank: nonBackgroundPixels >= Math.max(1, Math.floor(totalPixels * 0.005)),
131
+ };
132
+ }
133
+ function indexedFrameColour(value) {
134
+ return C64_SCREENSHOT_PALETTE[value & 0x0f] ?? C64_SCREENSHOT_PALETTE[0];
135
+ }
136
+ function rgbaToJimpColour(red, green, blue, alpha = 255) {
137
+ return (((red & 0xff) << 24) | ((green & 0xff) << 16) | ((blue & 0xff) << 8) | (alpha & 0xff)) >>> 0;
138
+ }
139
+ async function writeCapturedFramePng(frame, filePath) {
140
+ const image = new Jimp({ width: frame.width, height: frame.height, color: C64_SCREENSHOT_PALETTE[0] });
141
+ const totalPixels = frame.width * frame.height;
142
+ if (frame.bitsPerPixel === 4 || frame.bitsPerPixel === 8) {
143
+ for (let index = 0; index < totalPixels; index += 1) {
144
+ image.setPixelColor(indexedFrameColour(frame.pixels[index] ?? 0), index % frame.width, Math.floor(index / frame.width));
145
+ }
146
+ await image.write(filePath);
147
+ return;
148
+ }
149
+ const bytesPerPixel = frame.bitsPerPixel >= 24 && frame.bitsPerPixel % 8 === 0
150
+ ? frame.bitsPerPixel / 8
151
+ : 0;
152
+ for (let index = 0; index < totalPixels; index += 1) {
153
+ const x = index % frame.width;
154
+ const y = Math.floor(index / frame.width);
155
+ if (bytesPerPixel === 3 || bytesPerPixel === 4) {
156
+ const offset = index * bytesPerPixel;
157
+ image.setPixelColor(rgbaToJimpColour(frame.pixels[offset] ?? 0, frame.pixels[offset + 1] ?? 0, frame.pixels[offset + 2] ?? 0, bytesPerPixel === 4 ? (frame.pixels[offset + 3] ?? 255) : 255), x, y);
158
+ continue;
159
+ }
160
+ const value = frame.pixels[index] ?? 0;
161
+ image.setPixelColor(rgbaToJimpColour(value, value, value), x, y);
162
+ }
163
+ await image.write(filePath);
164
+ }
165
+ async function waitForGreetingScreen(ctx, expectedText, timeoutMs, pollIntervalMs) {
166
+ const deadline = Date.now() + timeoutMs;
167
+ let lastScreen = "";
168
+ while (Date.now() <= deadline) {
169
+ lastScreen = await ctx.client.readScreen();
170
+ if (screenContainsExpectedText(lastScreen, expectedText)) {
171
+ return { matched: true, screen: lastScreen };
172
+ }
173
+ if (Date.now() + pollIntervalMs > deadline) {
174
+ break;
175
+ }
176
+ await sleep(pollIntervalMs);
177
+ }
178
+ return { matched: false, screen: lastScreen };
179
+ }
8
180
  const programShuffleArgsSchema = objectSchema({
9
181
  description: "Discover PRG/CRT files under root path, run each for a duration, capture screen, then reset.",
10
182
  properties: {
@@ -49,7 +221,308 @@ const batchRunWithAssertionsArgsSchema = objectSchema({
49
221
  required: ["programs"],
50
222
  additionalProperties: false,
51
223
  });
224
+ const crossPlatformGreetingArgsSchema = objectSchema({
225
+ description: "Show a platform-customized greeting on one or more backends, capture screenshots, and verify the result.",
226
+ properties: {
227
+ platforms: optionalSchema(arraySchema(stringSchema({
228
+ description: "Backends to target in sequence.",
229
+ enum: ["vice", "c64u"],
230
+ minLength: 1,
231
+ })), ["vice", "c64u"]),
232
+ messageTemplate: optionalSchema(stringSchema({
233
+ description: "Greeting template. Use {PLATFORM}/{BACKEND} for uppercase or {platform}/{backend} for lowercase substitution.",
234
+ minLength: 1,
235
+ }), "HAVE A GREAT DAY, {PLATFORM}!"),
236
+ verify: optionalSchema(booleanSchema({
237
+ description: "Poll the text screen and require the rendered greeting to appear.",
238
+ default: true,
239
+ }), true),
240
+ captureScreenshot: optionalSchema(booleanSchema({
241
+ description: "Capture a framebuffer screenshot and save it as a PNG per backend.",
242
+ default: true,
243
+ }), true),
244
+ outputPath: optionalSchema(stringSchema({
245
+ description: "Directory for screenshots and the workflow summary JSON.",
246
+ minLength: 1,
247
+ })),
248
+ restoreActiveBackend: optionalSchema(booleanSchema({
249
+ description: "Restore the backend that was active before the workflow started.",
250
+ default: true,
251
+ }), true),
252
+ timeoutMs: optionalSchema(numberSchema({
253
+ description: "Maximum time to wait for each greeting to appear on screen.",
254
+ integer: true,
255
+ minimum: 100,
256
+ maximum: 5000,
257
+ default: 1500,
258
+ }), 1500),
259
+ pollIntervalMs: optionalSchema(numberSchema({
260
+ description: "Delay between screen polls while verifying the greeting.",
261
+ integer: true,
262
+ minimum: 50,
263
+ maximum: 1000,
264
+ default: 100,
265
+ }), 100),
266
+ },
267
+ required: [],
268
+ additionalProperties: false,
269
+ });
52
270
  export const tools = [
271
+ {
272
+ name: "cross_platform_greeting",
273
+ description: "Render a customized greeting on VICE and/or C64U, capture screenshots, and verify both results in one workflow.",
274
+ summary: "One-call cross-platform greeting demo with backend switching, screenshot capture, and text-screen verification.",
275
+ inputSchema: crossPlatformGreetingArgsSchema.jsonSchema,
276
+ tags: ["orchestration", "demo", "screenshots", "verification"],
277
+ examples: [
278
+ {
279
+ name: "Greet both backends",
280
+ description: "Show a platform-specific greeting on VICE and C64U in one call.",
281
+ arguments: { op: "cross_platform_greeting" },
282
+ },
283
+ {
284
+ name: "Custom template on VICE",
285
+ description: "Render a custom greeting only on the emulator.",
286
+ arguments: {
287
+ op: "cross_platform_greeting",
288
+ platforms: ["vice"],
289
+ messageTemplate: "HELLO FROM {PLATFORM}!",
290
+ },
291
+ },
292
+ ],
293
+ async execute(args, ctx) {
294
+ try {
295
+ const toolStartedAt = Date.now();
296
+ const parsed = crossPlatformGreetingArgsSchema.parse(args ?? {});
297
+ const availableBackends = uniqueGreetingBackends(canonicalGreetingBackends().filter((backend) => ctx.client.getAvailableBackends().includes(backend)));
298
+ const requestedBackends = uniqueGreetingBackends((parsed.platforms ?? availableBackends));
299
+ const missingBackends = requestedBackends.filter((backend) => !availableBackends.includes(backend));
300
+ if (missingBackends.length > 0) {
301
+ throw new ToolExecutionError("Requested greeting backends are not configured", {
302
+ details: {
303
+ requestedBackends,
304
+ availableBackends,
305
+ missingBackends,
306
+ },
307
+ });
308
+ }
309
+ if (requestedBackends.length === 0) {
310
+ throw new ToolExecutionError("No configured backends are available for the greeting workflow", {
311
+ details: { availableBackends },
312
+ });
313
+ }
314
+ const visibleViceFastPath = shouldUseVisibleViceGreetingFastPath(args ?? {}, requestedBackends);
315
+ const verify = visibleViceFastPath ? false : parsed.verify !== false;
316
+ const captureScreenshot = visibleViceFastPath ? false : parsed.captureScreenshot !== false;
317
+ const restoreActiveBackend = parsed.restoreActiveBackend !== false;
318
+ const outputPath = captureScreenshot || parsed.outputPath
319
+ ? resolvePath(String(parsed.outputPath ?? joinPath(process.cwd(), "artifacts", "greetings", `run_${Date.now()}`)))
320
+ : undefined;
321
+ const timeoutMs = parsed.timeoutMs ?? 1500;
322
+ const pollIntervalMs = parsed.pollIntervalMs ?? 100;
323
+ const template = parsed.messageTemplate ?? "HAVE A GREAT DAY, {PLATFORM}!";
324
+ const startingBackend = await ctx.client.getActiveBackendType();
325
+ const results = [];
326
+ let restoreError;
327
+ let restoreBackendLatencyMs = 0;
328
+ if (outputPath) {
329
+ await fs.mkdir(outputPath, { recursive: true });
330
+ }
331
+ try {
332
+ for (const backend of requestedBackends) {
333
+ const expectedText = applyGreetingTemplate(template, backend);
334
+ const program = buildGreetingProgram(expectedText);
335
+ const timeline = [];
336
+ let executionMode = "direct_screen_write";
337
+ let preparedCaptureSession = false;
338
+ const client = ctx.client;
339
+ const timedStep = async (name, fn) => {
340
+ const startedAt = Date.now();
341
+ try {
342
+ const result = await withDiagnosticSpan("greeting", name, { backend }, fn);
343
+ timeline.push({ name, latencyMs: Date.now() - startedAt, ok: true });
344
+ return result;
345
+ }
346
+ catch (error) {
347
+ timeline.push({
348
+ name,
349
+ latencyMs: Date.now() - startedAt,
350
+ ok: false,
351
+ error: error instanceof Error ? error.message : String(error),
352
+ });
353
+ throw error;
354
+ }
355
+ };
356
+ await timedStep("switch_backend", async () => {
357
+ ctx.client.switchBackend(backend);
358
+ ctx.setPlatform(backend);
359
+ });
360
+ let prepareCapturePromise;
361
+ if (captureScreenshot && backend === "c64u" && typeof client.prepareVideoCapture === "function") {
362
+ prepareCapturePromise = timedStep("prepare_capture", async () => {
363
+ await client.prepareVideoCapture?.({ keepAliveMs: Math.max(500, timeoutMs) });
364
+ preparedCaptureSession = true;
365
+ }).catch(() => {
366
+ preparedCaptureSession = false;
367
+ });
368
+ }
369
+ const runResult = await timedStep("render_greeting_screen", async () => {
370
+ if (typeof client.renderGreetingScreen === "function") {
371
+ executionMode = "direct_screen_write";
372
+ return client.renderGreetingScreen({ message: expectedText });
373
+ }
374
+ executionMode = "basic_program";
375
+ return ctx.client.uploadAndRunBasic(program);
376
+ });
377
+ if (prepareCapturePromise) {
378
+ await prepareCapturePromise;
379
+ }
380
+ const backendResult = {
381
+ backend,
382
+ expectedText,
383
+ program,
384
+ executionMode,
385
+ timeline,
386
+ };
387
+ if (!runResult.success) {
388
+ if (preparedCaptureSession && typeof client.releaseVideoCapture === "function") {
389
+ try {
390
+ await timedStep("release_capture", () => client.releaseVideoCapture?.());
391
+ }
392
+ catch {
393
+ // Preserve the original render failure and best-effort cleanup.
394
+ }
395
+ }
396
+ backendResult.success = false;
397
+ backendResult.error = "basic_run_failed";
398
+ backendResult.details = runResult.details ?? null;
399
+ results.push(backendResult);
400
+ continue;
401
+ }
402
+ let screen = "";
403
+ let screenMatched = false;
404
+ if (verify) {
405
+ const waited = await timedStep("verify_screen", () => waitForGreetingScreen(ctx, expectedText, timeoutMs, pollIntervalMs));
406
+ screen = waited.screen;
407
+ screenMatched = waited.matched;
408
+ }
409
+ backendResult.screen = screen;
410
+ let screenshotPath;
411
+ let screenshotAnalysis;
412
+ let screenshotError;
413
+ if (captureScreenshot) {
414
+ try {
415
+ const capture = await timedStep("capture_screenshot", () => client.captureFrames({
416
+ count: 1,
417
+ reuseSession: preparedCaptureSession,
418
+ keepAliveMs: preparedCaptureSession ? Math.max(500, timeoutMs) : 0,
419
+ }));
420
+ const frame = capture.frames[0];
421
+ if (!frame) {
422
+ throw new Error("No video frame returned by backend capture");
423
+ }
424
+ await timedStep("persist_screenshot", async () => {
425
+ screenshotAnalysis = analyseCapturedFrame(frame);
426
+ screenshotPath = outputPath
427
+ ? resolvePath(joinPath(outputPath, `${backend}.png`))
428
+ : undefined;
429
+ if (screenshotPath) {
430
+ await writeCapturedFramePng(frame, screenshotPath);
431
+ }
432
+ });
433
+ }
434
+ catch (error) {
435
+ screenshotError = error instanceof Error ? error.message : String(error);
436
+ }
437
+ finally {
438
+ if (preparedCaptureSession && typeof client.releaseVideoCapture === "function") {
439
+ try {
440
+ await timedStep("release_capture", () => client.releaseVideoCapture?.());
441
+ }
442
+ catch {
443
+ // Surface screenshot failures, but do not overwrite a successful capture result.
444
+ }
445
+ }
446
+ }
447
+ }
448
+ backendResult.verification = {
449
+ screenContainsExpectedText: verify ? screenMatched : undefined,
450
+ screenshotCaptured: captureScreenshot ? !screenshotError : undefined,
451
+ screenshotAnalysis,
452
+ };
453
+ if (screenshotPath) {
454
+ backendResult.screenshotPath = screenshotPath;
455
+ }
456
+ if (screenshotError) {
457
+ backendResult.screenshotError = screenshotError;
458
+ }
459
+ backendResult.success = runResult.success
460
+ && (!verify || screenMatched)
461
+ && (!captureScreenshot || !screenshotError);
462
+ backendResult.promptToVisibleLatencyMs = timeline
463
+ .filter((entry) => entry.ok === true && (entry.name === "switch_backend" || entry.name === "render_greeting_screen" || entry.name === "verify_screen"))
464
+ .reduce((total, entry) => total + Number(entry.latencyMs ?? 0), 0);
465
+ backendResult.totalLatencyMs = timeline
466
+ .filter((entry) => entry.ok === true)
467
+ .reduce((total, entry) => total + Number(entry.latencyMs ?? 0), 0);
468
+ results.push(backendResult);
469
+ }
470
+ }
471
+ finally {
472
+ if (restoreActiveBackend) {
473
+ try {
474
+ const restoreStartedAt = Date.now();
475
+ await withDiagnosticSpan("greeting", "restore_backend", { backend: startingBackend }, async () => {
476
+ ctx.client.switchBackend(startingBackend);
477
+ ctx.setPlatform(startingBackend);
478
+ });
479
+ restoreBackendLatencyMs = Date.now() - restoreStartedAt;
480
+ }
481
+ catch (error) {
482
+ restoreError = error instanceof Error ? error.message : String(error);
483
+ }
484
+ }
485
+ }
486
+ const toolCallLatencyMs = Date.now() - toolStartedAt;
487
+ const backendTimedLatencyMs = results
488
+ .map((result) => Number(result.totalLatencyMs ?? 0))
489
+ .reduce((total, latency) => total + latency, 0);
490
+ const orchestrationOverheadMs = Math.max(0, toolCallLatencyMs - backendTimedLatencyMs - restoreBackendLatencyMs);
491
+ const success = results.every((result) => result.success === true) && !restoreError;
492
+ const payload = {
493
+ kind: "cross_platform_greeting",
494
+ availableBackends,
495
+ requestedBackends,
496
+ startingBackend,
497
+ restoredBackend: restoreActiveBackend ? startingBackend : await ctx.client.getActiveBackendType(),
498
+ outputPath: outputPath ?? null,
499
+ fastPath: visibleViceFastPath ? "visible_vice_no_probe" : null,
500
+ toolCallLatencyMs,
501
+ restoreBackendLatencyMs,
502
+ orchestrationOverheadMs,
503
+ results,
504
+ ...(restoreError ? { restoreError } : {}),
505
+ };
506
+ if (outputPath) {
507
+ const reportPath = resolvePath(joinPath(outputPath, "results.json"));
508
+ await fs.writeFile(reportPath, JSON.stringify(payload, null, 2), "utf8");
509
+ payload.outputPath = outputPath;
510
+ payload.reportPath = reportPath;
511
+ }
512
+ const result = jsonResult(payload, {
513
+ success,
514
+ backends: requestedBackends,
515
+ outputPath: outputPath ?? null,
516
+ });
517
+ return success ? result : { ...result, isError: true };
518
+ }
519
+ catch (error) {
520
+ if (error instanceof ToolError)
521
+ return toolErrorResult(error);
522
+ return unknownErrorResult(error);
523
+ }
524
+ },
525
+ },
53
526
  {
54
527
  name: "program_shuffle",
55
528
  description: "Discover and run PRG/CRT programs under a root path, capturing screens and resetting between runs.",
@@ -202,8 +202,10 @@ async function pollAsmOutcome(client, logger, config) {
202
202
  const lowMemAndScreen = await client.readMemoryRaw(0x0000, 0x7E8); // $0000-$07E7
203
203
  // Extract jiffy clock for separate comparison (at offset $00A0 in the buffer)
204
204
  const jiffyClock = lowMemAndScreen.slice(0xA0, 0xA3);
205
- // Concatenate all regions and compute signature
206
- const combinedBuffer = concatBuffers(ioRegions, lowMemAndScreen);
205
+ // Exclude the jiffy clock from the broad signature so it can be tracked separately.
206
+ const lowMemSignatureBuffer = lowMemAndScreen.slice();
207
+ lowMemSignatureBuffer.fill(0, 0xA0, 0xA3);
208
+ const combinedBuffer = concatBuffers(ioRegions, lowMemSignatureBuffer);
207
209
  const ioSignature = computeCrc32(combinedBuffer);
208
210
  // Check for any activity
209
211
  if (prevIoSignature !== null && ioSignature !== prevIoSignature) {
@@ -24,6 +24,8 @@ function toRecord(details) {
24
24
  return { value: details };
25
25
  }
26
26
  const BASIC_MAX_LINE = 63999;
27
+ const prgPathDescription = "Path to the PRG image. Use an Ultimate-visible filesystem path on c64u, or a host-local filesystem path on VICE.";
28
+ const crtPathDescription = "Path to the CRT image on Ultimate-visible storage. CRT mounting is only supported on c64u.";
27
29
  function stripOperationDiscriminator(value) {
28
30
  const { [OPERATION_DISCRIMINATOR]: _ignored, ...rest } = value;
29
31
  return rest;
@@ -213,10 +215,10 @@ const uploadAsmArgsSchema = objectSchema({
213
215
  additionalProperties: false,
214
216
  });
215
217
  const prgFileArgsSchema = objectSchema({
216
- description: "Arguments for loading or running a PRG that already exists on the Ultimate filesystem.",
218
+ description: "Arguments for loading or running a PRG file.",
217
219
  properties: {
218
220
  path: stringSchema({
219
- description: "Absolute or Ultimate filesystem path to the PRG file (e.g. //USB0/demo.prg).",
221
+ description: prgPathDescription,
220
222
  minLength: 1,
221
223
  }),
222
224
  },
@@ -224,10 +226,10 @@ const prgFileArgsSchema = objectSchema({
224
226
  additionalProperties: false,
225
227
  });
226
228
  const crtFileArgsSchema = objectSchema({
227
- description: "Arguments for running a CRT image stored on the Ultimate filesystem.",
229
+ description: "Arguments for running a CRT image stored on Ultimate-visible storage.",
228
230
  properties: {
229
231
  path: stringSchema({
230
- description: "Absolute or Ultimate filesystem path to the CRT file (e.g. //USB0/game.crt).",
232
+ description: crtPathDescription,
231
233
  minLength: 1,
232
234
  }),
233
235
  },
@@ -676,6 +678,7 @@ export const programRunnersModule = defineToolModule({
676
678
  "Stage PRG files without running when the user wants to inspect memory first.",
677
679
  "Confirm the Ultimate filesystem path (e.g. //USB0/demo.prg) is accessible before invoking.",
678
680
  ],
681
+ supportedPlatforms: ["c64u"],
679
682
  async execute(args, ctx) {
680
683
  return executeLoadPrg(args, ctx);
681
684
  },
@@ -723,6 +726,7 @@ export const programRunnersModule = defineToolModule({
723
726
  arguments: { path: "//USB0/game.crt" },
724
727
  },
725
728
  ],
729
+ supportedPlatforms: ["c64u"],
726
730
  async execute(args, ctx) {
727
731
  try {
728
732
  const parsed = crtFileArgsSchema.parse(args ?? {});
@@ -154,6 +154,7 @@ export const configModuleGroup = defineToolModule({
154
154
  resources: ["c64://context/bootstrap", "c64://docs/index"],
155
155
  prompts: ["memory-debug"],
156
156
  defaultTags: ["config", "diagnostics"],
157
+ supportedPlatforms: ["c64u", "vice"],
157
158
  workflowHints: [
158
159
  "List categories before changing values so users can confirm firmware-provided names.",
159
160
  "Mention when operations persist to flash or modify debug registers to highlight impacts.",
@@ -169,6 +170,22 @@ export const configModuleGroup = defineToolModule({
169
170
  variants: configOperations.map((operation) => operation.schema),
170
171
  }),
171
172
  tags: ["config", "diagnostics", "grouped"],
173
+ operationPlatforms: {
174
+ load_flash: ["c64u"],
175
+ save_flash: ["c64u"],
176
+ reset_defaults: ["c64u"],
177
+ read_debugreg: ["c64u"],
178
+ write_debugreg: ["c64u"],
179
+ shuffle: ["c64u"],
180
+ },
181
+ operationToolNames: {
182
+ load_flash: "config_load_from_flash",
183
+ save_flash: "config_save_to_flash",
184
+ reset_defaults: "config_reset_to_default",
185
+ read_debugreg: "debugreg_read",
186
+ write_debugreg: "debugreg_write",
187
+ shuffle: "program_shuffle",
188
+ },
172
189
  examples: [
173
190
  {
174
191
  name: "List categories",
@@ -251,6 +251,7 @@ const diskOperationHandlers = createOperationHandlers(diskOperations);
251
251
  export const diskModuleGroup = defineToolModule({
252
252
  domain: "storage",
253
253
  summary: "Grouped disk image management, mounting, and discovery tools.",
254
+ supportedPlatforms: ["c64u", "vice"],
254
255
  resources: ["c64://context/bootstrap"],
255
256
  prompts: ["drive-management"],
256
257
  defaultTags: ["storage", "drive"],
@@ -268,6 +269,12 @@ export const diskModuleGroup = defineToolModule({
268
269
  variants: diskOperations.map((operation) => operation.schema),
269
270
  }),
270
271
  tags: ["storage", "drive", "grouped"],
272
+ operationPlatforms: { file_info: ["c64u"], create_image: ["c64u"], find_and_run: ["c64u"] },
273
+ operationToolNames: {
274
+ file_info: "file_info",
275
+ create_image: "create_image",
276
+ find_and_run: "find_and_run_program_by_name",
277
+ },
271
278
  examples: [
272
279
  {
273
280
  name: "Mount image with verification",
@@ -33,6 +33,7 @@ const driveOperationHandlers = createOperationHandlers(driveOperations);
33
33
  export const driveModuleGroup = defineToolModule({
34
34
  domain: "drive",
35
35
  summary: "Grouped drive power, reset, ROM, and mode helpers.",
36
+ supportedPlatforms: ["c64u", "vice"],
36
37
  resources: ["c64://context/bootstrap"],
37
38
  defaultTags: ["drive", "hardware"],
38
39
  workflowHints: [
@@ -49,6 +50,8 @@ export const driveModuleGroup = defineToolModule({
49
50
  variants: driveOperations.map((operation) => operation.schema),
50
51
  }),
51
52
  tags: ["drive", "hardware", "grouped"],
53
+ operationPlatforms: { load_rom: ["c64u"] },
54
+ operationToolNames: { load_rom: "drive_load_rom" },
52
55
  examples: [
53
56
  {
54
57
  name: "Power on drive",