c64bridge 1.0.4 → 1.0.6

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 (63) hide show
  1. package/README.md +2 -2
  2. package/data/embeddings_basic.json +1 -1
  3. package/data/embeddings_hardware.json +1 -1
  4. package/data/embeddings_mixed.json +1 -1
  5. package/data/embeddings_other.json +1 -1
  6. package/data/graphics/character-set.csv +64 -64
  7. package/data/sound/sidwave.md +113 -36
  8. package/data/sound/sidwave.schema.json +138 -28
  9. package/dist/.tsbuildinfo +1 -1
  10. package/dist/c64Client.js +144 -150
  11. package/dist/config.js +93 -39
  12. package/dist/device.js +252 -130
  13. package/dist/diagnostics.js +10 -2
  14. package/dist/knowledge.js +2 -1
  15. package/dist/mcp-server.js +12 -4
  16. package/dist/petscii.js +18 -15
  17. package/dist/petsciiArt.js +40 -14
  18. package/dist/rag/indexer.js +2 -1
  19. package/dist/sidwave.js +453 -61
  20. package/dist/sidwaveCompiler.js +1424 -275
  21. package/dist/tools/audio.js +97 -18
  22. package/dist/tools/debug.js +9 -9
  23. package/dist/tools/developer.js +7 -7
  24. package/dist/tools/errors.js +19 -1
  25. package/dist/tools/graphics.js +4 -4
  26. package/dist/tools/hex.js +9 -0
  27. package/dist/tools/input.js +21 -18
  28. package/dist/tools/machineControl.js +6 -6
  29. package/dist/tools/memory.js +11 -10
  30. package/dist/tools/meta/audio.js +8 -2
  31. package/dist/tools/meta/background.js +6 -1
  32. package/dist/tools/meta/filesystem.js +37 -10
  33. package/dist/tools/meta/graphics.js +11 -16
  34. package/dist/tools/meta/memory.js +16 -22
  35. package/dist/tools/meta/program.js +28 -9
  36. package/dist/tools/pollValidator.js +30 -2
  37. package/dist/tools/printer.js +4 -4
  38. package/dist/tools/programRunners.js +10 -9
  39. package/dist/tools/registry/disk.js +7 -5
  40. package/dist/tools/storage.js +21 -17
  41. package/dist/tools/streaming.js +2 -2
  42. package/dist/tools/translation/assembler.js +192 -57
  43. package/dist/tools/translation/basicTokenizer.js +44 -10
  44. package/dist/tools/translation/opcodeInventory.js +1 -1
  45. package/dist/tools/types.js +11 -4
  46. package/dist/tools/vice.js +1 -1
  47. package/dist/vicBitmap.js +16 -0
  48. package/dist/vice/mockServer.js +99 -8
  49. package/dist/vice/readiness.js +4 -9
  50. package/dist/vice/viceClient.js +34 -6
  51. package/mcp/schemas/c64_disk.schema.json +2 -2
  52. package/mcp/schemas/c64_input.schema.json +3 -3
  53. package/mcp/schemas/c64_program.schema.json +2 -2
  54. package/mcp/schemas/c64_system.schema.json +6 -4
  55. package/mcp/schemas/c64_vice.schema.json +3 -3
  56. package/mcp/server.json +1 -1
  57. package/mcp/tools.json +20 -14
  58. package/mcp.json +1 -1
  59. package/package.json +1 -1
  60. package/scripts/mockC64Server.mjs +113 -20
  61. package/scripts/run-coverage.mjs +31 -1
  62. package/scripts/update-readme.ts +3 -2
  63. package/server.json +2 -2
package/dist/device.js CHANGED
@@ -9,10 +9,9 @@ import { Api } from "../generated/c64/index.js";
9
9
  import { createLoggingHttpClient } from "./loggingHttpClient.js";
10
10
  import { ViceClient } from "./vice/viceClient.js";
11
11
  import { waitForBasicReady } from "./vice/readiness.js";
12
+ import { screenCodesToAscii } from "./petscii.js";
12
13
  import { startViceProcess } from "./vice/process.js";
13
- import { loadConfig } from "./config.js";
14
- const DEFAULT_C64U_HOST = "c64u";
15
- const DEFAULT_C64U_PORT = 80;
14
+ import { loadConfig, resolveUltimateEndpoint } from "./config.js";
16
15
  const DEFAULT_VICE_HOST = "127.0.0.1";
17
16
  const DEFAULT_VICE_PORT = 6502;
18
17
  function delay(ms) {
@@ -52,28 +51,44 @@ function coalesceMemoryWriteBlocks(blocks) {
52
51
  }
53
52
  return merged;
54
53
  }
