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
package/dist/c64Client.js CHANGED
@@ -6,24 +6,127 @@ Licensed under the GNU General Public License v2.0 or later.
6
6
  See <https://www.gnu.org/licenses/> for details.
7
7
  */
8
8
  import { Buffer } from "node:buffer";
9
+ import { createSocket } from "node:dgram";
9
10
  import axios from "axios";
10
11
  import { basicToPrg } from "./basicConverter.js";
11
12
  import { assemblyToPrg } from "./assemblyConverter.js";
12
13
  import { screenCodesToAscii } from "./petscii.js";
13
14
  import { resolveAddressSymbol } from "./knowledge.js";
14
- import { createFacade } from "./device.js";
15
+ import { createAllFacades, createFacade } from "./device.js";
15
16
  import { Api } from "../generated/c64/index.js";
16
17
  import { createLoggingHttpClient } from "./loggingHttpClient.js";
18
+ import { withDiagnosticSpan, writeDiagnosticEvent } from "./diagnostics.js";
19
+ import { C64U_NTSC_AUDIO_SAMPLE_RATE, C64U_PAL_AUDIO_SAMPLE_RATE, collectCompleteVideoFrames, parseAudioPacket, parseVideoPacket, } from "./streamCapture.js";
20
+ import { buildVicBitmapRegisters, resolveVicBitmapMemoryLayout, } from "./vicBitmap.js";
21
+ const TEXT_SCREEN_COLUMNS = 40;
22
+ const TEXT_SCREEN_ROWS = 25;
23
+ const TEXT_SCREEN_SIZE = TEXT_SCREEN_COLUMNS * TEXT_SCREEN_ROWS;
24
+ const TEXT_SCREEN_ADDRESS = 0x0400;
25
+ const TEXT_COLOR_RAM_ADDRESS = 0xD800;
26
+ const SPRITE_DATA_BASE_ADDRESS = 0x2000;
27
+ const DEFAULT_TEXT_FOREGROUND = 1;
28
+ const DEFAULT_BORDER_COLOR = 6;
29
+ const DEFAULT_BACKGROUND_COLOR = 0;
30
+ const SPACE_SCREEN_CODE = 0x20;
17
31
  export class C64Client {
18
32
  constructor(baseUrl, options = {}) {
33
+ this.allFacades = new Map();
34
+ this.warmupPromises = new Map();
35
+ this.greetingDd00Cache = new Map();
36
+ this.greetingDd00Warmups = new Map();
37
+ this.c64uVideoCaptureSession = null;
38
+ this.activeType = "c64u";
39
+ this.baseUrl = baseUrl;
19
40
  const headers = options.networkPassword ? { "X-Password": options.networkPassword } : undefined;
20
41
  this.http = createLoggingHttpClient({ baseURL: baseUrl, timeout: 10000, headers });
21
42
  this.api = new Api(this.http);
22
- // Select backend once lazily; keep REST for hardware-specific fallbacks
23
- this.facadePromise = createFacade(undefined, {
43
+ const forceC64uFacade = options.forceC64uFacade ?? true;
44
+ if (forceC64uFacade) {
45
+ this.facadePromise = createFacade(undefined, {
46
+ preferredC64uBaseUrl: baseUrl,
47
+ preferredC64uNetworkPassword: options.networkPassword,
48
+ }).then((sel) => sel.facade);
49
+ this.allFacades.set("c64u", this.facadePromise);
50
+ this.initPromise = Promise.resolve();
51
+ void this.primeGreetingDd00Cache(["c64u"]);
52
+ return;
53
+ }
54
+ const allFacadesPromise = createAllFacades(undefined, {
24
55
  preferredC64uBaseUrl: baseUrl,
25
56
  preferredC64uNetworkPassword: options.networkPassword,
26
- }).then((sel) => sel.facade);
57
+ });
58
+ this.facadePromise = allFacadesPromise.then(({ primary }) => primary.facade);
59
+ this.initPromise = allFacadesPromise.then(({ primary, secondary, secondaryType }) => {
60
+ const primaryPromise = Promise.resolve(primary.facade);
61
+ this.activeType = primary.selected;
62
+ this.facadePromise = primaryPromise;
63
+ this.allFacades.set(primary.selected, primaryPromise);
64
+ if (secondary && secondaryType) {
65
+ this.allFacades.set(secondaryType, Promise.resolve(secondary));
66
+ }
67
+ });
68
+ void this.initPromise.then(async () => {
69
+ if (this.allFacades.has("vice")) {
70
+ await this.prewarmBackends(["vice"]);
71
+ }
72
+ void this.primeGreetingDd00Cache();
73
+ }).catch(() => { });
74
+ }
75
+ async requireViceBackend() {
76
+ const facade = await this.facadePromise;
77
+ if (facade.type !== "vice") {
78
+ throw new Error("VICE-specific operation requested while connected to Ultimate hardware");
79
+ }
80
+ return facade;
81
+ }
82
+ async getBackendType() {
83
+ return this.getActiveBackendType();
84
+ }
85
+ async getActiveBackendType() {
86
+ await this.initPromise;
87
+ return this.activeType;
88
+ }
89
+ getAvailableBackends() {
90
+ return Array.from(this.allFacades.keys());
91
+ }
92
+ async prewarmBackends(types) {
93
+ return withDiagnosticSpan("client", "prewarm_backends", { types: types ?? null }, async () => {
94
+ await this.initPromise;
95
+ const targets = (types && types.length > 0 ? [...types] : this.getAvailableBackends()).filter((type, index, all) => all.indexOf(type) === index);
96
+ const entries = await Promise.all(targets.map(async (type) => {
97
+ let warmup = this.warmupPromises.get(type);
98
+ if (!warmup) {
99
+ const facadePromise = this.allFacades.get(type);
100
+ if (!facadePromise) {
101
+ throw new Error(`Backend '${type}' is not configured`);
102
+ }
103
+ warmup = facadePromise.then((facade) => facade.ping()).catch(() => false);
104
+ this.warmupPromises.set(type, warmup);
105
+ }
106
+ return [type, await warmup];
107
+ }));
108
+ return Object.fromEntries(entries);
109
+ });
110
+ }
111
+ switchBackend(type) {
112
+ const nextFacade = this.allFacades.get(type);
113
+ if (!nextFacade) {
114
+ throw new Error(`Backend '${type}' is not configured`);
115
+ }
116
+ // Platform state is owned by the caller so tool-level flows can update global routing explicitly.
117
+ this.facadePromise = nextFacade;
118
+ this.activeType = type;
119
+ if (type !== "c64u" && this.c64uVideoCaptureSession) {
120
+ void this.releaseVideoCapture().catch(() => { });
121
+ }
122
+ writeDiagnosticEvent("client_switch_backend", { backend: type });
123
+ }
124
+ async shouldUseC64uMockBypass() {
125
+ return process.env.C64_TEST_TARGET === "mock" && (await this.getBackendType()) === "c64u";
126
+ }
127
+ async withViceMonitor(fn) {
128
+ const backend = await this.requireViceBackend();
129
+ return backend.withMonitor(fn);
27
130
  }
28
131
  /**
29
132
  * Generate a BASIC program that opens the printer (device 4), prints the provided text,
@@ -57,16 +160,16 @@ export class C64Client {
57
160
  return this.uploadAndRunBasic(program);
58
161
  }
59
162
  async uploadAndRunBasic(program) {
60
- const prg = basicToPrg(program);
61
- return this.runPrg(prg);
163
+ return withDiagnosticSpan("client", "upload_run_basic", { programLength: program.length }, async () => {
164
+ const prg = basicToPrg(program);
165
+ return this.runPrg(prg);
166
+ });
62
167
  }
63
168
  /**
64
- * Build a simple sprite PRG from raw 63-byte sprite data and position/color attributes.
65
- * Returns the REST result after uploading and running the generated PRG.
169
+ * Render a sprite directly by writing sprite data, screen memory, and VIC-II registers.
66
170
  */
67
171
  async generateAndRunSpritePrg(options) {
68
- const prg = buildSingleSpriteProgram(options);
69
- return this.runPrg(prg);
172
+ return this.displaySprite(options);
70
173
  }
71
174
  /**
72
175
  * Build a BASIC program that draws a PETSCII screen (optionally set border/bg colours),
@@ -76,13 +179,192 @@ export class C64Client {
76
179
  const program = buildPetsciiScreenBasic(options);
77
180
  return this.uploadAndRunBasic(program);
78
181
  }
182
+ async renderGreetingScreen(options) {
183
+ return withDiagnosticSpan("client", "render_greeting_screen", { messageLength: options.message.length }, async () => {
184
+ try {
185
+ const facade = await this.facadePromise;
186
+ const currentDd00 = await this.getGreetingDd00(facade.type, facade);
187
+ const textRegisters = buildVicTextRegisters({
188
+ currentDd00,
189
+ borderColor: options.borderColor ?? DEFAULT_BORDER_COLOR,
190
+ backgroundColor: options.backgroundColor ?? DEFAULT_BACKGROUND_COLOR,
191
+ });
192
+ const screenRam = buildGreetingScreenRam(options.message);
193
+ const colorRam = new Uint8Array(TEXT_SCREEN_SIZE).fill(DEFAULT_TEXT_FOREGROUND);
194
+ const writes = [
195
+ { address: TEXT_SCREEN_ADDRESS, bytes: screenRam },
196
+ { address: TEXT_COLOR_RAM_ADDRESS, bytes: colorRam },
197
+ { address: 0xDD00, bytes: Uint8Array.of(textRegisters.dd00) },
198
+ { address: 0xD011, bytes: Uint8Array.of(textRegisters.d011) },
199
+ { address: 0xD016, bytes: Uint8Array.of(textRegisters.d016) },
200
+ { address: 0xD018, bytes: Uint8Array.of(textRegisters.d018) },
201
+ { address: 0xD020, bytes: Uint8Array.of(textRegisters.d020) },
202
+ { address: 0xD021, bytes: Uint8Array.of(textRegisters.d021) },
203
+ ];
204
+ if (typeof facade.writeMemoryBlocks === "function") {
205
+ await facade.writeMemoryBlocks(writes);
206
+ }
207
+ else {
208
+ for (const write of writes) {
209
+ await facade.writeMemory(write.address, write.bytes);
210
+ }
211
+ }
212
+ return {
213
+ success: true,
214
+ details: {
215
+ mode: "direct_screen_write",
216
+ screenAddress: this.formatAddress(TEXT_SCREEN_ADDRESS),
217
+ colorRamAddress: this.formatAddress(TEXT_COLOR_RAM_ADDRESS),
218
+ registers: textRegisters,
219
+ message: options.message,
220
+ },
221
+ };
222
+ }
223
+ catch (error) {
224
+ return {
225
+ success: false,
226
+ details: this.normaliseError(error),
227
+ };
228
+ }
229
+ });
230
+ }
231
+ async displaySprite(options) {
232
+ try {
233
+ const index = Math.max(0, Math.min(7, Math.floor(options.spriteIndex ?? 0)));
234
+ const x = Math.max(0, Math.min(511, Math.floor(options.x ?? 100)));
235
+ const y = Math.max(0, Math.min(255, Math.floor(options.y ?? 100)));
236
+ const color = normaliseColorNibble(options.color ?? 1);
237
+ const multicolour = options.multicolour === true;
238
+ const spriteBytes = Buffer.from(options.spriteBytes);
239
+ if (spriteBytes.length !== 63) {
240
+ throw new Error("spriteBytes must be exactly 63 bytes");
241
+ }
242
+ const spriteSlot = Buffer.alloc(64, 0x00);
243
+ spriteBytes.copy(spriteSlot, 0, 0, 63);
244
+ const spriteAddress = SPRITE_DATA_BASE_ADDRESS + index * 0x40;
245
+ const pointerAddress = TEXT_SCREEN_ADDRESS + 0x03F8 + index;
246
+ const pointerValue = (spriteAddress & 0x3FFF) >> 6;
247
+ const bitMask = 1 << index;
248
+ const screenRam = new Uint8Array(TEXT_SCREEN_SIZE).fill(SPACE_SCREEN_CODE);
249
+ screenRam[0x03F8 + index] = pointerValue & 0xFF;
250
+ const colorRam = new Uint8Array(TEXT_SCREEN_SIZE).fill(DEFAULT_TEXT_FOREGROUND);
251
+ const facade = await this.facadePromise;
252
+ const currentDd00 = await this.readByteOrDefault(facade, 0xDD00, 0);
253
+ const currentBorder = await this.readByteOrDefault(facade, 0xD020, DEFAULT_BORDER_COLOR);
254
+ const currentBackground = await this.readByteOrDefault(facade, 0xD021, DEFAULT_BACKGROUND_COLOR);
255
+ const currentD010 = await this.readByteOrDefault(facade, 0xD010, 0);
256
+ const currentD015 = await this.readByteOrDefault(facade, 0xD015, 0);
257
+ const currentD01C = await this.readByteOrDefault(facade, 0xD01C, 0);
258
+ const textRegisters = buildVicTextRegisters({
259
+ currentDd00,
260
+ borderColor: currentBorder,
261
+ backgroundColor: currentBackground,
262
+ });
263
+ const d010 = (currentD010 & ~bitMask) | (x > 0xFF ? bitMask : 0);
264
+ const d015 = currentD015 | bitMask;
265
+ const d01c = (currentD01C & ~bitMask) | (multicolour ? bitMask : 0);
266
+ await facade.writeMemory(spriteAddress, spriteSlot);
267
+ await facade.writeMemory(TEXT_SCREEN_ADDRESS, screenRam);
268
+ await facade.writeMemory(TEXT_COLOR_RAM_ADDRESS, colorRam);
269
+ await facade.writeMemory(0xDD00, Uint8Array.of(textRegisters.dd00));
270
+ await facade.writeMemory(0xD011, Uint8Array.of(textRegisters.d011));
271
+ await facade.writeMemory(0xD016, Uint8Array.of(textRegisters.d016));
272
+ await facade.writeMemory(0xD018, Uint8Array.of(textRegisters.d018));
273
+ await facade.writeMemory(0xD020, Uint8Array.of(textRegisters.d020));
274
+ await facade.writeMemory(0xD021, Uint8Array.of(textRegisters.d021));
275
+ await facade.writeMemory(0xD000 + index * 2, Uint8Array.of(x & 0xFF));
276
+ await facade.writeMemory(0xD001 + index * 2, Uint8Array.of(y & 0xFF));
277
+ await facade.writeMemory(0xD010, Uint8Array.of(d010));
278
+ await facade.writeMemory(0xD015, Uint8Array.of(d015));
279
+ await facade.writeMemory(0xD01C, Uint8Array.of(d01c));
280
+ await facade.writeMemory(0xD027 + index, Uint8Array.of(color));
281
+ return {
282
+ success: true,
283
+ details: {
284
+ bank: 0,
285
+ spriteAddress: this.formatAddress(spriteAddress),
286
+ screenAddress: this.formatAddress(TEXT_SCREEN_ADDRESS),
287
+ colorRamAddress: this.formatAddress(TEXT_COLOR_RAM_ADDRESS),
288
+ pointerAddress: this.formatAddress(pointerAddress),
289
+ pointerValue,
290
+ registers: {
291
+ ...textRegisters,
292
+ d010,
293
+ d015,
294
+ d01c,
295
+ },
296
+ index,
297
+ x,
298
+ y,
299
+ color,
300
+ multicolour,
301
+ spriteByteLength: spriteBytes.length,
302
+ },
303
+ };
304
+ }
305
+ catch (error) {
306
+ return {
307
+ success: false,
308
+ details: this.normaliseError(error),
309
+ };
310
+ }
311
+ }
312
+ async displayBitmap(bitmap, options) {
313
+ try {
314
+ const layout = resolveVicBitmapMemoryLayout(options?.bitmapAddress ?? 0x2000, options?.screenAddress ?? 0x0400);
315
+ const facade = await this.facadePromise;
316
+ await facade.writeMemory(layout.bitmapAddress, bitmap.bitmapData);
317
+ await facade.writeMemory(layout.screenAddress, bitmap.screenRam);
318
+ await facade.writeMemory(layout.colorRamAddress, bitmap.colorRam);
319
+ let currentDd00 = 0;
320
+ try {
321
+ const dd00 = await facade.readMemory(0xDD00, 1);
322
+ currentDd00 = dd00[0] ?? 0;
323
+ }
324
+ catch {
325
+ currentDd00 = 0;
326
+ }
327
+ const registers = buildVicBitmapRegisters(layout, {
328
+ mode: bitmap.mode,
329
+ backgroundColor: bitmap.backgroundColor,
330
+ borderColor: bitmap.borderColor,
331
+ currentDd00,
332
+ });
333
+ await facade.writeMemory(0xDD00, Uint8Array.of(registers.dd00));
334
+ await facade.writeMemory(0xD011, Uint8Array.of(registers.d011));
335
+ await facade.writeMemory(0xD016, Uint8Array.of(registers.d016));
336
+ await facade.writeMemory(0xD018, Uint8Array.of(registers.d018));
337
+ await facade.writeMemory(0xD020, Uint8Array.of(registers.d020));
338
+ await facade.writeMemory(0xD021, Uint8Array.of(registers.d021));
339
+ return {
340
+ success: true,
341
+ details: {
342
+ mode: bitmap.mode,
343
+ bank: layout.bank,
344
+ bitmapAddress: this.formatAddress(layout.bitmapAddress),
345
+ screenAddress: this.formatAddress(layout.screenAddress),
346
+ colorRamAddress: this.formatAddress(layout.colorRamAddress),
347
+ registers,
348
+ bitmapBytes: bitmap.bitmapData.length,
349
+ screenBytes: bitmap.screenRam.length,
350
+ colorRamBytes: bitmap.colorRam.length,
351
+ },
352
+ };
353
+ }
354
+ catch (error) {
355
+ return {
356
+ success: false,
357
+ details: this.normaliseError(error),
358
+ };
359
+ }
360
+ }
79
361
  async uploadAndRunAsm(program) {
80
362
  const prg = assemblyToPrg(program);
81
363
  return this.runPrg(prg);
82
364
  }
83
365
  async runPrg(prg) {
84
366
  try {
85
- if (process.env.C64_TEST_TARGET === "mock") {
367
+ if (await this.shouldUseC64uMockBypass()) {
86
368
  const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
87
369
  const response = await this.api.v1.runnersRunPrgCreate(":run_prg", payload, {
88
370
  headers: { "Content-Type": "application/octet-stream" },
@@ -161,13 +443,14 @@ export class C64Client {
161
443
  }
162
444
  }
163
445
  async readScreen() {
164
- // Exposed as MCP tool: read_screen
165
- const bytes = await this.readMemoryRaw(0x0400, 0x03e8);
166
- return screenCodesToAscii(bytes, { columns: 40, rows: 25 });
446
+ return withDiagnosticSpan("client", "read_screen", {}, async () => {
447
+ const bytes = await this.readMemoryRaw(0x0400, 0x03e8);
448
+ return screenCodesToAscii(bytes, { columns: 40, rows: 25 });
449
+ });
167
450
  }
168
451
  async reset() {
169
452
  try {
170
- if (process.env.C64_TEST_TARGET === "mock") {
453
+ if (await this.shouldUseC64uMockBypass()) {
171
454
  const response = await this.api.v1.machineResetUpdate(":reset");
172
455
  return { success: true, details: response.data };
173
456
  }
@@ -181,7 +464,7 @@ export class C64Client {
181
464
  }
182
465
  async reboot() {
183
466
  try {
184
- if (process.env.C64_TEST_TARGET === "mock") {
467
+ if (await this.shouldUseC64uMockBypass()) {
185
468
  const response = await this.api.v1.machineRebootUpdate(":reboot");
186
469
  return { success: true, details: response.data };
187
470
  }
@@ -360,16 +643,16 @@ export class C64Client {
360
643
  }
361
644
  // --- Additional API wrappers to cover full REST surface ---
362
645
  async version() {
363
- const res = await this.api.v1.versionList();
364
- return res.data;
646
+ const facade = await this.facadePromise;
647
+ return facade.version();
365
648
  }
366
649
  async info() {
367
- const res = await this.api.v1.infoList();
368
- return res.data;
650
+ const facade = await this.facadePromise;
651
+ return facade.info();
369
652
  }
370
653
  async pause() {
371
654
  try {
372
- if (process.env.C64_TEST_TARGET === "mock") {
655
+ if (await this.shouldUseC64uMockBypass()) {
373
656
  const res = await this.api.v1.machinePauseUpdate(":pause");
374
657
  return { success: true, details: res.data };
375
658
  }
@@ -382,7 +665,7 @@ export class C64Client {
382
665
  }
383
666
  async resume() {
384
667
  try {
385
- if (process.env.C64_TEST_TARGET === "mock") {
668
+ if (await this.shouldUseC64uMockBypass()) {
386
669
  const res = await this.api.v1.machineResumeUpdate(":resume");
387
670
  return { success: true, details: res.data };
388
671
  }
@@ -404,7 +687,7 @@ export class C64Client {
404
687
  }
405
688
  async menuButton() {
406
689
  try {
407
- if (process.env.C64_TEST_TARGET === "mock") {
690
+ if (await this.shouldUseC64uMockBypass()) {
408
691
  const res = await this.api.v1.machineMenuButtonUpdate(":menu_button");
409
692
  return { success: true, details: res.data };
410
693
  }
@@ -417,7 +700,7 @@ export class C64Client {
417
700
  }
418
701
  async debugregRead() {
419
702
  try {
420
- if (process.env.C64_TEST_TARGET === "mock") {
703
+ if (await this.shouldUseC64uMockBypass()) {
421
704
  const res = await this.api.v1.machineDebugregList(":debugreg");
422
705
  return { success: true, value: res.data.value, details: res.data };
423
706
  }
@@ -430,7 +713,7 @@ export class C64Client {
430
713
  }
431
714
  async debugregWrite(value) {
432
715
  try {
433
- if (process.env.C64_TEST_TARGET === "mock") {
716
+ if (await this.shouldUseC64uMockBypass()) {
434
717
  const res = await this.api.v1.machineDebugregUpdate(":debugreg", { value });
435
718
  return { success: true, value: res.data.value, details: res.data };
436
719
  }
@@ -494,7 +777,7 @@ export class C64Client {
494
777
  try {
495
778
  if (!drive || !path)
496
779
  throw new Error("Drive and path are required");
497
- if (process.env.C64_TEST_TARGET === "mock") {
780
+ if (await this.shouldUseC64uMockBypass()) {
498
781
  const res = await this.api.v1.drivesLoadRomUpdate(drive, ":load_rom", { file: path });
499
782
  return { success: true, details: res.data };
500
783
  }
@@ -533,6 +816,39 @@ export class C64Client {
533
816
  return { success: false, details: this.normaliseError(error) };
534
817
  }
535
818
  }
819
+ async prepareVideoCapture(options) {
820
+ const keepAliveMs = Math.max(0, Math.trunc(options?.keepAliveMs ?? 1000));
821
+ const facade = await this.facadePromise;
822
+ if (facade.type !== "c64u") {
823
+ throw new Error("Reusable video capture is only available on C64 Ultimate");
824
+ }
825
+ await this.ensureC64uVideoCaptureSession(facade);
826
+ this.scheduleC64uVideoCaptureStop(keepAliveMs);
827
+ }
828
+ async releaseVideoCapture() {
829
+ await this.stopC64uVideoCaptureSession();
830
+ }
831
+ async captureFrames(options) {
832
+ return withDiagnosticSpan("client", "capture_frames", { count: options?.count ?? 1, reuseSession: options?.reuseSession === true }, async () => {
833
+ const requestedCount = Math.max(1, Math.min(32, Math.trunc(options?.count ?? 1)));
834
+ const facade = await this.facadePromise;
835
+ if (facade.type === "vice") {
836
+ return this.captureViceFrames(requestedCount);
837
+ }
838
+ return this.captureC64uVideoFrames(facade, requestedCount, {
839
+ reuseSession: options?.reuseSession === true,
840
+ keepAliveMs: Math.max(0, Math.trunc(options?.keepAliveMs ?? 0)),
841
+ });
842
+ });
843
+ }
844
+ async captureSamples(options) {
845
+ const requestedPairs = Math.max(1, Math.min(65536, Math.trunc(options?.count ?? 256)));
846
+ const facade = await this.facadePromise;
847
+ if (facade.type !== "c64u") {
848
+ throw new Error("Audio sample capture is only available on C64 Ultimate");
849
+ }
850
+ return this.captureC64uAudioSamples(facade, requestedPairs);
851
+ }
536
852
  async configsList() {
537
853
  const facade = await this.facadePromise;
538
854
  return facade.configsList();
@@ -667,6 +983,484 @@ export class C64Client {
667
983
  }
668
984
  return new Uint8Array();
669
985
  }
986
+ async captureC64uVideoFrames(facade, count, options = {}) {
987
+ if (options.reuseSession) {
988
+ const session = await this.ensureC64uVideoCaptureSession(facade);
989
+ const frames = await this.collectFramesFromC64uVideoCaptureSession(session, count, { fresh: true });
990
+ this.scheduleC64uVideoCaptureStop(options.keepAliveMs ?? 0);
991
+ return { backend: "c64u", frames };
992
+ }
993
+ const host = new URL(this.baseUrl).hostname;
994
+ const bindAddress = await this.resolveLocalCaptureAddress(host);
995
+ const socket = createSocket("udp4");
996
+ const packets = [];
997
+ let stopError = null;
998
+ try {
999
+ await this.bindCaptureSocket(socket, bindAddress);
1000
+ const target = this.socketEndpoint(socket, bindAddress);
1001
+ await this.ensureStreamSuccess(await facade.streamStart("video", target), "start video stream");
1002
+ const frames = await new Promise((resolve, reject) => {
1003
+ const timeoutMs = Math.max(1500, count * 750);
1004
+ const timer = setTimeout(() => {
1005
+ reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${count} video frame(s)`));
1006
+ }, timeoutMs);
1007
+ socket.on("message", (msg) => {
1008
+ try {
1009
+ const packet = this.parseVideoPacketOrNull(Buffer.from(msg));
1010
+ if (!packet) {
1011
+ return;
1012
+ }
1013
+ packets.push(packet);
1014
+ const frames = collectCompleteVideoFrames(packets, count);
1015
+ if (frames.length >= count) {
1016
+ clearTimeout(timer);
1017
+ resolve(frames);
1018
+ }
1019
+ }
1020
+ catch (error) {
1021
+ clearTimeout(timer);
1022
+ reject(error);
1023
+ }
1024
+ });
1025
+ socket.once("error", (error) => {
1026
+ clearTimeout(timer);
1027
+ reject(error);
1028
+ });
1029
+ });
1030
+ return { backend: "c64u", frames };
1031
+ }
1032
+ finally {
1033
+ try {
1034
+ await this.ensureStreamSuccess(await facade.streamStop("video"), "stop video stream");
1035
+ }
1036
+ catch (error) {
1037
+ stopError = error instanceof Error ? error : new Error(String(error));
1038
+ }
1039
+ socket.close();
1040
+ if (stopError) {
1041
+ throw stopError;
1042
+ }
1043
+ }
1044
+ }
1045
+ async ensureC64uVideoCaptureSession(facade) {
1046
+ const existing = this.c64uVideoCaptureSession;
1047
+ if (existing && !existing.closed && existing.facade === facade) {
1048
+ if (existing.stopTimer) {
1049
+ clearTimeout(existing.stopTimer);
1050
+ existing.stopTimer = null;
1051
+ }
1052
+ if (existing.error) {
1053
+ throw existing.error;
1054
+ }
1055
+ return existing;
1056
+ }
1057
+ await this.stopC64uVideoCaptureSession();
1058
+ const host = new URL(this.baseUrl).hostname;
1059
+ const bindAddress = await this.resolveLocalCaptureAddress(host);
1060
+ const socket = createSocket("udp4");
1061
+ await this.bindCaptureSocket(socket, bindAddress);
1062
+ const target = this.socketEndpoint(socket, bindAddress);
1063
+ const session = {
1064
+ facade,
1065
+ socket,
1066
+ bindAddress,
1067
+ target,
1068
+ packets: [],
1069
+ waiters: [],
1070
+ stopTimer: null,
1071
+ closed: false,
1072
+ error: null,
1073
+ };
1074
+ socket.on("message", (msg) => {
1075
+ if (session.closed) {
1076
+ return;
1077
+ }
1078
+ const packet = this.parseVideoPacketOrNull(Buffer.from(msg));
1079
+ if (!packet) {
1080
+ return;
1081
+ }
1082
+ session.packets.push(packet);
1083
+ this.flushC64uVideoCaptureWaiters(session);
1084
+ });
1085
+ socket.on("error", (error) => {
1086
+ const err = error instanceof Error ? error : new Error(String(error));
1087
+ session.error = err;
1088
+ this.rejectC64uVideoCaptureWaiters(session, err);
1089
+ });
1090
+ try {
1091
+ await this.ensureStreamSuccess(await facade.streamStart("video", target), "start video stream");
1092
+ }
1093
+ catch (error) {
1094
+ session.closed = true;
1095
+ socket.close();
1096
+ throw error;
1097
+ }
1098
+ this.c64uVideoCaptureSession = session;
1099
+ return session;
1100
+ }
1101
+ async collectFramesFromC64uVideoCaptureSession(session, count, options = {}) {
1102
+ if (session.error) {
1103
+ throw session.error;
1104
+ }
1105
+ const startIndex = options.fresh ? session.packets.length : 0;
1106
+ const existingFrames = collectCompleteVideoFrames(session.packets.slice(startIndex), count);
1107
+ if (existingFrames.length >= count) {
1108
+ return existingFrames;
1109
+ }
1110
+ return await new Promise((resolve, reject) => {
1111
+ const timeoutMs = Math.max(1500, count * 750);
1112
+ const timer = setTimeout(() => {
1113
+ const waiterIndex = session.waiters.indexOf(waiter);
1114
+ if (waiterIndex >= 0) {
1115
+ session.waiters.splice(waiterIndex, 1);
1116
+ }
1117
+ reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${count} video frame(s)`));
1118
+ }, timeoutMs);
1119
+ const waiter = {
1120
+ count,
1121
+ startIndex,
1122
+ resolve: (frames) => {
1123
+ clearTimeout(timer);
1124
+ resolve(frames);
1125
+ },
1126
+ reject: (error) => {
1127
+ clearTimeout(timer);
1128
+ reject(error);
1129
+ },
1130
+ timer,
1131
+ };
1132
+ session.waiters.push(waiter);
1133
+ this.flushC64uVideoCaptureWaiters(session);
1134
+ });
1135
+ }
1136
+ flushC64uVideoCaptureWaiters(session) {
1137
+ for (let index = session.waiters.length - 1; index >= 0; index -= 1) {
1138
+ const waiter = session.waiters[index];
1139
+ const frames = collectCompleteVideoFrames(session.packets.slice(waiter.startIndex), waiter.count);
1140
+ if (frames.length >= waiter.count) {
1141
+ session.waiters.splice(index, 1);
1142
+ waiter.resolve(frames);
1143
+ }
1144
+ }
1145
+ }
1146
+ rejectC64uVideoCaptureWaiters(session, error) {
1147
+ while (session.waiters.length > 0) {
1148
+ const waiter = session.waiters.pop();
1149
+ if (waiter) {
1150
+ waiter.reject(error);
1151
+ }
1152
+ }
1153
+ }
1154
+ scheduleC64uVideoCaptureStop(keepAliveMs) {
1155
+ const session = this.c64uVideoCaptureSession;
1156
+ if (!session || session.closed) {
1157
+ return;
1158
+ }
1159
+ if (session.stopTimer) {
1160
+ clearTimeout(session.stopTimer);
1161
+ session.stopTimer = null;
1162
+ }
1163
+ if (keepAliveMs <= 0) {
1164
+ void this.stopC64uVideoCaptureSession().catch(() => { });
1165
+ return;
1166
+ }
1167
+ session.stopTimer = setTimeout(() => {
1168
+ void this.stopC64uVideoCaptureSession().catch(() => { });
1169
+ }, keepAliveMs);
1170
+ }
1171
+ async stopC64uVideoCaptureSession() {
1172
+ const session = this.c64uVideoCaptureSession;
1173
+ if (!session) {
1174
+ return;
1175
+ }
1176
+ this.c64uVideoCaptureSession = null;
1177
+ session.closed = true;
1178
+ if (session.stopTimer) {
1179
+ clearTimeout(session.stopTimer);
1180
+ session.stopTimer = null;
1181
+ }
1182
+ let stopError = null;
1183
+ try {
1184
+ await this.ensureStreamSuccess(await session.facade.streamStop("video"), "stop video stream");
1185
+ }
1186
+ catch (error) {
1187
+ stopError = error instanceof Error ? error : new Error(String(error));
1188
+ }
1189
+ finally {
1190
+ session.socket.close();
1191
+ this.rejectC64uVideoCaptureWaiters(session, stopError ?? new Error("Video capture session closed"));
1192
+ }
1193
+ if (stopError) {
1194
+ throw stopError;
1195
+ }
1196
+ }
1197
+ async captureViceFrames(count) {
1198
+ const viceFacade = await this.requireViceBackend();
1199
+ const frames = [];
1200
+ for (let index = 0; index < count; index += 1) {
1201
+ const snapshot = await this.captureViceDisplaySnapshot(viceFacade);
1202
+ frames.push(this.normaliseViceDisplaySnapshot(snapshot));
1203
+ }
1204
+ return { backend: "vice", frames };
1205
+ }
1206
+ async captureViceDisplaySnapshot(viceFacade) {
1207
+ let lastError;
1208
+ for (let attempt = 1; attempt <= 8; attempt += 1) {
1209
+ try {
1210
+ const snapshot = await viceFacade.withMonitor((client) => client.displayGet({}));
1211
+ if (snapshot.pixels.length > 0) {
1212
+ return snapshot;
1213
+ }
1214
+ lastError = new Error("VICE display capture returned an empty frame");
1215
+ }
1216
+ catch (error) {
1217
+ lastError = error;
1218
+ }
1219
+ if (attempt < 8) {
1220
+ await new Promise((resolve) => setTimeout(resolve, Math.min(1000, 150 * attempt)));
1221
+ }
1222
+ }
1223
+ throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "VICE display capture failed"));
1224
+ }
1225
+ async captureC64uAudioSamples(facade, samplePairs) {
1226
+ const host = new URL(this.baseUrl).hostname;
1227
+ const bindAddress = await this.resolveLocalCaptureAddress(host);
1228
+ const socket = createSocket("udp4");
1229
+ const chunks = [];
1230
+ let collectedValues = 0;
1231
+ let stopError = null;
1232
+ try {
1233
+ await this.bindCaptureSocket(socket, bindAddress);
1234
+ const target = this.socketEndpoint(socket, bindAddress);
1235
+ await this.ensureStreamSuccess(await facade.streamStart("audio", target), "start audio stream");
1236
+ const neededValues = samplePairs * 2;
1237
+ await new Promise((resolve, reject) => {
1238
+ const timeoutMs = Math.max(1000, Math.ceil(samplePairs / 192) * 500);
1239
+ const timer = setTimeout(() => {
1240
+ reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${samplePairs} audio sample pair(s)`));
1241
+ }, timeoutMs);
1242
+ socket.on("message", (msg) => {
1243
+ try {
1244
+ const packet = parseAudioPacket(Buffer.from(msg));
1245
+ chunks.push(packet.samples);
1246
+ collectedValues += packet.samples.length;
1247
+ if (collectedValues >= neededValues) {
1248
+ clearTimeout(timer);
1249
+ resolve();
1250
+ }
1251
+ }
1252
+ catch (error) {
1253
+ clearTimeout(timer);
1254
+ reject(error);
1255
+ }
1256
+ });
1257
+ socket.once("error", (error) => {
1258
+ clearTimeout(timer);
1259
+ reject(error);
1260
+ });
1261
+ });
1262
+ const samples = new Int16Array(samplePairs * 2);
1263
+ let offset = 0;
1264
+ for (const chunk of chunks) {
1265
+ const remaining = samples.length - offset;
1266
+ if (remaining <= 0) {
1267
+ break;
1268
+ }
1269
+ samples.set(chunk.subarray(0, remaining), offset);
1270
+ offset += Math.min(chunk.length, remaining);
1271
+ }
1272
+ return {
1273
+ backend: "c64u",
1274
+ channels: 2,
1275
+ sampleRateHz: await this.getC64uAudioSampleRate(facade),
1276
+ samplePairs,
1277
+ samples,
1278
+ };
1279
+ }
1280
+ finally {
1281
+ try {
1282
+ await this.ensureStreamSuccess(await facade.streamStop("audio"), "stop audio stream");
1283
+ }
1284
+ catch (error) {
1285
+ stopError = error instanceof Error ? error : new Error(String(error));
1286
+ }
1287
+ socket.close();
1288
+ if (stopError) {
1289
+ throw stopError;
1290
+ }
1291
+ }
1292
+ }
1293
+ async getC64uAudioSampleRate(facade) {
1294
+ try {
1295
+ const response = await facade.configGet("Video", "Mode");
1296
+ const raw = response?.value ?? response;
1297
+ const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
1298
+ if (mode.includes("NTSC")) {
1299
+ return C64U_NTSC_AUDIO_SAMPLE_RATE;
1300
+ }
1301
+ }
1302
+ catch {
1303
+ // Fall back to PAL below.
1304
+ }
1305
+ return C64U_PAL_AUDIO_SAMPLE_RATE;
1306
+ }
1307
+ async resolveLocalCaptureAddress(remoteHost) {
1308
+ const socket = createSocket("udp4");
1309
+ try {
1310
+ await new Promise((resolve, reject) => {
1311
+ socket.once("error", reject);
1312
+ socket.connect(64, remoteHost, () => {
1313
+ socket.off("error", reject);
1314
+ resolve();
1315
+ });
1316
+ });
1317
+ const address = socket.address();
1318
+ if (typeof address === "object" && address.address) {
1319
+ return address.address;
1320
+ }
1321
+ }
1322
+ catch {
1323
+ // Fall back below.
1324
+ }
1325
+ finally {
1326
+ socket.close();
1327
+ }
1328
+ return "127.0.0.1";
1329
+ }
1330
+ async bindCaptureSocket(socket, bindAddress) {
1331
+ await new Promise((resolve, reject) => {
1332
+ socket.once("error", reject);
1333
+ socket.bind(0, bindAddress, () => {
1334
+ socket.off("error", reject);
1335
+ resolve();
1336
+ });
1337
+ });
1338
+ }
1339
+ socketEndpoint(socket, bindAddress) {
1340
+ const address = socket.address();
1341
+ if (!address || typeof address === "string") {
1342
+ throw new Error("Unable to determine UDP capture socket endpoint");
1343
+ }
1344
+ return `${bindAddress}:${address.port}`;
1345
+ }
1346
+ async ensureStreamSuccess(result, action) {
1347
+ if (result.success) {
1348
+ return;
1349
+ }
1350
+ throw new Error(`Failed to ${action}: ${JSON.stringify(result.details ?? null)}`);
1351
+ }
1352
+ parseVideoPacketOrNull(payload) {
1353
+ try {
1354
+ return parseVideoPacket(payload);
1355
+ }
1356
+ catch (error) {
1357
+ writeDiagnosticEvent("capture_video_packet_ignored", {
1358
+ message: error instanceof Error ? error.message : String(error),
1359
+ bytes: payload.length,
1360
+ });
1361
+ return null;
1362
+ }
1363
+ }
1364
+ normaliseViceDisplaySnapshot(snapshot) {
1365
+ const bytesPerPixel = snapshot.bitsPerPixel > 0 && snapshot.bitsPerPixel % 8 === 0
1366
+ ? snapshot.bitsPerPixel / 8
1367
+ : 0;
1368
+ const pixels = Uint8Array.from(snapshot.pixels);
1369
+ const canCrop = bytesPerPixel > 0
1370
+ && snapshot.debugWidth > 0
1371
+ && snapshot.debugHeight > 0
1372
+ && pixels.length >= snapshot.debugWidth * snapshot.debugHeight * bytesPerPixel;
1373
+ if (!canCrop) {
1374
+ return {
1375
+ frameNumber: null,
1376
+ width: snapshot.debugWidth,
1377
+ height: snapshot.debugHeight,
1378
+ bitsPerPixel: snapshot.bitsPerPixel,
1379
+ pixels,
1380
+ complete: true,
1381
+ };
1382
+ }
1383
+ const innerWidth = typeof snapshot.innerWidth === "number" && snapshot.innerWidth > 0
1384
+ ? snapshot.innerWidth
1385
+ : snapshot.debugWidth;
1386
+ const innerHeight = typeof snapshot.innerHeight === "number" && snapshot.innerHeight > 0
1387
+ ? snapshot.innerHeight
1388
+ : snapshot.debugHeight;
1389
+ const rowThreshold = Math.max(1, Math.min(innerWidth, snapshot.debugWidth, 32));
1390
+ const columnThreshold = Math.max(1, Math.min(innerHeight, snapshot.debugHeight, 32));
1391
+ const isPixelVisible = (pixelIndex) => {
1392
+ const offset = pixelIndex * bytesPerPixel;
1393
+ for (let byteIndex = 0; byteIndex < bytesPerPixel; byteIndex += 1) {
1394
+ if ((pixels[offset + byteIndex] ?? 0) !== 0) {
1395
+ return true;
1396
+ }
1397
+ }
1398
+ return false;
1399
+ };
1400
+ const rowVisibleCounts = new Array(snapshot.debugHeight).fill(0);
1401
+ for (let row = 0; row < snapshot.debugHeight; row += 1) {
1402
+ let visiblePixels = 0;
1403
+ const rowStart = row * snapshot.debugWidth;
1404
+ for (let column = 0; column < snapshot.debugWidth; column += 1) {
1405
+ if (isPixelVisible(rowStart + column)) {
1406
+ visiblePixels += 1;
1407
+ }
1408
+ }
1409
+ rowVisibleCounts[row] = visiblePixels;
1410
+ }
1411
+ const top = rowVisibleCounts.findIndex((count) => count >= rowThreshold);
1412
+ const bottom = rowVisibleCounts.length - 1 - [...rowVisibleCounts].reverse().findIndex((count) => count >= rowThreshold);
1413
+ if (top < 0 || bottom < top) {
1414
+ return {
1415
+ frameNumber: null,
1416
+ width: snapshot.debugWidth,
1417
+ height: snapshot.debugHeight,
1418
+ bitsPerPixel: snapshot.bitsPerPixel,
1419
+ pixels,
1420
+ complete: true,
1421
+ };
1422
+ }
1423
+ const columnVisibleCounts = new Array(snapshot.debugWidth).fill(0);
1424
+ for (let column = 0; column < snapshot.debugWidth; column += 1) {
1425
+ let visiblePixels = 0;
1426
+ for (let row = top; row <= bottom; row += 1) {
1427
+ if (isPixelVisible((row * snapshot.debugWidth) + column)) {
1428
+ visiblePixels += 1;
1429
+ }
1430
+ }
1431
+ columnVisibleCounts[column] = visiblePixels;
1432
+ }
1433
+ const left = columnVisibleCounts.findIndex((count) => count >= columnThreshold);
1434
+ const right = columnVisibleCounts.length - 1 - [...columnVisibleCounts].reverse().findIndex((count) => count >= columnThreshold);
1435
+ if (left < 0 || right < left) {
1436
+ return {
1437
+ frameNumber: null,
1438
+ width: snapshot.debugWidth,
1439
+ height: snapshot.debugHeight,
1440
+ bitsPerPixel: snapshot.bitsPerPixel,
1441
+ pixels,
1442
+ complete: true,
1443
+ };
1444
+ }
1445
+ const croppedWidth = right - left + 1;
1446
+ const croppedHeight = bottom - top + 1;
1447
+ const rowStride = snapshot.debugWidth * bytesPerPixel;
1448
+ const croppedRowStride = croppedWidth * bytesPerPixel;
1449
+ const cropped = new Uint8Array(croppedRowStride * croppedHeight);
1450
+ for (let row = 0; row < croppedHeight; row += 1) {
1451
+ const sourceStart = ((top + row) * rowStride) + (left * bytesPerPixel);
1452
+ const sourceEnd = sourceStart + croppedRowStride;
1453
+ cropped.set(pixels.subarray(sourceStart, sourceEnd), row * croppedRowStride);
1454
+ }
1455
+ return {
1456
+ frameNumber: null,
1457
+ width: croppedWidth,
1458
+ height: croppedHeight,
1459
+ bitsPerPixel: snapshot.bitsPerPixel,
1460
+ pixels: cropped,
1461
+ complete: true,
1462
+ };
1463
+ }
670
1464
  /**
671
1465
  * Low-level memory read that transparently handles devices returning either
672
1466
  * raw binary bytes or JSON with a base64 payload.
@@ -698,6 +1492,68 @@ export class C64Client {
698
1492
  }
699
1493
  return body instanceof ArrayBuffer ? new Uint8Array(body) : this.extractBytes(body);
700
1494
  }
1495
+ async viceCheckpointList() {
1496
+ return this.withViceMonitor((client) => client.checkpointList());
1497
+ }
1498
+ async viceCheckpointGet(id) {
1499
+ return this.withViceMonitor((client) => client.checkpointGet(id));
1500
+ }
1501
+ async viceCheckpointCreate(options) {
1502
+ const payload = options.memspace === undefined
1503
+ ? options
1504
+ : { ...options, memspace: this.normaliseMemspace(options.memspace) };
1505
+ return this.withViceMonitor((client) => client.checkpointCreate(payload));
1506
+ }
1507
+ async viceCheckpointDelete(id) {
1508
+ await this.withViceMonitor((client) => client.checkpointDelete(id));
1509
+ }
1510
+ async viceCheckpointToggle(id, enabled) {
1511
+ await this.withViceMonitor((client) => client.checkpointToggle(id, enabled));
1512
+ }
1513
+ async viceCheckpointSetCondition(id, expression) {
1514
+ await this.withViceMonitor((client) => client.checkpointSetCondition(id, expression));
1515
+ }
1516
+ async viceRegistersAvailable(memspace = 0) {
1517
+ const space = this.normaliseMemspace(memspace);
1518
+ return this.withViceMonitor((client) => client.registersAvailable(space));
1519
+ }
1520
+ async viceRegistersGet(memspace = 0) {
1521
+ const space = this.normaliseMemspace(memspace);
1522
+ return this.withViceMonitor((client) => client.registersGet(space));
1523
+ }
1524
+ async viceRegistersSet(writes, options) {
1525
+ let payload;
1526
+ if (options) {
1527
+ payload = {
1528
+ ...(options.metadata ? { metadata: options.metadata } : {}),
1529
+ ...(options.memspace !== undefined ? { memspace: this.normaliseMemspace(options.memspace) } : {}),
1530
+ };
1531
+ }
1532
+ return this.withViceMonitor((client) => client.registersSet(writes, payload));
1533
+ }
1534
+ async viceStepInstructions(count = 1, options) {
1535
+ await this.withViceMonitor((client) => client.stepInstructions(count, options));
1536
+ }
1537
+ async viceStepReturn() {
1538
+ await this.withViceMonitor((client) => client.stepReturn());
1539
+ }
1540
+ async viceResourceGet(name) {
1541
+ return this.withViceMonitor((client) => client.resourceGet(name));
1542
+ }
1543
+ async viceResourceSet(name, value) {
1544
+ await this.withViceMonitor((client) => client.resourceSet(name, value));
1545
+ }
1546
+ normaliseMemspace(value) {
1547
+ const allowed = [0, 1, 2, 3, 4];
1548
+ if (value === undefined) {
1549
+ return 0;
1550
+ }
1551
+ const numeric = Number(value);
1552
+ if (allowed.includes(numeric)) {
1553
+ return numeric;
1554
+ }
1555
+ return 0;
1556
+ }
701
1557
  normaliseError(error) {
702
1558
  if (axios.isAxiosError(error)) {
703
1559
  return {
@@ -711,6 +1567,49 @@ export class C64Client {
711
1567
  }
712
1568
  return error;
713
1569
  }
1570
+ async readByteOrDefault(facade, address, fallback) {
1571
+ try {
1572
+ const bytes = await facade.readMemory(address, 1);
1573
+ return bytes[0] ?? fallback;
1574
+ }
1575
+ catch {
1576
+ return fallback;
1577
+ }
1578
+ }
1579
+ async getGreetingDd00(type, facade) {
1580
+ const cached = this.greetingDd00Cache.get(type);
1581
+ if (cached !== undefined) {
1582
+ return cached;
1583
+ }
1584
+ let warmup = this.greetingDd00Warmups.get(type);
1585
+ if (!warmup) {
1586
+ warmup = this.readByteOrDefault(facade, 0xDD00, 0).then((value) => {
1587
+ this.greetingDd00Cache.set(type, value);
1588
+ return value;
1589
+ }).finally(() => {
1590
+ this.greetingDd00Warmups.delete(type);
1591
+ });
1592
+ this.greetingDd00Warmups.set(type, warmup);
1593
+ }
1594
+ return warmup;
1595
+ }
1596
+ async primeGreetingDd00Cache(types) {
1597
+ await this.initPromise;
1598
+ const targets = (types && types.length > 0 ? [...types] : this.getAvailableBackends()).filter((type, index, all) => all.indexOf(type) === index);
1599
+ await Promise.all(targets.map(async (type) => {
1600
+ const facadePromise = this.allFacades.get(type);
1601
+ if (!facadePromise) {
1602
+ return;
1603
+ }
1604
+ try {
1605
+ const facade = await facadePromise;
1606
+ await this.getGreetingDd00(type, facade);
1607
+ }
1608
+ catch {
1609
+ // Keep greeting prewarm best-effort; renderGreetingScreen still falls back safely.
1610
+ }
1611
+ }));
1612
+ }
714
1613
  parseNumeric(value) {
715
1614
  if (typeof value !== "string") {
716
1615
  throw new Error("Expected string input");
@@ -806,6 +1705,71 @@ function toByte(value, fallback = 0) {
806
1705
  const v = value ?? fallback;
807
1706
  return Math.max(0, Math.min(255, v)) & 0xff;
808
1707
  }
1708
+ function normaliseColorNibble(value) {
1709
+ return Math.max(0, Math.min(0x0F, Math.floor(value))) & 0x0F;
1710
+ }
1711
+ function buildVicTextRegisters(options) {
1712
+ return {
1713
+ dd00: ((options.currentDd00 ?? 0) & 0xFC) | 0x03,
1714
+ d011: 0x1B,
1715
+ d016: 0x08,
1716
+ d018: 0x14,
1717
+ d020: normaliseColorNibble(options.borderColor ?? DEFAULT_BORDER_COLOR),
1718
+ d021: normaliseColorNibble(options.backgroundColor ?? DEFAULT_BACKGROUND_COLOR),
1719
+ };
1720
+ }
1721
+ function asciiCharToScreenCode(char) {
1722
+ if (char.length !== 1) {
1723
+ return SPACE_SCREEN_CODE;
1724
+ }
1725
+ const upper = char.toUpperCase();
1726
+ if (upper >= "A" && upper <= "Z") {
1727
+ return upper.charCodeAt(0) - 64;
1728
+ }
1729
+ if (upper >= "0" && upper <= "9") {
1730
+ return upper.charCodeAt(0);
1731
+ }
1732
+ switch (upper) {
1733
+ case " ":
1734
+ return SPACE_SCREEN_CODE;
1735
+ case ".":
1736
+ return 0x2E;
1737
+ case ",":
1738
+ return 0x2C;
1739
+ case "!":
1740
+ return 0x21;
1741
+ case ":":
1742
+ return 0x3A;
1743
+ case ";":
1744
+ return 0x3B;
1745
+ case "-":
1746
+ return 0x2D;
1747
+ case "'":
1748
+ return 0x27;
1749
+ case "/":
1750
+ return 0x2F;
1751
+ case "?":
1752
+ return 0x3F;
1753
+ default:
1754
+ return SPACE_SCREEN_CODE;
1755
+ }
1756
+ }
1757
+ function writeScreenLine(screenRam, row, text) {
1758
+ if (row < 0 || row >= TEXT_SCREEN_ROWS) {
1759
+ return;
1760
+ }
1761
+ const offset = row * TEXT_SCREEN_COLUMNS;
1762
+ const clipped = text.slice(0, TEXT_SCREEN_COLUMNS);
1763
+ for (let index = 0; index < clipped.length; index += 1) {
1764
+ screenRam[offset + index] = asciiCharToScreenCode(clipped[index] ?? " ");
1765
+ }
1766
+ }
1767
+ function buildGreetingScreenRam(message) {
1768
+ const screenRam = new Uint8Array(TEXT_SCREEN_SIZE).fill(SPACE_SCREEN_CODE);
1769
+ writeScreenLine(screenRam, 1, message);
1770
+ writeScreenLine(screenRam, 3, "READY.");
1771
+ return screenRam;
1772
+ }
809
1773
  function buildSingleSpriteProgram(opts) {
810
1774
  const index = Math.max(0, Math.min(7, opts.spriteIndex ?? 0));
811
1775
  const mx = Math.max(0, Math.min(511, opts.x ?? 100));