c64bridge 0.9.3 → 1.0.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 (65) hide show
  1. package/AGENTS.md +19 -3
  2. package/README.md +190 -148
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/c64Client.js +277 -41
  5. package/dist/config.js +30 -18
  6. package/dist/device.js +191 -125
  7. package/dist/knowledge.js +1 -1
  8. package/dist/loggingHttpClient.js +15 -3
  9. package/dist/mcp-server.js +65 -16
  10. package/dist/platform.js +13 -2
  11. package/dist/rag/indexer.js +11 -8
  12. package/dist/rag/init.js +11 -8
  13. package/dist/sidwaveCompiler.js +2 -2
  14. package/dist/tools/audio.js +27 -6
  15. package/dist/tools/developer.js +6 -6
  16. package/dist/tools/graphics.js +4 -4
  17. package/dist/tools/input.js +136 -24
  18. package/dist/tools/machineControl.js +85 -6
  19. package/dist/tools/memory.js +21 -6
  20. package/dist/tools/meta/artifacts.js +20 -8
  21. package/dist/tools/meta/background.js +11 -1
  22. package/dist/tools/meta/index.js +1 -1
  23. package/dist/tools/pollValidator.js +19 -51
  24. package/dist/tools/printer.js +1 -0
  25. package/dist/tools/programRunners.js +5 -5
  26. package/dist/tools/rag.js +1 -1
  27. package/dist/tools/registry/batch.js +1 -1
  28. package/dist/tools/registry/config.js +5 -5
  29. package/dist/tools/registry/disk.js +2 -2
  30. package/dist/tools/registry/drive.js +2 -2
  31. package/dist/tools/registry/graphics.js +2 -2
  32. package/dist/tools/registry/memory.js +2 -2
  33. package/dist/tools/registry/platform.js +9 -5
  34. package/dist/tools/registry/printer.js +1 -0
  35. package/dist/tools/registry/program.js +5 -5
  36. package/dist/tools/registry/rag.js +1 -1
  37. package/dist/tools/registry/sound.js +4 -4
  38. package/dist/tools/registry/system.js +13 -3
  39. package/dist/tools/storage.js +1 -1
  40. package/dist/vice/mockServer.js +33 -0
  41. package/dist/vice/process.js +11 -1
  42. package/dist/vice/viceClient.js +35 -6
  43. package/doc/c64u/c64-openapi.yaml +1130 -774
  44. package/doc/reviews/hardening/01-fable/prompt-handover.md +125 -0
  45. package/doc/reviews/hardening/01-fable/prompt.md +98 -0
  46. package/doc/reviews/hardening/01-fable/review-prompt.md +468 -0
  47. package/doc/reviews/hardening/01-fable/review.md +1234 -0
  48. package/doc/u2/u2-openapi.yaml +1192 -0
  49. package/generated/c64/index.js +142 -9
  50. package/generated/openapi/c64u/index.js +1117 -0
  51. package/generated/openapi/u2/index.js +929 -0
  52. package/mcp/schemas/c64_graphics.schema.json +3 -3
  53. package/mcp/schemas/c64_input.schema.json +249 -8
  54. package/mcp/schemas/c64_memory.schema.json +3 -3
  55. package/mcp/schemas/c64_program.schema.json +3 -3
  56. package/mcp/schemas/c64_select_backend.schema.json +2 -0
  57. package/mcp/schemas/c64_system.schema.json +41 -1
  58. package/mcp/server.json +1 -1
  59. package/mcp/tools.json +386 -44
  60. package/mcp.json +14 -2
  61. package/package.json +4 -2
  62. package/scripts/mockC64Server.mjs +171 -2
  63. package/scripts/prepare-release.mjs +13 -2
  64. package/scripts/update-readme.ts +18 -30
  65. package/server.json +3 -3
package/dist/device.js CHANGED
@@ -4,13 +4,13 @@
4
4
  import axios from "axios";
5
5
  import { Buffer } from "node:buffer";
6
6
  import fs from "node:fs";
7
- import os from "node:os";
8
7
  import path from "node:path";
9
8
  import { Api } from "../generated/c64/index.js";
10
9
  import { createLoggingHttpClient } from "./loggingHttpClient.js";
11
10
  import { ViceClient } from "./vice/viceClient.js";
12
11
  import { waitForBasicReady } from "./vice/readiness.js";
13
12
  import { startViceProcess } from "./vice/process.js";
13
+ import { loadConfig } from "./config.js";
14
14
  const DEFAULT_C64U_HOST = "c64u";
15
15
  const DEFAULT_C64U_PORT = 80;
16
16
  const DEFAULT_VICE_HOST = "127.0.0.1";
@@ -53,54 +53,25 @@ function coalesceMemoryWriteBlocks(blocks) {
53
53
  return merged;
54
54
  }