54
+ /** All C64U/U2 endpoints come from loadConfig(), so the MCP server and every
55
+ * backend agree on host, port and password. */
55
56
  function readConfigFile() {
56
- const sections = loadConfig().backendConfig;
57
- return Object.keys(sections).length === 0 ? null : {
58
- c64u: sections.c64u,
59
- u2: sections.u2,
57
+ const config = loadConfig();
58
+ const sections = config.backendConfig;
59
+ return {
60
+ c64u: sections.c64u || config.legacyUltimateEndpoint ? config.ultimate.c64u : undefined,
61
+ u2: sections.u2 ? config.ultimate.u2 : undefined,
60
62
  vice: sections.vice,
63
+ endpoints: config.ultimate,
61
64
  };
62
65
  }
66
+ /** Firmware drive ids (route_drives.h DriveToSubsys, matched case-insensitively). */
67
+ const FIRMWARE_DRIVE_IDS = new Set(["a", "b", "softiec"]);
68
+ /** GET /v1/drives reports the soft IEC drive under this name; the drive routes call it softiec. */
69
+ const SOFT_IEC_LISTING_ID = "iec drive";
70
+ /** Factory bus numbers of drives a and b, used when the drive listing is unavailable. */
71
+ const DEFAULT_DRIVE_BY_BUS_ID = { 8: "a", 9: "b" };
72
+ /** The firmware reports a mounted image as directory (image_path) plus file name (image_file). */
73
+ function driveImage(info) {
74
+ const file = typeof info.image_file === "string" ? info.image_file : "";
75
+ const dir = typeof info.image_path === "string" ? info.image_path : "";
76
+ if (file)
77
+ return dir ? `${dir.replace(/\/+$/, "")}/${file}` : file;
78
+ if (dir)
79
+ return dir;
80
+ return typeof info.image === "string" && info.image ? info.image : null;
81
+ }
82
+ function uploadFile(bytes, name) {
83
+ return new File([Uint8Array.from(bytes)], name, { type: "application/octet-stream" });
84
+ }
63
85
  class C64uBackend {
64
- constructor(config, type = "c64u") {
86
+ constructor(endpoint, type = "c64u") {
65
87
  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);
68
- const configBaseUrl = normaliseBaseUrl(config.baseUrl);
69
- const parsedConfigBaseUrl = configBaseUrl ? parseEndpoint(configBaseUrl) : {};
70
- const baseUrl = envHost !== undefined || envPort !== undefined
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)
72
- : resolveBaseUrl(config);
73
- this.baseUrl = baseUrl;
74
- this.networkPassword = firstDefined(configuredString(type === "u2" ? process.env.U2_PASSWORD ?? process.env.C64U_PASSWORD : process.env.C64U_PASSWORD), configuredString(config.networkPassword));
88
+ this.baseUrl = endpoint.baseUrl;
89
+ this.networkPassword = endpoint.networkPassword;
75
90
  const http = createLoggingHttpClient({
76
- baseURL: baseUrl,
91
+ baseURL: this.baseUrl,
77
92
  timeout: 10000,
78
93
  headers: buildC64uHeaders(this.networkPassword),
79
94
  });
@@ -132,10 +147,15 @@ class C64uBackend {
132
147
  return this.actionResult(res.data);
133
148
  }
134
149
  async sidplayAttachment(sid, options) {
135
- const form = { sid: Buffer.isBuffer(sid) ? sid : Buffer.from(sid) };
150
+ // The generated multipart builder JSON-encodes anything that is not a Blob,
151
+ // so the bytes must be File parts. The firmware ignores part names and
152
+ // takes the first file as the SID and the second as song lengths; the spec
153
+ // names both `file`. The temp file keeps the upload name, and its extension
154
+ // selects SID versus MUS playback.
155
+ const file = [uploadFile(sid, "upload.sid")];
136
156
  if (options?.songlengths)
137
- form.songlengths = Buffer.isBuffer(options.songlengths) ? options.songlengths : Buffer.from(options.songlengths);
138
- const res = await this.api.v1.runnersSidplayCreate(":sidplay", form, options?.songnr !== undefined ? { songnr: options.songnr } : undefined);
157
+ file.push(uploadFile(options.songlengths, "upload.ssl"));
158
+ const res = await this.api.v1.runnersSidplayCreate(":sidplay", { file }, options?.songnr !== undefined ? { songnr: options.songnr } : undefined);
139
159
  return this.actionResult(res.data);
140
160
  }
141
161
  async readMemory(address, length) {
@@ -196,32 +216,75 @@ class C64uBackend {
196
216
  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 }; }
197
217
  async version() { const res = await this.api.v1.versionList(); return res.data; }
198
218
  async info() { const res = await this.api.v1.infoList(); return res.data; }
199
- async drivesList() {
219
+ async driveEntries() {
200
220
  const res = await this.api.v1.drivesList();
201
221
  const raw = res.data;
202
222
  const entries = Array.isArray(raw.drives)
203
223
  ? raw.drives
204
224
  : raw.drives && typeof raw.drives === "object" ? [raw.drives] : [];
205
- return entries.flatMap((entry) => Object.entries(entry).map(([id, info]) => ({
225
+ return entries.flatMap((entry) => Object.entries(entry));
226
+ }
227
+ /**
228
+ * Map a drive id to the firmware's `a`, `b` or `softiec`. Bus numbers such
229
+ * as `drive8` or `8` are looked up by `bus_id` among drives a and b in
230
+ * GET /v1/drives, because a drive letter's bus number is configurable; the
231
+ * factory a=8, b=9 applies only when the listing is unavailable. The soft
232
+ * IEC drive is reachable only by name, never by bus number.
233
+ */
234
+ async resolveDriveId(drive) {
235
+ const lower = drive.trim().toLowerCase();
236
+ if (FIRMWARE_DRIVE_IDS.has(lower))
237
+ return lower;
238
+ if (lower === SOFT_IEC_LISTING_ID)
239
+ return "softiec";
240
+ const match = /^(?:drive)?\s*(\d+)$/.exec(lower);
241
+ if (!match)
242
+ return drive;
243
+ const busId = Number(match[1]);
244
+ let listed = [];
245
+ try {
246
+ listed = (await this.driveEntries()).flatMap(([id, info]) => {
247
+ const listedBusId = Number(info?.bus_id);
248
+ return (id === "a" || id === "b") && Number.isInteger(listedBusId) ? [{ id, busId: listedBusId, enabled: info?.enabled !== false }] : [];
249
+ });
250
+ }
251
+ catch {
252
+ listed = [];
253
+ }
254
+ // A disabled drive keeps its bus_id setting, so prefer the enabled drive the C64 actually sees on that bus.
255
+ const onBus = listed.filter((entry) => entry.busId === busId);
256
+ const resolved = listed.length > 0 ? (onBus.find((entry) => entry.enabled) ?? onBus[0])?.id : DEFAULT_DRIVE_BY_BUS_ID[busId];
257
+ if (!resolved) {
258
+ const known = listed.length > 0 ? listed.map((entry) => `${entry.id}=${entry.busId}`).join(", ") : "a=8, b=9";
259
+ throw new Error(`No drive found for '${drive}' (bus IDs: ${known})`);
260
+ }
261
+ return resolved;
262
+ }
263
+ async drivesList() {
264
+ const entries = await this.driveEntries();
265
+ return entries.map(([id, info]) => ({
206
266
  id,
207
267
  power: info.enabled === true || info.enabled === "on" || info.power === "on" ? "on" : "off",
208
- image: (info.image_path ?? info.image_file ?? info.image ?? null),
268
+ image: driveImage(info),
209
269
  type: info.type ?? null,
210
270
  raw: info,
211
- })));
271
+ }));
212
272
  }
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); }
273
+ async driveMount(d, img, options) { const res = await this.api.v1.drivesMountUpdate(await this.resolveDriveId(d), ":mount", { image: img, type: options?.type, mode: options?.mode }); return this.actionResult(res.data); }
274
+ async driveRemove(d) { const res = await this.api.v1.drivesRemoveUpdate(await this.resolveDriveId(d), ":remove"); return this.actionResult(res.data); }
275
+ async driveReset(d) { const res = await this.api.v1.drivesResetUpdate(await this.resolveDriveId(d), ":reset"); return this.actionResult(res.data); }
276
+ async driveOn(d) { const res = await this.api.v1.drivesOnUpdate(await this.resolveDriveId(d), ":on"); return this.actionResult(res.data); }
277
+ async driveOff(d) { const res = await this.api.v1.drivesOffUpdate(await this.resolveDriveId(d), ":off"); return this.actionResult(res.data); }
278
+ async driveSetMode(d, mode) { const res = await this.api.v1.drivesSetModeUpdate(await this.resolveDriveId(d), ":set_mode", { mode }); return this.actionResult(res.data); }
279
+ async driveLoadRom(d, romPath) { const res = await this.api.v1.drivesLoadRomUpdate(await this.resolveDriveId(d), ":load_rom", { file: romPath }); return this.actionResult(res.data); }
220
280
  async streamStart(s, ip) { this.requireC64u("streamStart"); const res = await this.api.v1.streamsStartUpdate(s, ":start", { ip }); return this.actionResult(res.data); }
221
281
  async streamStop(s) { this.requireC64u("streamStop"); const res = await this.api.v1.streamsStopUpdate(s, ":stop"); return this.actionResult(res.data); }
222
282
  async configsList() { const res = await this.api.v1.configsList(); return res.data; }
223
- async configGet(cat, item) { const res = item ? await this.api.v1.configsDetail2(cat, item) : await this.api.v1.configsDetail(cat); return res.data; }
224
- async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(cat, item, { value }); return this.actionResult(res.data); }
283
+ // The generated client interpolates path parameters unencoded and the firmware
284
+ // splits the path on "/" before decoding, so names such as "Bus Sharing - I/O1"
285
+ // must be percent-encoded here.
286
+ async configGet(cat, item) { const res = item ? await this.api.v1.configsDetail2(encodeURIComponent(cat), encodeURIComponent(item)) : await this.api.v1.configsDetail(encodeURIComponent(cat)); return res.data; }
287
+ async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(encodeURIComponent(cat), encodeURIComponent(item), { value }); return this.actionResult(res.data); }
225
288
  async configBatchUpdate(payload) { const res = await this.api.v1.configsCreate(payload); return this.actionResult(res.data); }
