c64bridge 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) 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 +62 -24
  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 +83 -9
  99. package/package.json +8 -3
  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/run-coverage.mjs +284 -0
  105. package/scripts/run-tests.mjs +95 -39
  106. package/scripts/run-tests.ts +378 -73
  107. package/scripts/update-readme.ts +147 -13
package/dist/device.js CHANGED
@@ -3,14 +3,43 @@
3
3
  */
4
4
  import axios from "axios";
5
5
  import { Buffer } from "node:buffer";
6
- import { spawn } from "node:child_process";
7
6
  import fs from "node:fs";
8
7
  import os from "node:os";
9
8
  import path from "node:path";
10
9
  import { Api } from "../generated/c64/index.js";
11
10
  import { createLoggingHttpClient } from "./loggingHttpClient.js";
11
+ import { ViceClient } from "./vice/viceClient.js";
12
+ import { waitForBasicReady } from "./vice/readiness.js";
13
+ import { startViceProcess } from "./vice/process.js";
12
14
  const DEFAULT_C64U_HOST = "c64u";
13
15
  const DEFAULT_C64U_PORT = 80;
16
+ const DEFAULT_VICE_HOST = "127.0.0.1";
17
+ const DEFAULT_VICE_PORT = 6502;
18
+ function delay(ms) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+ function coalesceMemoryWriteBlocks(blocks) {
22
+ const merged = [];
23
+ for (const block of blocks) {
24
+ if (!Number.isInteger(block.address) || block.address < 0 || block.address > 0xffff) {
25
+ throw new Error("Address must be within 0x0000-0xFFFF");
26
+ }
27
+ if (!(block.bytes instanceof Uint8Array) || block.bytes.length === 0) {
28
+ throw new Error("Bytes must be a non-empty Uint8Array");
29
+ }
30
+ const previous = merged[merged.length - 1];
31
+ const previousEnd = previous ? previous.address + previous.bytes.length : -1;
32
+ if (previous && previousEnd === block.address) {
33
+ const bytes = new Uint8Array(previous.bytes.length + block.bytes.length);
34
+ bytes.set(previous.bytes, 0);
35
+ bytes.set(block.bytes, previous.bytes.length);
36
+ previous.bytes = bytes;
37
+ continue;
38
+ }
39
+ merged.push({ address: block.address, bytes: Uint8Array.from(block.bytes) });
40
+ }
41
+ return merged;
42
+ }
14
43
  function readConfigFile() {
15
44
  const envPath = process.env.C64BRIDGE_CONFIG;
16
45
  const candidates = [];
@@ -25,24 +54,41 @@ function readConfigFile() {
25
54
  const home = process.env.HOME || os.homedir();
26
55
  if (home)
27
56
  candidates.push(path.join(home, ".c64bridge.json"));
57
+ let foundCandidate = false;
58
+ const merged = {};
28
59
  for (const p of candidates) {
29
60
  try {
30
61
  if (fs.existsSync(p)) {
62
+ foundCandidate = true;
31
63
  const text = fs.readFileSync(p, "utf8");
32
64
  const json = JSON.parse(text);
33
- return json ?? null;
65
+ if (!merged.c64u && json?.c64u && typeof json.c64u === "object" && !Array.isArray(json.c64u)) {
66
+ merged.c64u = json.c64u;
67
+ }
68
+ if (!merged.vice && json?.vice && typeof json.vice === "object" && !Array.isArray(json.vice)) {
69
+ merged.vice = json.vice;
70
+ }
71
+ if (merged.c64u && merged.vice) {
72
+ break;
73
+ }
34
74
  }
35
75
  }
36
76
  catch { }
37
77
  }
38
- return null;
78
+ return foundCandidate ? merged : null;
39
79
  }
40
80
  class C64uBackend {
41
81
  constructor(config) {
42
82
  this.type = "c64u";
43
- const baseUrl = resolveBaseUrl(config);
83
+ const envHost = configuredString(process.env.C64U_HOST);
84
+ const envPort = configuredPort(process.env.C64U_PORT);
85
+ const configBaseUrl = normaliseBaseUrl(config.baseUrl);
86
+ const parsedConfigBaseUrl = configBaseUrl ? parseEndpoint(configBaseUrl) : {};
87
+ const baseUrl = envHost !== undefined || envPort !== undefined
88
+ ? buildBaseUrl(firstDefined(envHost, configuredString(config.host), configuredString(config.hostname), parsedConfigBaseUrl.hostname) ?? DEFAULT_C64U_HOST, firstDefined(envPort, configuredPort(config.port), parsedConfigBaseUrl.port) ?? DEFAULT_C64U_PORT)
89
+ : resolveBaseUrl(config);
44
90
  this.baseUrl = baseUrl;
45
- this.networkPassword = configuredString(config.networkPassword);
91
+ this.networkPassword = firstDefined(configuredString(process.env.C64U_PASSWORD), configuredString(config.networkPassword));
46
92
  const http = createLoggingHttpClient({
47
93
  baseURL: baseUrl,
48
94
  timeout: 10000,
@@ -119,6 +165,10 @@ class C64uBackend {
119
165
  await this.api.v1.machineWritememCreate(":writemem", { address: addrStr }, Buffer.from(bytes), { headers: { "Content-Type": "application/octet-stream" } });
120
166
  }
121
167
  }
168
+ async writeMemoryBlocks(blocks) {
169
+ const mergedBlocks = coalesceMemoryWriteBlocks(blocks);
170
+ await Promise.all(mergedBlocks.map(({ address, bytes }) => this.writeMemory(address, bytes)));
171
+ }
122
172
  async reset() { const res = await this.api.v1.machineResetUpdate(":reset"); return { success: true, details: res.data }; }
123
173
  async reboot() { const res = await this.api.v1.machineRebootUpdate(":reboot"); return { success: true, details: res.data }; }
124
174
  async pause() { const res = await this.api.v1.machinePauseUpdate(":pause"); return { success: true, details: res.data }; }
@@ -153,61 +203,488 @@ class C64uBackend {
153
203
  async filesCreateDnp(p, tracks, options) { const res = await this.api.v1.filesCreateDnpUpdate(encodeURIComponent(p), ":create_dnp", { tracks, diskname: options?.diskname }); return { success: true, details: res.data }; }
154
204
  async modplayFile(pathStr) { const res = await this.api.v1.runnersModplayUpdate(":modplay", { file: pathStr }); return { success: true, details: res.data }; }
155
205
  }
156
- class ViceBackend {
206
+ export class ViceBackend {
157
207
  constructor(config) {
158
208
  this.type = "vice";
159
- this.exe = config.exe || which("x64sc") || which("x64") || "x64sc";
209
+ this.debugEnabled = process.env.VICE_DEVICE_TEST_DEBUG === "1";
210
+ this.startupPromise = null;
211
+ this.monitorQueue = Promise.resolve();
212
+ this.lastProcessStart = 0;
213
+ ViceBackend.ensureCleanupRegistration();
214
+ const envBinary = configuredString(process.env.VICE_BINARY);
215
+ const configBinary = config.exe !== undefined
216
+ ? configuredString(config.exe) ?? (typeof config.exe === "string" ? config.exe : String(config.exe))
217
+ : undefined;
218
+ this.exe = resolveViceBinary({ envBinary, configBinary });
219
+ const envHost = normaliseViceHost(process.env.VICE_HOST);
220
+ const envPort = normaliseVicePort(process.env.VICE_PORT);
221
+ this.host = firstDefined(envHost, normaliseViceHost(config.host)) ?? DEFAULT_VICE_HOST;
222
+ this.port = firstDefined(envPort, normaliseVicePort(config.port)) ?? DEFAULT_VICE_PORT;
223
+ this.directory = firstDefined(resolveViceDirectory(configuredString(process.env.VICE_DIRECTORY)), resolveViceDirectory(configuredString(config.directory)), findViceResourceDirectory(this.exe));
224
+ this.mockMode = (process.env.VICE_TEST_TARGET || "").toLowerCase() === "mock";
225
+ const hostLower = this.host.toLowerCase();
226
+ const isLocal = hostLower === "127.0.0.1" || hostLower === "localhost";
227
+ this.manageProcess = !this.mockMode && isLocal;
228
+ const warpEnv = configuredBoolean(process.env.VICE_WARP);
229
+ const visibleEnv = configuredBoolean(process.env.VICE_VISIBLE);
230
+ this.visible = visibleEnv ?? configuredBoolean(config.visible) ?? true;
231
+ this.warp = warpEnv ?? configuredBoolean(config.warp) ?? !this.visible;
232
+ this.extraArgs = resolveViceArgs(config);
160
233
  }
161
- async ping() { return Boolean(which(this.exe)); }
162
- async runPrg(prg) {
163
- const tmp = writeTempPrg(prg);
234
+ async tryPingExisting() {
235
+ const client = new ViceClient();
164
236
  try {
165
- const args = ["-silent", "-warp", "-autostart", tmp];
166
- const exitCode = await spawnWithTimeout(this.exe, args, resolveTimeout());
167
- return { success: exitCode === 0, details: { command: [this.exe, ...args].join(" "), exitCode } };
237
+ if (this.debugEnabled)
238
+ console.error("[vice-backend] probing existing VICE", this.host, this.port);
239
+ await client.connect(this.port, this.host);
240
+ await client.info();
241
+ if (this.debugEnabled)
242
+ console.error("[vice-backend] existing VICE is reachable");
243
+ return true;
244
+ }
245
+ catch {
246
+ return false;
168
247
  }
169
248
  finally {
249
+ client.close();
250
+ }
251
+ }
252
+ async waitForResponsiveMonitor(timeoutMs = 10000) {
253
+ const deadline = Date.now() + timeoutMs;
254
+ let lastError;
255
+ while (Date.now() < deadline) {
256
+ const client = new ViceClient();
170
257
  try {
171
- fs.unlinkSync(tmp);
258
+ await client.connect(this.port, this.host);
259
+ await client.info();
260
+ return;
261
+ }
262
+ catch (error) {
263
+ lastError = error;
264
+ await delay(250);
265
+ }
266
+ finally {
267
+ client.close();
268
+ }
269
+ }
270
+ throw lastError instanceof Error
271
+ ? lastError
272
+ : new Error(`VICE monitor at ${this.host}:${this.port} did not become responsive within ${timeoutMs}ms`);
273
+ }
274
+ async waitForUsableMachine(timeoutMs = 20000) {
275
+ const client = new ViceClient();
276
+ try {
277
+ await client.connect(this.port, this.host);
278
+ const readiness = await waitForBasicReady(client, { timeoutMs, ensurePrompt: true });
279
+ if (!readiness.pointersOk || !readiness.promptOk) {
280
+ throw new Error(`VICE machine at ${this.host}:${this.port} did not reach the BASIC prompt within ${timeoutMs}ms`);
281
+ }
282
+ }
283
+ finally {
284
+ client.close();
285
+ }
286
+ }
287
+ async ensureProcessInternal() {
288
+ if (!this.manageProcess)
289
+ return;
290
+ const key = `${this.host}:${this.port}`;
291
+ const existing = ViceBackend.supervisors.get(key);
292
+ if (existing) {
293
+ const running = existing.process.exitCode === null && existing.process.signalCode === null;
294
+ if (running)
295
+ return;
296
+ try {
297
+ await existing.stop();
172
298
  }
173
299
  catch { }
300
+ ViceBackend.supervisors.delete(key);
301
+ }
302
+ if (await this.tryPingExisting()) {
303
+ return;
304
+ }
305
+ if (this.debugEnabled) {
306
+ console.error("[vice-backend] starting VICE process", {
307
+ binary: this.exe,
308
+ host: this.host,
309
+ port: this.port,
310
+ warp: this.warp,
311
+ visible: this.visible,
312
+ extraArgs: this.extraArgs,
313
+ });
314
+ }
315
+ const handle = await startViceProcess({
316
+ binary: this.exe,
317
+ directory: this.directory,
318
+ host: this.host,
319
+ port: this.port,
320
+ warp: this.warp,
321
+ visible: this.visible,
322
+ extraArgs: this.extraArgs.length > 0 ? this.extraArgs : undefined,
323
+ });
324
+ this.lastProcessStart = Date.now();
325
+ if (this.debugEnabled) {
326
+ console.error("[vice-backend] VICE process started", { pid: handle.process.pid });
327
+ }
328
+ ViceBackend.supervisors.set(key, handle);
329
+ handle.process.once("exit", () => {
330
+ ViceBackend.supervisors.delete(key);
331
+ });
332
+ // Allow VICE to fully initialize its display before connecting to the monitor.
333
+ // Early monitor connection can interfere with the boot sequence.
334
+ await delay(1000);
335
+ await this.waitForResponsiveMonitor(20000);
336
+ await this.waitForUsableMachine(20000);
337
+ }
338
+ async ensureProcess() {
339
+ if (!this.manageProcess) {
340
+ return;
341
+ }
342
+ if (!this.startupPromise) {
343
+ this.startupPromise = this.ensureProcessInternal().finally(() => {
344
+ this.startupPromise = null;
345
+ });
346
+ }
347
+ await this.startupPromise;
348
+ }
349
+ static ensureCleanupRegistration() {
350
+ if (ViceBackend.cleanupRegistered) {
351
+ return;
174
352
  }
353
+ ViceBackend.cleanupRegistered = true;
354
+ process.once("exit", () => {
355
+ for (const [, handle] of ViceBackend.supervisors) {
356
+ handle.stop().catch(() => { });
357
+ }
358
+ ViceBackend.supervisors.clear();
359
+ });
360
+ }
361
+ async withClient(fn) {
362
+ const previous = this.monitorQueue;
363
+ let releaseQueue = () => { };
364
+ this.monitorQueue = new Promise((resolve) => {
365
+ releaseQueue = resolve;
366
+ });
367
+ await previous;
368
+ try {
369
+ if (!this.mockMode)
370
+ await this.ensureProcess();
371
+ const client = new ViceClient();
372
+ if (this.debugEnabled)
373
+ console.error("[vice-backend] connecting to VICE monitor", { host: this.host, port: this.port });
374
+ await client.connect(this.port, this.host);
375
+ if (this.manageProcess && this.lastProcessStart > 0) {
376
+ const sinceStart = Date.now() - this.lastProcessStart;
377
+ const settleDelay = 500 - sinceStart;
378
+ if (settleDelay > 0) {
379
+ await delay(settleDelay);
380
+ }
381
+ }
382
+ try {
383
+ if (this.debugEnabled)
384
+ console.error("[vice-backend] connected to VICE monitor");
385
+ return await fn(client);
386
+ }
387
+ finally {
388
+ if (this.debugEnabled)
389
+ console.error("[vice-backend] closing VICE monitor connection");
390
+ client.close();
391
+ }
392
+ }
393
+ finally {
394
+ releaseQueue();
395
+ }
396
+ }
397
+ async withMonitor(fn) {
398
+ return this.withClient(fn);
399
+ }
400
+ async ping() {
401
+ const attempts = 8;
402
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
403
+ try {
404
+ await this.withClient(async (client) => {
405
+ await client.info();
406
+ });
407
+ return true;
408
+ }
409
+ catch (error) {
410
+ if (this.debugEnabled) {
411
+ console.error(`[vice-backend] ping failed (attempt ${attempt}/${attempts})`, error);
412
+ }
413
+ if (attempt < attempts) {
414
+ await delay(Math.min(1000, 200 * attempt));
415
+ }
416
+ }
417
+ }
418
+ return false;
419
+ }
420
+ async injectPrg(buffer) {
421
+ if (buffer.length < 2)
422
+ throw new Error("PRG data too short");
423
+ const loadAddress = buffer.readUInt16LE(0);
424
+ const body = buffer.subarray(2);
425
+ await this.withClient(async (client) => {
426
+ await client.reset();
427
+ await waitForBasicReady(client, { timeoutMs: 10000, ensurePrompt: true });
428
+ if (body.length > 0)
429
+ await client.memSet(loadAddress, body);
430
+ const programEnd = loadAddress + body.length;
431
+ const ptrs = Buffer.alloc(8);
432
+ ptrs.writeUInt16LE(loadAddress, 0);
433
+ ptrs.writeUInt16LE(programEnd, 2);
434
+ ptrs.writeUInt16LE(programEnd, 4);
435
+ ptrs.writeUInt16LE(programEnd, 6);
436
+ await client.memSet(0x002B, ptrs);
437
+ await client.keyboardFeed("RUN\r");
438
+ await client.exitMonitor();
439
+ });
440
+ }
441
+ async runPrg(prg) {
442
+ const buffer = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
443
+ await this.injectPrg(buffer);
444
+ return { success: true };
175
445
  }
176
446
  async loadPrgFile(_path) { throw unsupported("loadPrgFile"); }
177
447
  async runPrgFile(prgPath) {
178
- const args = ["-silent", "-warp", "-autostart", prgPath];
179
- const exitCode = await spawnWithTimeout(this.exe, args, resolveTimeout());
180
- return { success: exitCode === 0, details: { command: [this.exe, ...args].join(" "), exitCode } };
448
+ const data = fs.readFileSync(prgPath);
449
+ await this.injectPrg(data);
450
+ return { success: true };
181
451
  }
182
452
  async runCrtFile(_path) { throw unsupported("runCrtFile"); }
183
453
  async sidplayFile(_p) { throw unsupported("sidplayFile"); }
184
454
  async sidplayAttachment(_sid) { throw unsupported("sidplayAttachment"); }
185
- async readMemory(_a) { throw unsupported("readMemory"); }
186
- async writeMemory(_a, _b) { throw unsupported("writeMemory"); }
187
- async reset() { throw unsupported("reset"); }
188
- async reboot() { throw unsupported("reboot"); }
189
- async pause() { throw unsupported("pause"); }
190
- async resume() { throw unsupported("resume"); }
191
- async poweroff() { throw unsupported("poweroff"); }
455
+ async readMemory(address, length) {
456
+ if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
457
+ throw new Error("Address must be within 0x0000-0xFFFF");
458
+ }
459
+ if (!Number.isInteger(length) || length <= 0) {
460
+ throw new Error("Length must be positive");
461
+ }
462
+ const end = Math.min(0xffff, address + length - 1);
463
+ return await this.withClient(async (client) => {
464
+ const buf = await client.memGet(address, end);
465
+ return buf.subarray(0, Math.min(buf.length, length));
466
+ });
467
+ }
468
+ async writeMemory(address, bytes) {
469
+ if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
470
+ throw new Error("Address must be within 0x0000-0xFFFF");
471
+ }
472
+ if (!(bytes instanceof Uint8Array) || bytes.length === 0) {
473
+ throw new Error("Bytes must be a non-empty Uint8Array");
474
+ }
475
+ await this.withClient(async (client) => {
476
+ await client.memSet(address, Buffer.from(bytes));
477
+ });
478
+ }
479
+ async writeMemoryBlocks(blocks) {
480
+ const mergedBlocks = coalesceMemoryWriteBlocks(blocks);
481
+ await this.withClient(async (client) => {
482
+ for (const { address, bytes } of mergedBlocks) {
483
+ await client.memSet(address, Buffer.from(bytes));
484
+ }
485
+ });
486
+ }
487
+ async reset() {
488
+ await this.withClient(async (client) => {
489
+ await client.reset();
490
+ const opts = this.debugEnabled
491
+ ? {
492
+ timeoutMs: 20000,
493
+ ensurePrompt: true,
494
+ onPointersSample: (p) => {
495
+ console.error("[vice-backend] BASIC pointers", p);
496
+ },
497
+ }
498
+ : { timeoutMs: 20000, ensurePrompt: true };
499
+ const readiness = await waitForBasicReady(client, opts);
500
+ if (this.debugEnabled)
501
+ console.error("[vice-backend] waitForBasicReady result", readiness);
502
+ });
503
+ return { success: true };
504
+ }
505
+ async reboot() { return this.reset(); }
506
+ async pause() {
507
+ return {
508
+ success: false,
509
+ details: {
510
+ code: "UNSUPPORTED",
511
+ message: "VICE pause is not implemented as a durable machine stop; use per-operation monitor access instead.",
512
+ },
513
+ };
514
+ }
515
+ async resume() {
516
+ return {
517
+ success: false,
518
+ details: {
519
+ code: "UNSUPPORTED",
520
+ message: "VICE resume is unavailable because durable pause is not supported.",
521
+ },
522
+ };
523
+ }
524
+ async poweroff() {
525
+ const key = this.supervisorKey();
526
+ const managedHandle = this.manageProcess ? ViceBackend.supervisors.get(key) : null;
527
+ try {
528
+ await this.withClient(async (client) => {
529
+ try {
530
+ await client.quit();
531
+ }
532
+ catch {
533
+ // Ignore transport errors during quit; the emulator will terminate regardless.
534
+ }
535
+ });
536
+ if (managedHandle) {
537
+ try {
538
+ await managedHandle.stop();
539
+ }
540
+ catch { }
541
+ ViceBackend.supervisors.delete(key);
542
+ }
543
+ return { success: true };
544
+ }
545
+ catch (error) {
546
+ const details = error instanceof Error
547
+ ? { message: error.message }
548
+ : error;
549
+ return { success: false, details };
550
+ }
551
+ }
192
552
  async menuButton() { throw unsupported("menuButton"); }
193
553
  async debugregRead() { throw unsupported("debugregRead"); }
194
554
  async debugregWrite(_v) { throw unsupported("debugregWrite"); }
195
- async version() { return { emulator: "vice" }; }
196
- async info() { return { emulator: "vice", phase: 1 }; }
197
- async drivesList() { throw unsupported("drivesList"); }
198
- async driveMount() { throw unsupported("driveMount"); }
199
- async driveRemove() { throw unsupported("driveRemove"); }
200
- async driveReset() { throw unsupported("driveReset"); }
201
- async driveOn() { throw unsupported("driveOn"); }
202
- async driveOff() { throw unsupported("driveOff"); }
203
- async driveSetMode() { throw unsupported("driveSetMode"); }
555
+ async version() { return { emulator: "vice", host: this.host, port: this.port }; }
556
+ async info() {
557
+ return await this.withClient(async (client) => {
558
+ await client.info();
559
+ return { emulator: "vice", host: this.host, port: this.port };
560
+ });
561
+ }
562
+ getEndpoint() {
563
+ return { host: this.host, port: this.port };
564
+ }
565
+ async drivesList() {
566
+ return await this.withClient(async (client) => {
567
+ const drives = [];
568
+ for (const n of [8, 9, 10, 11]) {
569
+ try {
570
+ const enabled = await client.resourceGet(`Drive${n}CPUEnabled`);
571
+ const image = await client.resourceGet(`Drive${n}Image`);
572
+ const typeRes = await client.resourceGet(`Drive${n}Type`);
573
+ drives.push({
574
+ id: `drive${n}`,
575
+ power: (typeof enabled.value === "number" ? enabled.value : 0) ? "on" : "off",
576
+ image: typeof image.value === "string" && image.value ? image.value : null,
577
+ type: typeRes.value,
578
+ });
579
+ }
580
+ catch {
581
+ drives.push({ id: `drive${n}`, power: "off", image: null, type: 0 });
582
+ }
583
+ }
584
+ return drives;
585
+ });
586
+ }
587
+ async driveMount(drive, imagePath) {
588
+ const n = parseDriveNumber(drive);
589
+ await this.withClient(async (client) => {
590
+ await client.resourceSet(`Drive${n}CPUEnabled`, 1);
591
+ await client.resourceSet(`Drive${n}Image`, imagePath);
592
+ });
593
+ return { success: true, details: { drive, image: imagePath } };
594
+ }
595
+ async driveRemove(drive) {
596
+ const n = parseDriveNumber(drive);
597
+ await this.withClient(async (client) => {
598
+ await client.resourceSet(`Drive${n}Image`, "");
599
+ });
600
+ return { success: true, details: { drive } };
601
+ }
602
+ async driveReset(drive) {
603
+ const n = parseDriveNumber(drive);
604
+ await this.withClient(async (client) => {
605
+ await client.resourceSet(`Drive${n}CPUEnabled`, 0);
606
+ await client.resourceSet(`Drive${n}CPUEnabled`, 1);
607
+ });
608
+ return { success: true, details: { drive } };
609
+ }
610
+ async driveOn(drive) {
611
+ const n = parseDriveNumber(drive);
612
+ await this.withClient(async (client) => {
613
+ await client.resourceSet(`Drive${n}CPUEnabled`, 1);
614
+ });
615
+ return { success: true, details: { drive, power: "on" } };
616
+ }
617
+ async driveOff(drive) {
618
+ const n = parseDriveNumber(drive);
619
+ await this.withClient(async (client) => {
620
+ await client.resourceSet(`Drive${n}CPUEnabled`, 0);
621
+ });
622
+ return { success: true, details: { drive, power: "off" } };
623
+ }
624
+ async driveSetMode(drive, mode) {
625
+ const n = parseDriveNumber(drive);
626
+ const DRIVE_TYPE = { "1541": 2, "1571": 8, "1581": 11 };
627
+ const typeNum = DRIVE_TYPE[mode];
628
+ if (typeNum === undefined)
629
+ throw new Error(`Unknown drive mode: ${mode}`);
630
+ await this.withClient(async (client) => {
631
+ await client.resourceSet(`Drive${n}Type`, typeNum);
632
+ });
633
+ return { success: true, details: { drive, mode } };
634
+ }
204
635
  async driveLoadRom() { throw unsupported("driveLoadRom"); }
205
636
  async streamStart() { throw unsupported("streamStart"); }
206
637
  async streamStop() { throw unsupported("streamStop"); }
207
- async configsList() { throw unsupported("configsList"); }
208
- async configGet() { throw unsupported("configGet"); }
209
- async configSet() { throw unsupported("configSet"); }
210
- async configBatchUpdate() { throw unsupported("configBatchUpdate"); }
638
+ async configsList() {
639
+ return {
640
+ categories: [
641
+ {
642
+ name: "VICE",
643
+ items: [
644
+ "WarpMode", "SoundVolume", "Drive8CPUEnabled", "Drive9CPUEnabled",
645
+ "Drive10CPUEnabled", "Drive11CPUEnabled", "Drive8Image", "Drive9Image",
646
+ "Drive8Type", "Drive9Type", "VICIIBorderMode", "VICIIFullscreen",
647
+ ],
648
+ },
649
+ ],
650
+ };
651
+ }
652
+ async configGet(_category, item) {
653
+ if (!item)
654
+ throw unsupported("configGet without item name");
655
+ return await this.withClient(async (client) => {
656
+ const res = await client.resourceGet(item);
657
+ return { category: _category, item, value: res.value, type: res.type };
658
+ });
659
+ }
660
+ async configSet(_category, item, value) {
661
+ const numValue = Number(value);
662
+ const parsed = !isNaN(numValue) && value.trim() !== "" ? numValue : value;
663
+ await this.withClient(async (client) => {
664
+ await client.resourceSet(item, parsed);
665
+ });
666
+ return { success: true, details: { item, value: parsed } };
667
+ }
668
+ async configBatchUpdate(payload) {
669
+ const results = [];
670
+ await this.withClient(async (client) => {
671
+ for (const [category, items] of Object.entries(payload)) {
672
+ for (const [item, value] of Object.entries(items)) {
673
+ try {
674
+ const str = String(value ?? "");
675
+ const numValue = Number(str);
676
+ const parsed = !isNaN(numValue) && str.trim() !== "" ? numValue : str;
677
+ await client.resourceSet(item, parsed);
678
+ results.push({ item: `${category}/${item}`, success: true });
679
+ }
680
+ catch (err) {
681
+ results.push({ item: `${category}/${item}`, success: false, error: err instanceof Error ? err.message : String(err) });
682
+ }
683
+ }
684
+ }
685
+ });
686
+ return { success: results.every((r) => r.success), details: { results } };
687
+ }
211
688
  async configLoadFromFlash() { throw unsupported("configLoadFromFlash"); }
212
689
  async configSaveToFlash() { throw unsupported("configSaveToFlash"); }
213
690
  async configResetToDefault() { throw unsupported("configResetToDefault"); }
@@ -216,8 +693,20 @@ class ViceBackend {
216
693
  async filesCreateD71() { throw unsupported("filesCreateD71"); }
217
694
  async filesCreateD81() { throw unsupported("filesCreateD81"); }
218
695
  async filesCreateDnp() { throw unsupported("filesCreateDnp"); }
696
+ supervisorKey() {
697
+ return `${this.host}:${this.port}`;
698
+ }
219
699
  }
700
+ ViceBackend.supervisors = new Map();
701
+ ViceBackend.cleanupRegistered = false;
220
702
  function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend in phase one`); err.code = "UNSUPPORTED"; return err; }
703
+ function parseDriveNumber(drive) {
704
+ const match = /\d+/.exec(drive);
705
+ const n = match ? parseInt(match[0], 10) : NaN;
706
+ if (isNaN(n) || n < 8 || n > 11)
707
+ throw new Error(`Invalid drive specification: ${drive}`);
708
+ return n;
709
+ }
221
710
  function extractBytes(data) {
222
711
  if (!data)
223
712
  return new Uint8Array();
@@ -271,24 +760,23 @@ function which(binary) {
271
760
  }
272
761
  return null;
273
762
  }
274
- function writeTempPrg(prg) {
275
- const dir = process.env.TMPDIR || process.env.TEMP || "/tmp";
276
- const file = path.join(dir, `c64bridge-${Date.now()}-${Math.random().toString(16).slice(2)}.prg`);
277
- const buf = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
278
- fs.writeFileSync(file, buf);
279
- return file;
763
+ function resolveViceBinary(options, findBinary = which) {
764
+ const explicit = firstDefined(options.envBinary, options.configBinary);
765
+ if (explicit) {
766
+ const resolvedExplicit = findBinary(explicit);
767
+ if (resolvedExplicit) {
768
+ return resolvedExplicit;
769
+ }
770
+ }
771
+ for (const candidate of ["/usr/local/bin/x64sc", "/usr/local/bin/x64"]) {
772
+ if (findBinary(candidate)) {
773
+ return candidate;
774
+ }
775
+ }
776
+ return findBinary("x64sc") ?? findBinary("x64") ?? "x64sc";
280
777
  }
281
- function resolveTimeout() { const n = Number(process.env.VICE_RUN_TIMEOUT_MS); return Number.isFinite(n) && n > 0 ? Math.floor(n) : 10000; }
282
- async function spawnWithTimeout(file, args, timeoutMs) {
283
- return await new Promise((resolve) => {
284
- const child = spawn(file, args, { stdio: ["ignore", "ignore", "pipe"] });
285
- const timer = setTimeout(() => { try {
286
- child.kill("SIGKILL");
287
- }
288
- catch { } }, Math.max(1000, timeoutMs));
289
- child.on("close", (code) => { clearTimeout(timer); resolve(code ?? 0); });
290
- child.on("error", () => { clearTimeout(timer); resolve(127); });
291
- });
778
+ export function __resolveViceBinaryForTests(options, findBinary) {
779
+ return resolveViceBinary(options, findBinary ?? which);
292
780
  }
293
781
  export async function createFacade(logger, options) {
294
782
  // Caller-forced preference: use c64u with provided base URL (used by tests and server wiring)
@@ -312,7 +800,8 @@ export async function createFacade(logger, options) {
312
800
  if (envMode === "vice") {
313
801
  const backend = new ViceBackend(cfg?.vice ?? {});
314
802
  logger?.info?.("Active backend: vice (from env override)");
315
- return { facade: backend, selected: "vice", reason: "env override", details: { exe: (cfg?.vice?.exe || which("x64sc") || "x64sc") } };
803
+ const endpoint = backend.getEndpoint();
804
+ return { facade: backend, selected: "vice", reason: "env override", details: { host: endpoint.host, port: endpoint.port } };
316
805
  }
317
806
  if (hasC64u && !hasVice) {
318
807
  const backend = new C64uBackend(cfg.c64u);
@@ -322,7 +811,8 @@ export async function createFacade(logger, options) {
322
811
  if (!hasC64u && hasVice) {
323
812
  const backend = new ViceBackend(cfg.vice);
324
813
  logger?.info?.("Active backend: vice (from config)");
325
- return { facade: backend, selected: "vice", reason: "config only", details: { exe: (cfg.vice.exe || which("x64sc") || "x64sc") } };
814
+ const endpoint = backend.getEndpoint();
815
+ return { facade: backend, selected: "vice", reason: "config only", details: { host: endpoint.host, port: endpoint.port } };
326
816
  }
327
817
  if (hasC64u && hasVice) {
328
818
  const backend = new C64uBackend(cfg.c64u);
@@ -342,7 +832,46 @@ export async function createFacade(logger, options) {
342
832
  catch { }
343
833
  const backend = new ViceBackend(cfg?.vice ?? {});
344
834
  logger?.info?.("Active backend: vice (fallback – hardware unavailable)");
345
- return { facade: backend, selected: "vice", reason: "fallback (hardware unavailable)", details: { exe: (cfg?.vice?.exe || which("x64sc") || "x64sc") } };
835
+ const endpoint = backend.getEndpoint();
836
+ return { facade: backend, selected: "vice", reason: "fallback (hardware unavailable)", details: { host: endpoint.host, port: endpoint.port } };
837
+ }
838
+ export async function createAllFacades(logger, options) {
839
+ const primary = await createFacade(logger);
840
+ const config = readConfigFile();
841
+ const secondaryType = primary.selected === "c64u" ? "vice" : "c64u";
842
+ if (secondaryType === "c64u" && shouldProvisionSecondaryC64u(config, options)) {
843
+ const secondaryConfig = {
844
+ ...(config?.c64u ?? {}),
845
+ ...(!config?.c64u?.baseUrl && options?.preferredC64uBaseUrl ? { baseUrl: options.preferredC64uBaseUrl } : {}),
846
+ ...(!config?.c64u?.networkPassword && options?.preferredC64uNetworkPassword
847
+ ? { networkPassword: options.preferredC64uNetworkPassword }
848
+ : {}),
849
+ };
850
+ return {
851
+ primary,
852
+ secondary: new C64uBackend(secondaryConfig),
853
+ secondaryType,
854
+ };
855
+ }
856
+ if (secondaryType === "vice" && config?.vice) {
857
+ return {
858
+ primary,
859
+ secondary: new ViceBackend(config.vice),
860
+ secondaryType,
861
+ };
862
+ }
863
+ return {
864
+ primary,
865
+ secondary: null,
866
+ secondaryType: null,
867
+ };
868
+ }
869
+ function shouldProvisionSecondaryC64u(config, options) {
870
+ return Boolean(config?.c64u
871
+ || options?.preferredC64uBaseUrl
872
+ || configuredString(process.env.C64U_HOST)
873
+ || configuredPort(process.env.C64U_PORT)
874
+ || configuredString(process.env.C64U_PASSWORD));
346
875
  }
347
876
  function resolveBaseUrl(config) {
348
877
  const explicit = normaliseBaseUrl(config.baseUrl);
@@ -367,6 +896,83 @@ function configuredString(value) {
367
896
  }
368
897
  return undefined;
369
898
  }
899
+ function configuredBoolean(value) {
900
+ if (typeof value === "boolean") {
901
+ return value;
902
+ }
903
+ const input = configuredString(value);
904
+ if (!input)
905
+ return undefined;
906
+ const normalised = input.toLowerCase();
907
+ if (normalised === "1" || normalised === "true" || normalised === "yes" || normalised === "on") {
908
+ return true;
909
+ }
910
+ if (normalised === "0" || normalised === "false" || normalised === "no" || normalised === "off") {
911
+ return false;
912
+ }
913
+ return undefined;
914
+ }
915
+ function resolveViceArgs(config) {
916
+ const argsEnv = configuredString(process.env.VICE_ARGS);
917
+ if (argsEnv) {
918
+ return parseArgsList(argsEnv);
919
+ }
920
+ if (Array.isArray(config.args)) {
921
+ return config.args
922
+ .filter((value) => typeof value === "string")
923
+ .map((value) => value.trim())
924
+ .filter((value) => value.length > 0);
925
+ }
926
+ if (typeof config.args === "string") {
927
+ return parseArgsList(config.args);
928
+ }
929
+ return [];
930
+ }
931
+ function resolveViceDirectory(value) {
932
+ const input = configuredString(value);
933
+ if (!input) {
934
+ return undefined;
935
+ }
936
+ return isViceResourceDirectory(input) ? input : undefined;
937
+ }
938
+ function findViceResourceDirectory(binaryPath) {
939
+ const candidates = new Set();
940
+ const resolvedBinary = configuredString(binaryPath);
941
+ if (resolvedBinary) {
942
+ const binaryDir = path.dirname(resolvedBinary);
943
+ candidates.add(path.resolve(binaryDir, "..", "share", "vice"));
944
+ candidates.add(path.resolve(binaryDir, "..", "..", "share", "vice"));
945
+ }
946
+ candidates.add("/usr/local/share/vice");
947
+ candidates.add("/usr/share/vice");
948
+ candidates.add("/opt/homebrew/share/vice");
949
+ candidates.add("/opt/local/share/vice");
950
+ for (const candidate of candidates) {
951
+ if (isViceResourceDirectory(candidate)) {
952
+ return candidate;
953
+ }
954
+ }
955
+ return undefined;
956
+ }
957
+ function isViceResourceDirectory(candidate) {
958
+ const normalized = configuredString(candidate);
959
+ if (!normalized) {
960
+ return false;
961
+ }
962
+ const requiredFiles = [
963
+ path.join(normalized, "C64", "kernal-901227-03.bin"),
964
+ path.join(normalized, "C64", "basic-901226-01.bin"),
965
+ path.join(normalized, "C64", "chargen-901225-01.bin"),
966
+ ];
967
+ return requiredFiles.every((filePath) => {
968
+ try {
969
+ return fs.existsSync(filePath);
970
+ }
971
+ catch {
972
+ return false;
973
+ }
974
+ });
975
+ }
370
976
  function configuredPort(value) {
371
977
  if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) {
372
978
  return value;
@@ -418,6 +1024,16 @@ function formatHost(host) {
418
1024
  function stripTrailingSlash(input) {
419
1025
  return input.replace(/\/+$/, "");
420
1026
  }
1027
+ function parseArgsList(input) {
1028
+ const args = [];
1029
+ const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
1030
+ let match;
1031
+ while ((match = pattern.exec(input)) !== null) {
1032
+ const value = match[1] ?? match[2] ?? match[3] ?? "";
1033
+ args.push(value.replace(/\\(["'\\])/g, "$1"));
1034
+ }
1035
+ return args;
1036
+ }
421
1037
  function firstDefined(...values) {
422
1038
  for (const value of values) {
423
1039
  if (value !== undefined && value !== null)
@@ -425,3 +1041,10 @@ function firstDefined(...values) {
425
1041
  }
426
1042
  return undefined;
427
1043
  }
1044
+ function normaliseViceHost(input) {
1045
+ const trimmed = configuredString(input);
1046
+ return trimmed && trimmed.length > 0 ? trimmed : undefined;
1047
+ }
1048
+ function normaliseVicePort(value) {
1049
+ return configuredPort(value);
1050
+ }