55
55
  function readConfigFile() {
56
- const envPath = process.env.C64BRIDGE_CONFIG;
57
- const candidates = [];
58
- if (envPath)
59
- candidates.push(envPath);
60
- // Repo root
61
- try {
62
- const here = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", ".c64bridge.json");
63
- candidates.push(here);
64
- }
65
- catch { }
66
- const home = process.env.HOME || os.homedir();
67
- if (home)
68
- candidates.push(path.join(home, ".c64bridge.json"));
69
- let foundCandidate = false;
70
- const merged = {};
71
- for (const p of candidates) {
72
- try {
73
- if (fs.existsSync(p)) {
74
- foundCandidate = true;
75
- const text = fs.readFileSync(p, "utf8");
76
- const json = JSON.parse(text);
77
- if (!merged.c64u && json?.c64u && typeof json.c64u === "object" && !Array.isArray(json.c64u)) {
78
- merged.c64u = json.c64u;
79
- }
80
- if (!merged.vice && json?.vice && typeof json.vice === "object" && !Array.isArray(json.vice)) {
81
- merged.vice = json.vice;
82
- }
83
- if (merged.c64u && merged.vice) {
84
- break;
85
- }
86
- }
87
- }
88
- catch { }
89
- }
90
- return foundCandidate ? merged : null;
56
+ const sections = loadConfig().backendConfig;
57
+ return Object.keys(sections).length === 0 ? null : {
58
+ c64u: sections.c64u,
59
+ u2: sections.u2,
60
+ vice: sections.vice,
61
+ };
91
62
  }