226
289
  async configLoadFromFlash() { const res = await this.api.v1.configsLoadFromFlashUpdate(":load_from_flash"); return this.actionResult(res.data); }
227
290
  async configSaveToFlash() { const res = await this.api.v1.configsSaveToFlashUpdate(":save_to_flash"); return this.actionResult(res.data); }
@@ -240,6 +303,12 @@ export class ViceBackend {
240
303
  this.startupPromise = null;
241
304
  this.monitorQueue = Promise.resolve();
242
305
  this.lastProcessStart = 0;
306
+ // VICE has no "drive enabled" or "attached image" resource. A drive is off
307
+ // when Drive<n>Type is 0, and images attached through this backend are
308
+ // remembered here because VICE cannot report them.
309
+ this.attachedImages = new Map();
310
+ this.savedDriveTypes = new Map();
311
+ this.trackedStateProcessStart = 0;
243
312
  ViceBackend.ensureCleanupRegistration();
244
313
  const envBinary = configuredString(process.env.VICE_BINARY);
245
314
  const configBinary = config.exe !== undefined
@@ -672,19 +741,27 @@ export class ViceBackend {
672
741
  getEndpoint() {
673
742
  return { host: this.host, port: this.port };
674
743
  }
744
+ /** A restarted VICE has no images attached and default drive types, so forget what was tracked before. */
745
+ syncTrackedState() {
746
+ if (this.trackedStateProcessStart !== this.lastProcessStart) {
747
+ this.attachedImages.clear();
748
+ this.savedDriveTypes.clear();
749
+ this.trackedStateProcessStart = this.lastProcessStart;
750
+ }
751
+ }
675
752
  async drivesList() {
753
+ this.syncTrackedState();
676
754
  return await this.withClient(async (client) => {
677
755
  const drives = [];
678
756
  for (const n of [8, 9, 10, 11]) {
679
757
  try {
680
- const enabled = await client.resourceGet(`Drive${n}CPUEnabled`);
681
- const image = await client.resourceGet(`Drive${n}Image`);
682
758
  const typeRes = await client.resourceGet(`Drive${n}Type`);
759
+ const type = typeRes.type === "int" ? typeRes.value : Number(typeRes.value);
683
760
  drives.push({
684
761
  id: `drive${n}`,
685
- power: (typeof enabled.value === "number" ? enabled.value : 0) ? "on" : "off",
686
- image: typeof image.value === "string" && image.value ? image.value : null,
687
- type: typeRes.value,
762
+ power: type !== 0 ? "on" : "off",
763
+ image: this.attachedImages.get(n) ?? null,
764
+ type,
688
765
  });
689
766
  }
690
767
  catch {
@@ -699,44 +776,83 @@ export class ViceBackend {
699
776
  throw unsupported("driveMount type/mode overrides; omit these options on VICE");
700
777
  }
701
778
  const n = parseDriveNumber(drive);
702
- await this.withClient(async (client) => {
703
- await client.resourceSet(`Drive${n}CPUEnabled`, 1);
704
- await client.resourceSet(`Drive${n}Image`, imagePath);
779
+ if (n !== 8) {
780
+ // The Binary Monitor's only attach path is autostart, which always uses drive 8.
781
+ throw unsupported(`driveMount on drive ${n}; VICE can attach images over the Binary Monitor only to drive 8`);
782
+ }
783
+ // Autostart falls back to tape, snapshot, cartridge and PRG handling when a
784
+ // file is not a disk image, so only disk images are passed on.
785
+ if (!VICE_DISK_IMAGE_EXTENSIONS.has(path.extname(imagePath).toLowerCase())) {
786
+ throw new Error(`VICE can mount only disk images (${[...VICE_DISK_IMAGE_EXTENSIONS].join(", ")}): ${imagePath}`);
787
+ }
788
+ if (!fs.existsSync(imagePath)) {
789
+ throw new Error(`Disk image not found on the VICE host: ${imagePath}`);
790
+ }
791
+ const outcome = await this.withClient(async (client) => {
792
+ // Sync after withClient has started VICE, or a start here would clear this mount's record.
793
+ this.syncTrackedState();
794
+ await this.ensureDriveOn(client, n);
795
+ await client.memSet(AUTOLOAD_MARKER_ADDRESS, AUTOLOAD_MARKER);
796
+ await client.autostart(imagePath, { run: false });
797
+ // Autoload power-cycles the machine after this call returns; wait until
798
+ // its LOAD has finished so callers do not write into a machine that is
799
+ // about to be reset.
800
+ return waitForAutoloadFinished(client, 30000);
705
801
  });
706
- return { success: true, details: { drive, image: imagePath } };
802
+ this.attachedImages.set(n, imagePath);
803
+ return {
804
+ success: true,
805
+ details: {
806
+ drive,
807
+ image: imagePath,
808
+ loadedFirstProgram: outcome.loaded,
809
+ note: outcome.loaded
810
+ ? "VICE attaches images through autoload, which resets the machine (clearing RAM) and LOADs the first program without running it."
811
+ : "VICE attaches images through autoload, which resets the machine (clearing RAM); the disk has no program to load.",
812
+ },
813
+ };
707
814
  }
708
- async driveRemove(drive) {
709
- const n = parseDriveNumber(drive);
710
- await this.withClient(async (client) => {
711
- await client.resourceSet(`Drive${n}Image`, "");
712
- });
713
- return { success: true, details: { drive } };
815
+ async driveRemove(_drive) {
816
+ throw unsupported("driveRemove; the VICE Binary Monitor has no command to detach a disk image");
714
817
  }
715
818
  async driveReset(drive) {
716
819
  const n = parseDriveNumber(drive);
717
820
  await this.withClient(async (client) => {
718
- await client.resourceSet(`Drive${n}CPUEnabled`, 0);
719
- await client.resourceSet(`Drive${n}CPUEnabled`, 1);
821
+ await client.resetDrive(n);
720
822
  });
721
823
  return { success: true, details: { drive } };
722
824
  }
723
825
  async driveOn(drive) {
724
826
  const n = parseDriveNumber(drive);
725
827
  await this.withClient(async (client) => {
726
- await client.resourceSet(`Drive${n}CPUEnabled`, 1);
828
+ await this.ensureDriveOn(client, n);
727
829
  });
728
830
  return { success: true, details: { drive, power: "on" } };
729
831
  }
730
832
  async driveOff(drive) {
731
833
  const n = parseDriveNumber(drive);
834
+ this.syncTrackedState();
732
835
  await this.withClient(async (client) => {
733
- await client.resourceSet(`Drive${n}CPUEnabled`, 0);
836
+ const current = await client.resourceGet(`Drive${n}Type`);
837
+ const type = current.type === "int" ? current.value : Number(current.value);
838
+ if (type !== 0)
839
+ this.savedDriveTypes.set(n, type);
840
+ await client.resourceSet(`Drive${n}Type`, 0);
734
841
  });
735
842
  return { success: true, details: { drive, power: "off" } };
736
843
  }
844
+ /** Switch a drive on by giving it a type again: the one it had before driveOff, or a 1541. */
845
+ async ensureDriveOn(client, n) {
846
+ const current = await client.resourceGet(`Drive${n}Type`);
847
+ const type = current.type === "int" ? current.value : Number(current.value);
848
+ if (type === 0) {
849
+ await client.resourceSet(`Drive${n}Type`, this.savedDriveTypes.get(n) ?? 1541);
850
+ }
851
+ }
737
852
  async driveSetMode(drive, mode) {
738
853
  const n = parseDriveNumber(drive);
739
- const DRIVE_TYPE = { "1541": 2, "1571": 8, "1581": 11 };
854
+ // Drive<n>Type takes the model number itself (DRIVE_TYPE_1541 = 1541 in VICE's drive.h).
855
+ const DRIVE_TYPE = { "1541": 1541, "1571": 1571, "1581": 1581 };
740
856
  const typeNum = DRIVE_TYPE[mode];
741
857
  if (typeNum === undefined)
742
858
  throw new Error(`Unknown drive mode: ${mode}`);
@@ -753,10 +869,11 @@ export class ViceBackend {
753
869
  categories: [
754
870
  {
755
871
  name: "VICE",
872
+ // Only resources that exist in VICE 3.10; checked against a running x64sc.
873
+ // VICE has no warp, drive-enable or attached-image resources.
756
874
  items: [
757
- "WarpMode", "SoundVolume", "Drive8CPUEnabled", "Drive9CPUEnabled",
758
- "Drive10CPUEnabled", "Drive11CPUEnabled", "Drive8Image", "Drive9Image",
759
- "Drive8Type", "Drive9Type", "VICIIBorderMode", "VICIIFullscreen",
875
+ "SoundVolume", "Drive8Type", "Drive9Type", "Drive10Type", "Drive11Type",
876
+ "VICIIBorderMode", "VICIIFullscreen", "MachineVideoStandard", "SidModel",
760
877
  ],
761
878
  },
762
879
  ],
@@ -832,13 +949,69 @@ function parseViceConfigValue(value, type, item) {
832
949
  }
833
950
  return parsed;
834
951
  }
835
- function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend in phase one`); err.code = "UNSUPPORTED"; return err; }
952
+ function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend`); err.code = "UNSUPPORTED"; return err; }
836
953
  /** Ultimate file APIs treat slash as a path separator. Encode each name once,
837
954
  * preserving leading and nested separators for firmware routing. */
838
955
  function encodeDevicePath(value) {
839
956
  return value.split("/").map((segment) => encodeURIComponent(segment)).join("/");
840
957
  }
958
+ /** Disk image types VICE's drive 8 can attach (autostart would treat anything else as tape, cartridge or PRG). */
959
+ const VICE_DISK_IMAGE_EXTENSIONS = new Set([".d64", ".g64", ".d71", ".g71", ".d81", ".d80", ".d82", ".x64", ".p64", ".d1m", ".d2m", ".d4m"]);
960
+ /**
961
+ * Bytes written to unused RAM ($02A7-$02AA) before an autoload. The autoload's
962
+ * power cycle clears them, which shows that the reset has happened.
963
+ */
964
+ const AUTOLOAD_MARKER_ADDRESS = 0x02a7;
965
+ const AUTOLOAD_MARKER = Buffer.from([0xc6, 0x4b, 0x1d, 0x9e]);
966
+ /**
967
+ * Wait until VICE's autoload has reset the machine and its LOAD has finished.
968
+ * Completion is the LOAD"/READY. text on screen, or, when the loaded program
969
+ * covers screen RAM, BASIC initialised (TXTTAB $0801) with its end-of-program
970
+ * pointer (VARTAB) moved off the reset value $0803 and an empty keyboard buffer.
971
+ */
972
+ async function waitForAutoloadFinished(client, timeoutMs) {
973
+ const deadline = Date.now() + timeoutMs;
974
+ let loadedEnd = null;
975
+ while (Date.now() < deadline) {
976
+ await client.exitMonitor();
977
+ await delay(250);
978
+ const marker = await client.memGet(AUTOLOAD_MARKER_ADDRESS, AUTOLOAD_MARKER_ADDRESS + AUTOLOAD_MARKER.length - 1);
979
+ if (Buffer.from(marker).equals(AUTOLOAD_MARKER))
980
+ continue; // not reset yet
981
+ const screen = screenCodesToAscii(await client.memGet(0x0400, 0x0400 + 999));
982
+ const load = screen.lastIndexOf('LOAD"');
983
+ if (load >= 0) {
984
+ const after = screen.slice(load);
985
+ // An empty disk is still attached; it just has no program for the autoload to load.
986
+ if (/\?FILE NOT FOUND/.test(after))
987
+ return { loaded: false };
988
+ const error = /\?([A-Z ]+ERROR|DEVICE NOT PRESENT)/.exec(after);
989
+ if (error)
990
+ throw new Error(`VICE autoload failed: ?${error[1]}`);
991
+ if (after.includes("READY."))
992
+ return { loaded: true };
993
+ }
994
+ // TXTTAB = $0801 shows BASIC has initialised (right after the power cycle RAM
995
+ // holds VICE's init pattern); VARTAB off $0803 then shows the LOAD has ended.
996
+ const pointers = await client.memGet(0x002b, 0x002e);
997
+ const keyboardQueue = await client.memGet(0x00c6, 0x00c6);
998
+ const txttab = (pointers[0] ?? 0) | ((pointers[1] ?? 0) << 8);
999
+ const vartab = (pointers[2] ?? 0) | ((pointers[3] ?? 0) << 8);
1000
+ // BASIC's cold start sets TXTTAB before VARTAB, so require the same end
1001
+ // pointer on two consecutive polls before trusting it.
1002
+ const candidate = txttab === 0x0801 && vartab !== 0x0803 && keyboardQueue[0] === 0 ? vartab : null;
1003
+ if (candidate !== null && candidate === loadedEnd)
1004
+ return { loaded: true };
1005
+ loadedEnd = candidate;
1006
+ }
1007
+ throw new Error("VICE autoload did not finish within the time limit");
1008
+ }
1009
+ /** C64U drive letters with their factory bus numbers, so one drive id works on both backends. */
1010
+ const VICE_DRIVE_LETTERS = { a: 8, b: 9 };
841
1011
  function parseDriveNumber(drive) {
1012
+ const key = drive.trim().toLowerCase();
1013
+ if (Object.prototype.hasOwnProperty.call(VICE_DRIVE_LETTERS, key))
1014
+ return VICE_DRIVE_LETTERS[key];
842
1015
  const match = /\d+/.exec(drive);
843
1016
  const n = match ? parseInt(match[0], 10) : NaN;
844
1017
  if (isNaN(n) || n < 8 || n > 11)
@@ -971,29 +1144,29 @@ export async function createFacade(logger, options) {
971
1144
  // Caller-forced preference: use c64u with provided base URL (used by tests and server wiring)
972
1145
  if (options?.preferredC64uBaseUrl) {
973
1146
  const type = envMode === "u2" ? "u2" : "c64u";
974
- const backend = new C64uBackend({
1147
+ const backend = new C64uBackend(resolveUltimateEndpoint(type, {
975
1148
  baseUrl: options.preferredC64uBaseUrl,
976
1149
  networkPassword: options.preferredC64uNetworkPassword,
977
- }, type);
1150
+ }), type);
978
1151
  logger?.info?.(`Active backend: ${type} (forced by caller)`);
979
1152
  return { facade: backend, selected: type, reason: "forced by caller", details: { baseUrl: options.preferredC64uBaseUrl } };
980
1153
  }
981
1154
  const cfg = readConfigFile();
982
- const hasC64u = Boolean(cfg?.c64u);
983
- const hasU2 = Boolean(cfg?.u2);
984
- const hasVice = Boolean(cfg?.vice);
1155
+ const hasC64u = Boolean(cfg.c64u);
1156
+ const hasU2 = Boolean(cfg.u2);
1157
+ const hasVice = Boolean(cfg.vice);
985
1158
  if (envMode === "c64u") {
986
- const backend = new C64uBackend(cfg?.c64u ?? {});
1159
+ const backend = new C64uBackend(cfg.endpoints.c64u);
987
1160
  logger?.info?.("Active backend: c64u (from env override)");
988
1161
  return { facade: backend, selected: "c64u", reason: "env override", details: { baseUrl: backend.getBaseUrl?.() } };
989
1162
  }
990
1163
  if (envMode === "u2") {
991
- const backend = new C64uBackend(cfg?.u2 ?? {}, "u2");
1164
+ const backend = new C64uBackend(cfg.endpoints.u2, "u2");
992
1165
  logger?.info?.("Active backend: u2 (from env override)");
993
1166
  return { facade: backend, selected: "u2", reason: "env override", details: { baseUrl: backend.getBaseUrl?.() } };
994
1167
  }
995
1168
  if (envMode === "vice") {
996
- const backend = new ViceBackend(cfg?.vice ?? {});
1169
+ const backend = new ViceBackend(cfg.vice ?? {});
997
1170
  logger?.info?.("Active backend: vice (from env override)");
998
1171
  const endpoint = backend.getEndpoint();
999
1172
  return { facade: backend, selected: "vice", reason: "env override", details: { host: endpoint.host, port: endpoint.port } };
@@ -1015,17 +1188,17 @@ export async function createFacade(logger, options) {
1015
1188
  return { facade: backend, selected: "vice", reason: "config only", details: { host: endpoint.host, port: endpoint.port } };
1016
1189
  }
1017
1190
  // No configuration
1018
- const probeBase = resolveBaseUrl({});
1191
+ const probeBase = cfg.endpoints.c64u.baseUrl;
1019
1192
  try {
1020
1193
  const res = await axios.get(probeBase, { timeout: 1500 });
1021
1194
  if (res.status >= 200 && res.status < 500) {
1022
- const backend = new C64uBackend({ baseUrl: probeBase });
1195
+ const backend = new C64uBackend(cfg.endpoints.c64u);
1023
1196
  logger?.info?.("Active backend: c64u (fallback – hardware reachable)");
1024
1197
  return { facade: backend, selected: "c64u", reason: "fallback (reachable)", details: { baseUrl: probeBase } };
1025
1198
  }
1026
1199
  }
1027
1200
  catch { }
1028
- const backend = new ViceBackend(cfg?.vice ?? {});
1201
+ const backend = new ViceBackend(cfg.vice ?? {});
1029
1202
  logger?.info?.("Active backend: vice (fallback – hardware unavailable)");
1030
1203
  const endpoint = backend.getEndpoint();
1031
1204
  return { facade: backend, selected: "vice", reason: "fallback (hardware unavailable)", details: { host: endpoint.host, port: endpoint.port } };
@@ -1036,49 +1209,31 @@ export async function createAllFacades(logger, options) {
1036
1209
  const primary = await createFacade(logger, process.env.C64_MODE?.toLowerCase().trim() === "u2" ? options : undefined);
1037
1210
  const config = readConfigFile();
1038
1211
  const facades = new Map([[primary.selected, primary.facade]]);
1039
- if (config?.c64u && primary.selected !== "c64u") {
1212
+ if (config.c64u && primary.selected !== "c64u") {
1040
1213
  facades.set("c64u", new C64uBackend(config.c64u));
1041
1214
  }
1042
- if (config?.u2 && primary.selected !== "u2") {
1215
+ if (config.u2 && primary.selected !== "u2") {
1043
1216
  facades.set("u2", new C64uBackend(config.u2, "u2"));
1044
1217
  }
1045
- if (config?.vice && primary.selected !== "vice") {
1218
+ if (config.vice && primary.selected !== "vice") {
1046
1219
  facades.set("vice", new ViceBackend(config.vice));
1047
1220
  }
1048
- if (!facades.has("c64u") && !config?.u2 && shouldProvisionSecondaryC64u(config, options) && primary.selected !== "u2") {
1049
- facades.set("c64u", new C64uBackend({
1050
- ...(config?.c64u ?? {}),
1051
- ...(!config?.c64u?.baseUrl && options?.preferredC64uBaseUrl ? { baseUrl: options.preferredC64uBaseUrl } : {}),
1052
- ...(!config?.c64u?.networkPassword && options?.preferredC64uNetworkPassword
1053
- ? { networkPassword: options.preferredC64uNetworkPassword }
1054
- : {}),
1055
- }));
1221
+ // Reached only without a configured c64u profile, so the caller's endpoint applies.
1222
+ if (!facades.has("c64u") && !config.u2 && shouldProvisionSecondaryC64u(config, options) && primary.selected !== "u2") {
1223
+ facades.set("c64u", new C64uBackend(resolveUltimateEndpoint("c64u", {
1224
+ baseUrl: options?.preferredC64uBaseUrl,
1225
+ networkPassword: options?.preferredC64uNetworkPassword,
1226
+ })));
1056
1227
  }
1057
1228
  return { primary, facades };
1058
1229
  }
1059
1230
  function shouldProvisionSecondaryC64u(config, options) {
1060
- return Boolean(config?.c64u
1231
+ return Boolean(config.c64u
1061
1232
  || options?.preferredC64uBaseUrl
1062
1233
  || configuredString(process.env.C64U_HOST)
1063
1234
  || configuredPort(process.env.C64U_PORT)
1064
1235
  || configuredString(process.env.C64U_PASSWORD));
1065
1236
  }
1066
- function resolveBaseUrl(config) {
1067
- const explicit = normaliseBaseUrl(config.baseUrl);
1068
- if (explicit)
1069
- return explicit;
1070
- const hostEntries = [configuredString(config.host), configuredString(config.hostname)];
1071
- for (const entry of hostEntries) {
1072
- if (!entry)
1073
- continue;
1074
- const parsed = parseEndpoint(entry);
1075
- if (parsed.hostname) {
1076
- const port = firstDefined(configuredPort(config.port), parsed.port) ?? DEFAULT_C64U_PORT;
1077
- return buildBaseUrl(parsed.hostname, port);
1078
- }
1079
- }
1080
- return buildBaseUrl(DEFAULT_C64U_HOST, DEFAULT_C64U_PORT);
1081
- }
1082
1237
  function configuredString(value) {
1083
1238
  if (typeof value === "string") {
1084
1239
  const trimmed = value.trim();
@@ -1188,42 +1343,9 @@ function configuredPort(value) {
1188
1343
  }
1189
1344
  return undefined;
1190
1345
  }
1191
- function normaliseBaseUrl(value) {
1192
- const input = configuredString(value);
1193
- if (!input)
1194
- return undefined;
1195
- if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) {
1196
- return `http://${input}`;
1197
- }
1198
- return stripTrailingSlash(input);
1199
- }
1200
- function parseEndpoint(value) {
1201
- try {
1202
- const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
1203
- const url = new URL(hasScheme ? value : `http://${value}`);
1204
- const hostname = url.hostname || undefined;
1205
- const port = url.port ? configuredPort(url.port) : undefined;
1206
- return { hostname, port };
1207
- }
1208
- catch {
1209
- return {};
1210
- }
1211
- }
1212
- function buildBaseUrl(host, port) {
1213
- const normalizedPort = Number.isInteger(port) && port > 0 ? port : DEFAULT_C64U_PORT;
1214
- const hostPart = formatHost(host);
1215
- const suffix = normalizedPort === DEFAULT_C64U_PORT ? "" : `:${normalizedPort}`;
1216
- return `http://${hostPart}${suffix}`;
1217
- }
1218
1346
  function buildC64uHeaders(networkPassword) {
1219
1347
  return networkPassword ? { "X-Password": networkPassword } : undefined;
1220
1348
  }
1221
- function formatHost(host) {
1222
- return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
1223
- }
1224
- function stripTrailingSlash(input) {
1225
- return input.replace(/\/+$/, "");
1226
- }
1227
1349
  function parseArgsList(input) {
1228
1350
  const args = [];
1229
1351
  const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
@@ -137,8 +137,16 @@ export function readDiagnosticRecords(filePath) {
137
137
  if (!text) {
138
138
  return [];
139
139
  }
140
- return text.split("\n")
141
- .map((line) => JSON.parse(line));
140
+ // The file is appended while the server runs, so a crash or a concurrent
141
+ // read can leave a partial last line; skip lines that are not whole records.
142
+ return text.split("\n").flatMap((line) => {
143
+ try {
144
+ return [JSON.parse(line)];
145
+ }
146
+ catch {
147
+ return [];
148
+ }
149
+ });
142
150
  }
143
151
  export function summarizeDiagnosticsSession(filePath, options = {}) {
144
152
  const records = readDiagnosticRecords(filePath);
package/dist/knowledge.js CHANGED
@@ -41,7 +41,8 @@ export function formatAddress(address) {
41
41
  }
42
42
  export function resolveAddressSymbol(input) {
43
43
  const key = input.trim().toLowerCase().replace(/\s+/g, "_");
44
- return SYMBOLS[key];
44
+ // Own keys only, so "constructor" or "toString" are not mistaken for symbols.
45
+ return Object.prototype.hasOwnProperty.call(SYMBOLS, key) ? SYMBOLS[key] : undefined;
45
46
  }
46
47
  export function listSymbols() {
47
48
  return Object.entries(SYMBOLS)
@@ -449,7 +449,7 @@ async function main() {
449
449
  writeDiagnosticEvent("mcp_transport_connected", { mode: "stdio" });
450
450
  console.error("c64bridge MCP server running on stdio");
451
451
  }
452
- await logConnectivity(client, baseUrl);
452
+ await logConnectivity(client, baseUrl, config.networkPassword);
453
453
  writeDiagnosticEvent("server_ready", {
454
454
  diagnosticsFile: diagnostics.filePath,
455
455
  });
@@ -524,7 +524,11 @@ export async function renderPlatformStatusMarkdown(client) {
524
524
  lines.push("> Use `c64_select_backend` to switch to another available backend without restarting the MCP server.");
525
525
  return lines.join("\n");
526
526
  }
527
- async function logConnectivity(client, baseUrl) {
527
+ /** URL of the Ultimate's /v1/info route, keeping any path prefix of the base URL (reverse proxy). */
528
+ export function connectivityProbeUrl(baseUrl) {
529
+ return `${baseUrl.replace(/\/+$/, "")}/v1/info`;
530
+ }
531
+ async function logConnectivity(client, baseUrl, networkPassword) {
528
532
  const backendType = await client.getBackendType();
529
533
  if (backendType === "vice") {
530
534
  writeDiagnosticEvent("connectivity_probe_skipped", { baseUrl, reason: "vice_backend" });
@@ -550,10 +554,14 @@ async function logConnectivity(client, baseUrl) {
550
554
  }
551
555
  const c64Logger = loggerFor("c64u");
552
556
  const startedAt = Date.now();
553
- const infoUrl = new URL("/v1/info", baseUrl).toString();
557
+ const infoUrl = connectivityProbeUrl(baseUrl);
554
558
  let response = null;
555
559
  try {
556
- const probeResponse = await axios.get(infoUrl, { timeout: 2000 });
560
+ // Send the network password like every other request, or a protected device answers 403.
561
+ const probeResponse = await axios.get(infoUrl, {
562
+ timeout: 2000,
563
+ ...(networkPassword ? { headers: { "X-Password": networkPassword } } : {}),
564
+ });
557
565
  response = probeResponse;
558
566
  const latency = Date.now() - startedAt;
559
567
  const bytes = payloadByteLength(probeResponse.data);