92
63
  class C64uBackend {
93
- constructor(config) {
94
- this.type = "c64u";
95
- const envHost = configuredString(process.env.C64U_HOST);
96
- const envPort = configuredPort(process.env.C64U_PORT);
64
+ constructor(config, type = "c64u") {
65
+ this.type = type;
66
+ const envHost = configuredString(type === "u2" ? process.env.U2_HOST ?? process.env.C64U_HOST : process.env.C64U_HOST);
67
+ const envPort = configuredPort(type === "u2" ? process.env.U2_PORT ?? process.env.C64U_PORT : process.env.C64U_PORT);
97
68
  const configBaseUrl = normaliseBaseUrl(config.baseUrl);
98
69
  const parsedConfigBaseUrl = configBaseUrl ? parseEndpoint(configBaseUrl) : {};
99
70
  const baseUrl = envHost !== undefined || envPort !== undefined
100
71
  ? buildBaseUrl(firstDefined(envHost, configuredString(config.host), configuredString(config.hostname), parsedConfigBaseUrl.hostname) ?? DEFAULT_C64U_HOST, firstDefined(envPort, configuredPort(config.port), parsedConfigBaseUrl.port) ?? DEFAULT_C64U_PORT)
101
72
  : resolveBaseUrl(config);
102
73
  this.baseUrl = baseUrl;
103
- this.networkPassword = firstDefined(configuredString(process.env.C64U_PASSWORD), configuredString(config.networkPassword));
74
+ this.networkPassword = firstDefined(configuredString(type === "u2" ? process.env.U2_PASSWORD ?? process.env.C64U_PASSWORD : process.env.C64U_PASSWORD), configuredString(config.networkPassword));
104
75
  const http = createLoggingHttpClient({
105
76
  baseURL: baseUrl,
106
77
  timeout: 10000,
@@ -108,7 +79,20 @@ class C64uBackend {
108
79
  });
109
80
  this.api = new Api(http);
110
81
  }
82
+ requireC64u(feature) {
83
+ if (this.type === "u2") {
84
+ throw unsupported(`${feature} is unavailable on U2-family cartridges`);
85
+ }
86
+ }
111
87
  getBaseUrl() { return this.baseUrl; }
88
+ actionResult(data) {
89
+ const errors = data && typeof data === "object" && Array.isArray(data.errors)
90
+ ? data.errors.filter((error) => error !== undefined && error !== null && String(error).trim() !== "")
91
+ : [];
92
+ return errors.length > 0
93
+ ? { success: false, details: { response: data, errors: errors.map(String) } }
94
+ : { success: true, details: data };
95
+ }
112
96
  async ping() {
113
97
  try {
114
98
  const res = await axios.get(this.baseUrl, {
@@ -124,30 +108,35 @@ class C64uBackend {
124
108
  async runPrg(prg) {
125
109
  const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
126
110
  const res = await this.api.v1.runnersRunPrgCreate(":run_prg", payload, { headers: { "Content-Type": "application/octet-stream" } });
127
- return { success: true, details: res.data };
111
+ return this.actionResult(res.data);
112
+ }
113
+ async loadPrg(prg) {
114
+ const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
115
+ const res = await this.api.v1.runnersLoadPrgCreate(":load_prg", payload, { headers: { "Content-Type": "application/octet-stream" } });
116
+ return this.actionResult(res.data);
128
117
  }
129
118
  async loadPrgFile(pathStr) {
130
119
  const res = await this.api.v1.runnersLoadPrgUpdate(":load_prg", { file: pathStr });
131
- return { success: true, details: res.data };
120
+ return this.actionResult(res.data);
132
121
  }
133
122
  async runPrgFile(pathStr) {
134
123
  const res = await this.api.v1.runnersRunPrgUpdate(":run_prg", { file: pathStr });
135
- return { success: true, details: res.data };
124
+ return this.actionResult(res.data);
136
125
  }
137
126
  async runCrtFile(pathStr) {
138
127
  const res = await this.api.v1.runnersRunCrtUpdate(":run_crt", { file: pathStr });
139
- return { success: true, details: res.data };
128
+ return this.actionResult(res.data);
140
129
  }
141
130
  async sidplayFile(pathStr, songnr) {
142
131
  const res = await this.api.v1.runnersSidplayUpdate(":sidplay", { file: pathStr, songnr });
143
- return { success: true, details: res.data };
132
+ return this.actionResult(res.data);
144
133
  }
145
134
  async sidplayAttachment(sid, options) {
146
135
  const form = { sid: Buffer.isBuffer(sid) ? sid : Buffer.from(sid) };
147
136
  if (options?.songlengths)
148
137
  form.songlengths = Buffer.isBuffer(options.songlengths) ? options.songlengths : Buffer.from(options.songlengths);
149
138
  const res = await this.api.v1.runnersSidplayCreate(":sidplay", form, options?.songnr !== undefined ? { songnr: options.songnr } : undefined);
150
- return { success: true, details: res.data };
139
+ return this.actionResult(res.data);
151
140
  }
152
141
  async readMemory(address, length) {
153
142
  const addrStr = address.toString(16).toUpperCase().padStart(4, "0");
@@ -181,39 +170,68 @@ class C64uBackend {
181
170
  const mergedBlocks = coalesceMemoryWriteBlocks(blocks);
182
171
  await Promise.all(mergedBlocks.map(({ address, bytes }) => this.writeMemory(address, bytes)));
183
172
  }
184
- async reset() { const res = await this.api.v1.machineResetUpdate(":reset"); return { success: true, details: res.data }; }
185
- async reboot() { const res = await this.api.v1.machineRebootUpdate(":reboot"); return { success: true, details: res.data }; }
186
- async pause() { const res = await this.api.v1.machinePauseUpdate(":pause"); return { success: true, details: res.data }; }
187
- async resume() { const res = await this.api.v1.machineResumeUpdate(":resume"); return { success: true, details: res.data }; }
188
- async poweroff() { const res = await this.api.v1.machinePoweroffUpdate(":poweroff"); return { success: true, details: res.data }; }
189
- async menuButton() { const res = await this.api.v1.machineMenuButtonUpdate(":menu_button"); return { success: true, details: res.data }; }
190
- async debugregRead() { const res = await this.api.v1.machineDebugregList(":debugreg"); return { success: true, value: res.data.value, details: res.data }; }
191
- async debugregWrite(value) { const res = await this.api.v1.machineDebugregUpdate(":debugreg", { value }); return { success: true, value: res.data.value, details: res.data }; }
173
+ async reset() { const res = await this.api.v1.machineResetUpdate(":reset"); return this.actionResult(res.data); }
174
+ async reboot() { const res = await this.api.v1.machineRebootUpdate(":reboot"); return this.actionResult(res.data); }
175
+ async pause() { const res = await this.api.v1.machinePauseUpdate(":pause"); return this.actionResult(res.data); }
176
+ async resume() { const res = await this.api.v1.machineResumeUpdate(":resume"); return this.actionResult(res.data); }
177
+ async poweroff() { this.requireC64u("poweroff"); const res = await this.api.v1.machinePoweroffUpdate(":poweroff"); return this.actionResult(res.data); }
178
+ async powerCycle() { this.requireC64u("powerCycle"); throw unsupported("powerCycle must be coordinated by C64Client"); }
179
+ async menuButton() { const res = await this.api.v1.machineMenuButtonUpdate(":menu_button"); return this.actionResult(res.data); }
180
+ async readMenuScreen() {
181
+ const response = await this.api.v1.machineMenuScreenList(":menu_screen", { format: "arraybuffer", headers: { Accept: "application/octet-stream" } });
182
+ const body = response.data;
183
+ return body instanceof ArrayBuffer ? new Uint8Array(body) : extractBytes(body);
184
+ }
185
+ async getInputState() {
186
+ this.requireC64u("getInputState");
187
+ const response = await this.api.v1.machineInputList(":input");
188
+ return response.data;
189
+ }
190
+ async sendInputEvents(batch) {
191
+ this.requireC64u("sendInputEvents");
192
+ const response = await this.api.v1.machineInputCreate(":input", batch);
193
+ return response.data;
194
+ }
195
+ async debugregRead() { this.requireC64u("debugregRead"); const res = await this.api.v1.machineDebugregList(":debugreg"); return { success: true, value: res.data.value, details: res.data }; }
196
+ async debugregWrite(value) { this.requireC64u("debugregWrite"); const res = await this.api.v1.machineDebugregUpdate(":debugreg", { value }); return { success: true, value: res.data.value, details: res.data }; }
192
197
  async version() { const res = await this.api.v1.versionList(); return res.data; }
193
198
  async info() { const res = await this.api.v1.infoList(); return res.data; }
194
- async drivesList() { const res = await this.api.v1.drivesList(); return res.data; }
195
- async driveMount(d, img, options) { const res = await this.api.v1.drivesMountUpdate(d, ":mount", { image: img, type: options?.type, mode: options?.mode }); return { success: true, details: res.data }; }
196
- async driveRemove(d) { const res = await this.api.v1.drivesRemoveUpdate(d, ":remove"); return { success: true, details: res.data }; }
197
- async driveReset(d) { const res = await this.api.v1.drivesResetUpdate(d, ":reset"); return { success: true, details: res.data }; }
198
- async driveOn(d) { const res = await this.api.v1.drivesOnUpdate(d, ":on"); return { success: true, details: res.data }; }
199
- async driveOff(d) { const res = await this.api.v1.drivesOffUpdate(d, ":off"); return { success: true, details: res.data }; }
200
- async driveSetMode(d, mode) { const res = await this.api.v1.drivesSetModeUpdate(d, ":set_mode", { mode }); return { success: true, details: res.data }; }
201
- async driveLoadRom(d, romPath) { const res = await this.api.v1.drivesLoadRomUpdate(d, ":load_rom", { file: romPath }); return { success: true, details: res.data }; }
202
- async streamStart(s, ip) { const res = await this.api.v1.streamsStartUpdate(s, ":start", { ip }); return { success: true, details: res.data }; }
203
- async streamStop(s) { const res = await this.api.v1.streamsStopUpdate(s, ":stop"); return { success: true, details: res.data }; }
199
+ async drivesList() {
200
+ const res = await this.api.v1.drivesList();
201
+ const raw = res.data;
202
+ const entries = Array.isArray(raw.drives)
203
+ ? raw.drives
204
+ : raw.drives && typeof raw.drives === "object" ? [raw.drives] : [];
205
+ return entries.flatMap((entry) => Object.entries(entry).map(([id, info]) => ({
206
+ id,
207
+ power: info.enabled === true || info.enabled === "on" || info.power === "on" ? "on" : "off",
208
+ image: (info.image_path ?? info.image_file ?? info.image ?? null),
209
+ type: info.type ?? null,
210
+ raw: info,
211
+ })));
212
+ }
213
+ async driveMount(d, img, options) { const res = await this.api.v1.drivesMountUpdate(d, ":mount", { image: img, type: options?.type, mode: options?.mode }); return this.actionResult(res.data); }
214
+ async driveRemove(d) { const res = await this.api.v1.drivesRemoveUpdate(d, ":remove"); return this.actionResult(res.data); }
215
+ async driveReset(d) { const res = await this.api.v1.drivesResetUpdate(d, ":reset"); return this.actionResult(res.data); }
216
+ async driveOn(d) { const res = await this.api.v1.drivesOnUpdate(d, ":on"); return this.actionResult(res.data); }
217
+ async driveOff(d) { const res = await this.api.v1.drivesOffUpdate(d, ":off"); return this.actionResult(res.data); }
218
+ async driveSetMode(d, mode) { const res = await this.api.v1.drivesSetModeUpdate(d, ":set_mode", { mode }); return this.actionResult(res.data); }
219
+ async driveLoadRom(d, romPath) { const res = await this.api.v1.drivesLoadRomUpdate(d, ":load_rom", { file: romPath }); return this.actionResult(res.data); }
220
+ async streamStart(s, ip) { this.requireC64u("streamStart"); const res = await this.api.v1.streamsStartUpdate(s, ":start", { ip }); return this.actionResult(res.data); }
221
+ async streamStop(s) { this.requireC64u("streamStop"); const res = await this.api.v1.streamsStopUpdate(s, ":stop"); return this.actionResult(res.data); }
204
222
  async configsList() { const res = await this.api.v1.configsList(); return res.data; }
205
223
  async configGet(cat, item) { const res = item ? await this.api.v1.configsDetail2(cat, item) : await this.api.v1.configsDetail(cat); return res.data; }
206
- async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(cat, item, { value }); return { success: true, details: res.data }; }
207
- async configBatchUpdate(payload) { const res = await this.api.v1.configsCreate(payload); return { success: true, details: res.data }; }
208
- async configLoadFromFlash() { const res = await this.api.v1.configsLoadFromFlashUpdate(":load_from_flash"); return { success: true, details: res.data }; }
209
- async configSaveToFlash() { const res = await this.api.v1.configsSaveToFlashUpdate(":save_to_flash"); return { success: true, details: res.data }; }
210
- async configResetToDefault() { const res = await this.api.v1.configsResetToDefaultUpdate(":reset_to_default"); return { success: true, details: res.data }; }
211
- async filesInfo(p) { const res = await this.api.v1.filesInfoDetail(encodeURIComponent(p), ":info"); return res.data; }
212
- async filesCreateD64(p, options) { const res = await this.api.v1.filesCreateD64Update(encodeURIComponent(p), ":create_d64", { tracks: options?.tracks, diskname: options?.diskname }); return { success: true, details: res.data }; }
213
- async filesCreateD71(p, options) { const res = await this.api.v1.filesCreateD71Update(encodeURIComponent(p), ":create_d71", { diskname: options?.diskname }); return { success: true, details: res.data }; }
214
- async filesCreateD81(p, options) { const res = await this.api.v1.filesCreateD81Update(encodeURIComponent(p), ":create_d81", { diskname: options?.diskname }); return { success: true, details: res.data }; }
215
- 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 }; }
216
- async modplayFile(pathStr) { const res = await this.api.v1.runnersModplayUpdate(":modplay", { file: pathStr }); return { success: true, details: res.data }; }
224
+ async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(cat, item, { value }); return this.actionResult(res.data); }
225
+ async configBatchUpdate(payload) { const res = await this.api.v1.configsCreate(payload); return this.actionResult(res.data); }
226
+ async configLoadFromFlash() { const res = await this.api.v1.configsLoadFromFlashUpdate(":load_from_flash"); return this.actionResult(res.data); }
227
+ async configSaveToFlash() { const res = await this.api.v1.configsSaveToFlashUpdate(":save_to_flash"); return this.actionResult(res.data); }
228
+ async configResetToDefault() { const res = await this.api.v1.configsResetToDefaultUpdate(":reset_to_default"); return this.actionResult(res.data); }
229
+ async filesInfo(p) { const res = await this.api.v1.filesInfoDetail(encodeDevicePath(p), ":info"); return res.data; }
230
+ async filesCreateD64(p, options) { const res = await this.api.v1.filesCreateD64Update(encodeDevicePath(p), ":create_d64", { tracks: options?.tracks, diskname: options?.diskname }); return this.actionResult(res.data); }
231
+ async filesCreateD71(p, options) { const res = await this.api.v1.filesCreateD71Update(encodeDevicePath(p), ":create_d71", { diskname: options?.diskname }); return this.actionResult(res.data); }
232
+ async filesCreateD81(p, options) { const res = await this.api.v1.filesCreateD81Update(encodeDevicePath(p), ":create_d81", { diskname: options?.diskname }); return this.actionResult(res.data); }
233
+ async filesCreateDnp(p, tracks, options) { const res = await this.api.v1.filesCreateDnpUpdate(encodeDevicePath(p), ":create_dnp", { tracks, diskname: options?.diskname }); return this.actionResult(res.data); }
234
+ async modplayFile(pathStr) { const res = await this.api.v1.runnersModplayUpdate(":modplay", { file: pathStr }); return this.actionResult(res.data); }
217
235
  }
218
236
  export class ViceBackend {
219
237
  constructor(config) {
@@ -378,13 +396,19 @@ export class ViceBackend {
378
396
  return;
379
397
  }
380
398
  ViceBackend.cleanupRegistered = true;
381
- /* c8 ignore next 5 -- global process exit hooks are not practical to exercise in the unit test process */
382
- process.once("exit", () => {
399
+ const cleanupSync = () => {
383
400
  for (const [, handle] of ViceBackend.supervisors) {
384
- handle.stop().catch(() => { });
401
+ handle.stopSync();
385
402
  }
386
403
  ViceBackend.supervisors.clear();
387
- });
404
+ };
405
+ process.once("exit", cleanupSync);
406
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
407
+ process.once(signal, () => {
408
+ cleanupSync();
409
+ process.exit(signal === "SIGINT" ? 130 : 143);
410
+ });
411
+ }
388
412
  }
389
413
  async withClient(fn, options) {
390
414
  const previous = this.monitorQueue;
@@ -448,7 +472,15 @@ export class ViceBackend {
448
472
  }
449
473
  return false;
450
474
  }
451
- async injectPrg(buffer) {
475
+ /**
476
+ * Reset, wait for a usable BASIC prompt, and load PRG bytes into memory.
477
+ * `finish` decides what happens next: a genuine BASIC PRG needs its
478
+ * pointers set and RUN typed, but machine code must never receive either
479
+ * — RUN would misinterpret raw opcodes as tokenized BASIC text, and
480
+ * rewriting TXTTAB/VARTAB/ARYTAB/STREND corrupts the BASIC workspace for
481
+ * an origin that isn't BASIC program text at all.
482
+ */
483
+ async loadPrgAndFinish(buffer, finish) {
452
484
  if (buffer.length < 2)
453
485
  throw new Error("PRG data too short");
454
486
  const loadAddress = buffer.readUInt16LE(0);
@@ -458,7 +490,12 @@ export class ViceBackend {
458
490
  await waitForBasicReady(client, { timeoutMs: 10000, ensurePrompt: true });
459
491
  if (body.length > 0)
460
492
  await client.memSet(loadAddress, body);
461
- const programEnd = loadAddress + body.length;
493
+ await finish(client, loadAddress, loadAddress + body.length);
494
+ await client.exitMonitor();
495
+ });
496
+ }
497
+ async injectPrg(buffer) {
498
+ await this.loadPrgAndFinish(buffer, async (client, loadAddress, programEnd) => {
462
499
  const ptrs = Buffer.alloc(8);
463
500
  ptrs.writeUInt16LE(loadAddress, 0);
464
501
  ptrs.writeUInt16LE(programEnd, 2);
@@ -466,7 +503,6 @@ export class ViceBackend {
466
503
  ptrs.writeUInt16LE(programEnd, 6);
467
504
  await client.memSet(0x002B, ptrs);
468
505
  await client.keyboardFeed("RUN\r");
469
- await client.exitMonitor();
470
506
  });
471
507
  }
472
508
  async runPrg(prg) {
@@ -474,6 +510,13 @@ export class ViceBackend {
474
510
  await this.injectPrg(buffer);
475
511
  return { success: true };
476
512
  }
513
+ /** Load PRG bytes without starting them — the caller decides how to enter
514
+ * the code (e.g. a subsequent typed `SYS <entry>` for machine code). */
515
+ async loadPrg(prg) {
516
+ const buffer = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
517
+ await this.loadPrgAndFinish(buffer, async () => { });
518
+ return { success: true };
519
+ }
477
520
  async loadPrgFile(_path) { throw unsupported("loadPrgFile"); }
478
521
  async runPrgFile(prgPath) {
479
522
  const data = fs.readFileSync(prgPath);
@@ -516,22 +559,29 @@ export class ViceBackend {
516
559
  });
517
560
  }
518
561
  async reset() {
562
+ let readiness;
563
+ // Overridable only for deterministic tests of the readiness-failure path;
564
+ // production always uses the 20s default.
565
+ const resetReadinessTimeoutMs = Number(process.env.C64BRIDGE_VICE_RESET_TIMEOUT_MS) || 20000;
519
566
  await this.withClient(async (client) => {
520
567
  await client.reset();
521
568
  const opts = this.debugEnabled
522
569
  ? {
523
- timeoutMs: 20000,
570
+ timeoutMs: resetReadinessTimeoutMs,
524
571
  ensurePrompt: true,
525
572
  onPointersSample: (p) => {
526
573
  console.error("[vice-backend] BASIC pointers", p);
527
574
  },
528
575
  }
529
- : { timeoutMs: 20000, ensurePrompt: true };
530
- const readiness = await waitForBasicReady(client, opts);
576
+ : { timeoutMs: resetReadinessTimeoutMs, ensurePrompt: true };
577
+ readiness = await waitForBasicReady(client, opts);
531
578
  if (this.debugEnabled)
532
579
  console.error("[vice-backend] waitForBasicReady result", readiness);
533
580
  });
534
- return { success: true };
581
+ const ready = Boolean(readiness?.pointersOk && readiness?.promptOk);
582
+ return ready
583
+ ? { success: true, details: readiness }
584
+ : { success: false, details: { readiness, message: "VICE reset completed but BASIC READY was not observed. Reset again or inspect the emulator monitor." } };
535
585
  }
536
586
  async reboot() { return this.reset(); }
537
587
  async pause() {
@@ -580,6 +630,7 @@ export class ViceBackend {
580
630
  return { success: false, details };
581
631
  }
582
632
  }
633
+ async powerCycle() { return this.nuclearReset(); }
583
634
  async nuclearReset() {
584
635
  const poweroffResult = await this.poweroff();
585
636
  if (!poweroffResult.success) {
@@ -606,6 +657,9 @@ export class ViceBackend {
606
657
  }
607
658
  }
608
659
  async menuButton() { throw unsupported("menuButton"); }
660
+ async readMenuScreen() { throw unsupported("readMenuScreen"); }
661
+ async getInputState() { throw unsupported("getInputState"); }
662
+ async sendInputEvents(_batch) { throw unsupported("sendInputEvents"); }
609
663
  async debugregRead() { throw unsupported("debugregRead"); }
610
664
  async debugregWrite(_v) { throw unsupported("debugregWrite"); }
611
665
  async version() { return { emulator: "vice", host: this.host, port: this.port }; }
@@ -714,9 +768,12 @@ export class ViceBackend {
714
768
  });
715
769
  }
716
770
  async configSet(_category, item, value) {
717
- const numValue = Number(value);
718
- const parsed = !isNaN(numValue) && value.trim() !== "" ? numValue : value;
771
+ let parsed = value;
719
772
  await this.withClient(async (client) => {
773
+ const current = await client.resourceGet(item);
774
+ parsed = current.type === "int" ? Number(value) : value;
775
+ if (current.type === "int" && !Number.isFinite(parsed))
776
+ throw new Error(`VICE resource '${item}' requires an integer value`);
720
777
  await client.resourceSet(item, parsed);
721
778
  });
722
779
  return { success: true, details: { item, value: parsed } };
@@ -728,8 +785,10 @@ export class ViceBackend {
728
785
  for (const [item, value] of Object.entries(items)) {
729
786
  try {
730
787
  const str = String(value ?? "");
731
- const numValue = Number(str);
732
- const parsed = !isNaN(numValue) && str.trim() !== "" ? numValue : str;
788
+ const current = await client.resourceGet(item);
789
+ const parsed = current.type === "int" ? Number(str) : str;
790
+ if (current.type === "int" && !Number.isFinite(parsed))
791
+ throw new Error(`VICE resource '${item}' requires an integer value`);
733
792
  await client.resourceSet(item, parsed);
734
793
  results.push({ item: `${category}/${item}`, success: true });
735
794
  }
@@ -756,6 +815,11 @@ export class ViceBackend {
756
815
  ViceBackend.supervisors = new Map();
757
816
  ViceBackend.cleanupRegistered = false;
758
817
  function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend in phase one`); err.code = "UNSUPPORTED"; return err; }
818
+ /** Ultimate file APIs treat slash as a path separator. Encode each name once,
819
+ * preserving leading and nested separators for firmware routing. */
820
+ function encodeDevicePath(value) {
821
+ return value.split("/").map((segment) => encodeURIComponent(segment)).join("/");
822
+ }
759
823
  function parseDriveNumber(drive) {
760
824
  const match = /\d+/.exec(drive);
761
825
  const n = match ? parseInt(match[0], 10) : NaN;
@@ -885,46 +949,53 @@ export function __buildViceProcessOptionsForTests(input) {
885
949
  return buildManagedViceProcessOptions(input);
886
950
  }
887
951
  export async function createFacade(logger, options) {
952
+ const envMode = (process.env.C64_MODE || "").toLowerCase().trim();
888
953
  // Caller-forced preference: use c64u with provided base URL (used by tests and server wiring)
889
954
  if (options?.preferredC64uBaseUrl) {
955
+ const type = envMode === "u2" ? "u2" : "c64u";
890
956
  const backend = new C64uBackend({
891
957
  baseUrl: options.preferredC64uBaseUrl,
892
958
  networkPassword: options.preferredC64uNetworkPassword,
893
- });
894
- logger?.info?.("Active backend: c64u (forced by caller)");
895
- return { facade: backend, selected: "c64u", reason: "forced by caller", details: { baseUrl: options.preferredC64uBaseUrl } };
959
+ }, type);
960
+ logger?.info?.(`Active backend: ${type} (forced by caller)`);
961
+ return { facade: backend, selected: type, reason: "forced by caller", details: { baseUrl: options.preferredC64uBaseUrl } };
896
962
  }
897
963
  const cfg = readConfigFile();
898
- const envMode = (process.env.C64_MODE || "").toLowerCase().trim();
899
964
  const hasC64u = Boolean(cfg?.c64u);
965
+ const hasU2 = Boolean(cfg?.u2);
900
966
  const hasVice = Boolean(cfg?.vice);
901
967
  if (envMode === "c64u") {
902
968
  const backend = new C64uBackend(cfg?.c64u ?? {});
903
969
  logger?.info?.("Active backend: c64u (from env override)");
904
970
  return { facade: backend, selected: "c64u", reason: "env override", details: { baseUrl: backend.getBaseUrl?.() } };
905
971
  }
972
+ if (envMode === "u2") {
973
+ const backend = new C64uBackend(cfg?.u2 ?? {}, "u2");
974
+ logger?.info?.("Active backend: u2 (from env override)");
975
+ return { facade: backend, selected: "u2", reason: "env override", details: { baseUrl: backend.getBaseUrl?.() } };
976
+ }
906
977
  if (envMode === "vice") {
907
978
  const backend = new ViceBackend(cfg?.vice ?? {});
908
979
  logger?.info?.("Active backend: vice (from env override)");
909
980
  const endpoint = backend.getEndpoint();
910
981
  return { facade: backend, selected: "vice", reason: "env override", details: { host: endpoint.host, port: endpoint.port } };
911
982
  }
912
- if (hasC64u && !hasVice) {
983
+ if (hasC64u) {
913
984
  const backend = new C64uBackend(cfg.c64u);
914
985
  logger?.info?.("Active backend: c64u (from config)");
915
- return { facade: backend, selected: "c64u", reason: "config only", details: { baseUrl: backend.getBaseUrl?.() } };
986
+ return { facade: backend, selected: "c64u", reason: hasVice ? "both defined (prefer c64u)" : "config only", details: { baseUrl: backend.getBaseUrl?.() } };
987
+ }
988
+ if (hasU2) {
989
+ const backend = new C64uBackend(cfg.u2, "u2");
990
+ logger?.info?.("Active backend: u2 (from config)");
991
+ return { facade: backend, selected: "u2", reason: "config only", details: { baseUrl: backend.getBaseUrl?.() } };
916
992
  }
917
- if (!hasC64u && hasVice) {
993
+ if (hasVice) {
918
994
  const backend = new ViceBackend(cfg.vice);
919
995
  logger?.info?.("Active backend: vice (from config)");
920
996
  const endpoint = backend.getEndpoint();
921
997
  return { facade: backend, selected: "vice", reason: "config only", details: { host: endpoint.host, port: endpoint.port } };
922
998
  }
923
- if (hasC64u && hasVice) {
924
- const backend = new C64uBackend(cfg.c64u);
925
- logger?.info?.("Active backend: c64u (both defined; default preference)");
926
- return { facade: backend, selected: "c64u", reason: "both defined (prefer c64u)", details: { baseUrl: backend.getBaseUrl?.() } };
927
- }
928
999
  // No configuration
929
1000
  const probeBase = resolveBaseUrl({});
930
1001
  try {
@@ -942,35 +1013,30 @@ export async function createFacade(logger, options) {
942
1013
  return { facade: backend, selected: "vice", reason: "fallback (hardware unavailable)", details: { host: endpoint.host, port: endpoint.port } };
943
1014
  }
944
1015
  export async function createAllFacades(logger, options) {
945
- const primary = await createFacade(logger);
1016
+ // C64U/VICE preserve config-file selection semantics. U2 needs the
1017
+ // caller-provided endpoint because it is selected through C64_MODE=u2.
1018
+ const primary = await createFacade(logger, process.env.C64_MODE?.toLowerCase().trim() === "u2" ? options : undefined);
946
1019
  const config = readConfigFile();
947
- const secondaryType = primary.selected === "c64u" ? "vice" : "c64u";
948
- if (secondaryType === "c64u" && shouldProvisionSecondaryC64u(config, options)) {
949
- const secondaryConfig = {
1020
+ const facades = new Map([[primary.selected, primary.facade]]);
1021
+ if (config?.c64u && primary.selected !== "c64u") {
1022
+ facades.set("c64u", new C64uBackend(config.c64u));
1023
+ }
1024
+ if (config?.u2 && primary.selected !== "u2") {
1025
+ facades.set("u2", new C64uBackend(config.u2, "u2"));
1026
+ }
1027
+ if (config?.vice && primary.selected !== "vice") {
1028
+ facades.set("vice", new ViceBackend(config.vice));
1029
+ }
1030
+ if (!facades.has("c64u") && !config?.u2 && shouldProvisionSecondaryC64u(config, options) && primary.selected !== "u2") {
1031
+ facades.set("c64u", new C64uBackend({
950
1032
  ...(config?.c64u ?? {}),
951
1033
  ...(!config?.c64u?.baseUrl && options?.preferredC64uBaseUrl ? { baseUrl: options.preferredC64uBaseUrl } : {}),
952
1034
  ...(!config?.c64u?.networkPassword && options?.preferredC64uNetworkPassword
953
1035
  ? { networkPassword: options.preferredC64uNetworkPassword }
954
1036
  : {}),
955
- };
956
- return {
957
- primary,
958
- secondary: new C64uBackend(secondaryConfig),
959
- secondaryType,
960
- };
961
- }
962
- if (secondaryType === "vice" && config?.vice) {
963
- return {
964
- primary,
965
- secondary: new ViceBackend(config.vice),
966
- secondaryType,
967
- };
1037
+ }));
968
1038
  }
969
- return {
970
- primary,
971
- secondary: null,
972
- secondaryType: null,
973
- };
1039
+ return { primary, facades };
974
1040
  }
975
1041
  function shouldProvisionSecondaryC64u(config, options) {
976
1042
  return Boolean(config?.c64u
package/dist/knowledge.js CHANGED
@@ -82,7 +82,7 @@ Key SID Registers (decimal addresses):
82
82
  **PAL vs NTSC Clock Differences:**
83
83
  - PAL: 985,248 Hz SID clock
84
84
  - NTSC: 1,022,727 Hz SID clock
85
- - Formula: freq_value = (frequency_Hz × 65536) / clock_rate
85
+ - Formula: freq_value = round((frequency_Hz × 16777216) / clock_rate) (SID has a 24-bit phase accumulator)
86
86
 
87
87
  **Complete SID Frequency Lookup Table (All Notes, Multiple Octaves):**
88
88
 
@@ -1,5 +1,17 @@
1
1
  import { HttpClient } from "../generated/c64/index.js";
2
2
  import { formatErrorMessage, formatPayloadForDebug, loggerFor, payloadByteLength } from "./logger.js";
3
+ /** Headers are useful in diagnostics, but credentials must never leave the
4
+ * transport layer. Axios normalises some keys and preserves others, so do a
5
+ * case-insensitive pass instead of relying on one spelling. */
6
+ function redactHeaders(headers) {
7
+ if (!headers)
8
+ return headers;
9
+ const sensitive = /^(x-password|authorization|proxy-authorization|cookie|set-cookie|x-api-key)$/i;
10
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [
11
+ key,
12
+ sensitive.test(key) ? "***" : value,
13
+ ]));
14
+ }
3
15
  export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
4
16
  const http = new HttpClient(config);
5
17
  http.instance.interceptors.request.use((request) => {
@@ -9,7 +21,7 @@ export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
9
21
  startedAt: Date.now(),
10
22
  method,
11
23
  path,
12
- headers: request.headers ? { ...request.headers } : undefined,
24
+ headers: redactHeaders(request.headers),
13
25
  params: request.params ? { ...request.params } : undefined,
14
26
  body: request.data,
15
27
  };
@@ -40,7 +52,7 @@ function handleResponse(response, logger) {
40
52
  });
41
53
  logger.debug(`response ${method} ${path}`, {
42
54
  status: response.status,
43
- headers: response.headers ?? {},
55
+ headers: redactHeaders(response.headers),
44
56
  body: formatPayloadForDebug(response.data),
45
57
  });
46
58
  }
@@ -68,7 +80,7 @@ function handleError(error, logger) {
68
80
  });
69
81
  logger.debug(`error ${method} ${path}`, {
70
82
  status,
71
- headers: response?.headers ?? {},
83
+ headers: redactHeaders(response?.headers),
72
84
  body: response ? formatPayloadForDebug(response.data) : null,
73
85
  message,
74
86
  });