c64bridge 1.0.5 → 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 (46) hide show
  1. package/README.md +2 -2
  2. package/data/sound/sidwave.md +113 -36
  3. package/data/sound/sidwave.schema.json +138 -28
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/c64Client.js +83 -93
  6. package/dist/config.js +21 -4
  7. package/dist/device.js +130 -24
  8. package/dist/diagnostics.js +10 -2
  9. package/dist/mcp-server.js +12 -4
  10. package/dist/sidwave.js +453 -61
  11. package/dist/sidwaveCompiler.js +1424 -269
  12. package/dist/tools/audio.js +70 -12
  13. package/dist/tools/developer.js +7 -7
  14. package/dist/tools/errors.js +7 -0
  15. package/dist/tools/graphics.js +4 -4
  16. package/dist/tools/input.js +8 -11
  17. package/dist/tools/machineControl.js +6 -6
  18. package/dist/tools/memory.js +6 -6
  19. package/dist/tools/meta/audio.js +8 -2
  20. package/dist/tools/meta/graphics.js +1 -1
  21. package/dist/tools/meta/memory.js +4 -4
  22. package/dist/tools/meta/program.js +12 -8
  23. package/dist/tools/printer.js +4 -4
  24. package/dist/tools/programRunners.js +8 -8
  25. package/dist/tools/registry/disk.js +7 -5
  26. package/dist/tools/storage.js +21 -17
  27. package/dist/tools/streaming.js +2 -2
  28. package/dist/tools/translation/assembler.js +44 -12
  29. package/dist/tools/translation/basicTokenizer.js +5 -1
  30. package/dist/tools/translation/opcodeInventory.js +1 -1
  31. package/dist/tools/vice.js +1 -1
  32. package/dist/vice/mockServer.js +92 -5
  33. package/dist/vice/readiness.js +4 -9
  34. package/dist/vice/viceClient.js +18 -0
  35. package/mcp/schemas/c64_disk.schema.json +2 -2
  36. package/mcp/schemas/c64_input.schema.json +3 -3
  37. package/mcp/schemas/c64_program.schema.json +2 -2
  38. package/mcp/schemas/c64_vice.schema.json +3 -3
  39. package/mcp/server.json +1 -1
  40. package/mcp/tools.json +14 -10
  41. package/mcp.json +1 -1
  42. package/package.json +1 -1
  43. package/scripts/mockC64Server.mjs +15 -1
  44. package/scripts/run-coverage.mjs +31 -1
  45. package/scripts/update-readme.ts +3 -2
  46. package/server.json +2 -2
package/dist/c64Client.js CHANGED
@@ -11,8 +11,9 @@ import { createSocket } from "node:dgram";
11
11
  import { networkInterfaces } from "node:os";
12
12
  import axios from "axios";
13
13
  import { basicToPrg } from "./tools/translation/basicTokenizer.js";
14
- import { assemblyToPrg, prgEntryAddress } from "./tools/translation/assembler.js";
14
+ import { assemblyToPrgDetailed, prgEntryAddress } from "./tools/translation/assembler.js";
15
15
  import { stripHexByteSeparators } from "./tools/hex.js";
16
+ import { SIDWAVE_PLAY_ADDRESS } from "./sidwaveCompiler.js";
16
17
  import { petsciiToAscii, screenCodesToAscii } from "./petscii.js";
17
18
  import { resolveAddressSymbol } from "./knowledge.js";
18
19
  import { createAllFacades, createFacade, } from "./device.js";
@@ -33,6 +34,8 @@ export class KeyboardQueueDrainTimeoutError extends Error {
33
34
  this.timeoutMs = timeoutMs;
34
35
  }
35
36
  }
37
+ /** JOYPORT_ID_IO_SIMULATION in joyport.h (the same in VICE 3.9 and 3.10). */
38
+ const VICE_JOYPORT_IO_SIMULATION = 37;
36
39
  const C64U_SYSTEM_MODE_CATEGORY = "U64 Specific Settings";
37
40
  const C64U_SYSTEM_MODE_ITEM = "System Mode";
38
41
  // System Mode values that run the machine clock at the NTSC frequency
@@ -184,6 +187,8 @@ export class C64Client {
184
187
  this.greetingDd00Warmups = new Map();
185
188
  this.machineInputCapabilityProbes = new Map();
186
189
  this.c64uVideoCaptureSession = null;
190
+ /** VICE joyport device ids to restore when a joystick press ends. */
191
+ this.viceJoyportSavedDevice = new Map();
187
192
  this.activeType = "c64u";
188
193
  this.baseUrl = baseUrl;
189
194
  const headers = options.networkPassword ? { "X-Password": options.networkPassword } : undefined;
@@ -545,16 +550,17 @@ export class C64Client {
545
550
  * range corrupts the workspace. Instead this loads the assembled bytes
546
551
  * without running them, then types `SYS <entry>` to transfer control
547
552
  * to the entry point: the target of a leading SYS stub at $0801, or else
548
- * the lowest assembled address.
553
+ * the first assembled instruction.
549
554
  */
550
555
  async uploadAndRunAsm(program) {
551
556
  try {
552
- const prg = assemblyToPrg(program);
557
+ const assembled = assemblyToPrgDetailed(program);
558
+ const prg = assembled.prg;
553
559
  if (prg.length < 2) {
554
560
  return { success: false, details: { message: "Assembled program produced no output" } };
555
561
  }
556
- const entryAddress = prgEntryAddress(prg);
557
- if (entryAddress === 0x0801) {
562
+ const entryAddress = assembled.entryAddress;
563
+ if (prg.readUInt16LE(0) === 0x0801 && prgEntryAddress(prg) === 0x0801) {
558
564
  // Loading makes BASIC relink $0801 as program lines, which overwrites raw code there.
559
565
  return {
560
566
  success: false,
@@ -763,24 +769,48 @@ export class C64Client {
763
769
  async sidReset(hard = false) {
764
770
  try {
765
771
  const facade = await this.facadePromise;
772
+ // Unhooking a running SIDWAVE player is best-effort: a failed read must not stop the SID being silenced.
773
+ const stoppedSidwavePlayer = await this.stopSidwaveIrqPlayer(facade).catch(() => false);
766
774
  if (hard) {
767
775
  const span = 0x19;
768
776
  const ff = Buffer.alloc(span, 0xff);
769
777
  const zz = Buffer.alloc(span, 0x00);
770
778
  await facade.writeMemory(0xd400, ff);
771
779
  await facade.writeMemory(0xd400, zz);
772
- return { success: true };
780
+ return { success: true, ...(stoppedSidwavePlayer ? { details: { stoppedSidwavePlayer } } : {}) };
773
781
  }
774
782
  await facade.writeMemory(0xd404, Buffer.from([0x00]));
775
783
  await facade.writeMemory(0xd40b, Buffer.from([0x00]));
776
784
  await facade.writeMemory(0xd412, Buffer.from([0x00]));
777
785
  await facade.writeMemory(0xd418, Buffer.from([0x00]));
778
- return { success: true };
786
+ return { success: true, ...(stoppedSidwavePlayer ? { details: { stoppedSidwavePlayer } } : {}) };
779
787
  }
780
788
  catch (error) {
781
789
  return { success: false, details: this.normaliseError(error) };
782
790
  }
783
791
  }
792
+ /**
793
+ * A SIDWAVE PRG keeps playing from the IRQ after the SID is silenced. Its
794
+ * IRQ handler is "JSR play / JMP $EA31", and its play vector at $0813 is
795
+ * "JMP play". When $0314 points at such a handler, point it back at the
796
+ * KERNAL handler so silencing sticks; other programs' IRQ handlers are left
797
+ * alone. Both vector bytes go in one write.
798
+ */
799
+ async stopSidwaveIrqPlayer(facade) {
800
+ const vector = await facade.readMemory(0x0314, 2);
801
+ const handler = (vector[0] ?? 0) | ((vector[1] ?? 0) << 8);
802
+ // A handler too close to $FFFF cannot hold the 6-byte SIDWAVE handler (and the read would cross $FFFF).
803
+ if (handler === 0xea31 || handler > 0xfffa)
804
+ return false;
805
+ const code = await facade.readMemory(handler, 6);
806
+ const playVector = await facade.readMemory(SIDWAVE_PLAY_ADDRESS, 3);
807
+ const isSidwaveHandler = code[0] === 0x20 && code[3] === 0x4c && code[4] === 0x31 && code[5] === 0xea
808
+ && playVector[0] === 0x4c && playVector[1] === code[1] && playVector[2] === code[2];
809
+ if (!isSidwaveHandler)
810
+ return false;
811
+ await facade.writeMemory(0x0314, Uint8Array.of(0x31, 0xea));
812
+ return true;
813
+ }
784
814
  async sidNoteOn(options, pinnedFacade) {
785
815
  const voice = options.voice ?? 1;
786
816
  if (voice < 1 || voice > 3) {
@@ -789,6 +819,15 @@ export class C64Client {
789
819
  const system = options.system ?? "PAL";
790
820
  // The tool schema documents that a note name overrides frequencyHz.
791
821
  const hz = options.note ? this.noteNameToHz(options.note) : options.frequencyHz ?? 440;
822
+ // The 16-bit frequency register tops out at about 3848 Hz on PAL and 3995 Hz
823
+ // on NTSC (B7 is already above the PAL limit); report it instead of clamping.
824
+ const maxHz = (0xffff * (system === "PAL" ? 985248 : 1022727)) / 16777216;
825
+ if (hz > maxHz + 0.5) {
826
+ return {
827
+ success: false,
828
+ details: { message: `${options.note ?? `${hz} Hz`} (${hz.toFixed(1)} Hz) is above the SID's highest ${system} frequency of ${maxHz.toFixed(1)} Hz` },
829
+ };
830
+ }
792
831
  const freq16 = this.hzToSidFrequency(hz, system);
793
832
  const freqLo = freq16 & 0xff;
794
833
  const freqHi = (freq16 >> 8) & 0xff;
@@ -1971,6 +2010,43 @@ export class C64Client {
1971
2010
  async viceJoyportSet(port, activeLowMask) {
1972
2011
  await this.withViceMonitor((client) => client.joyportSet(port, activeLowMask));
1973
2012
  }
2013
+ /**
2014
+ * Hold joystick lines on a VICE control port. Binary Monitor command 0xA2
2015
+ * only drives VICE's "I/O simulation" joyport device, so that device is
2016
+ * attached for the duration of the press and the previous device is
2017
+ * restored on release. The device reads bits 5-7 as low, which the
2018
+ * keyboard scan sees as extra keys, so it must not stay attached.
2019
+ *
2020
+ * While a press is held: on port 1 the keyboard scan sees phantom keys
2021
+ * (for example SHIFT+C=, which toggles the character set); on port 2,
2022
+ * $DC00 reads with bits 5-7 clear (up = $1E where a joystick gives $7E),
2023
+ * so programs that compare the whole byte will not see the input.
2024
+ */
2025
+ async viceJoystickPress(port, activeLowMask) {
2026
+ await this.withViceMonitor(async (client) => {
2027
+ const resource = `JoyPort${port}Device`;
2028
+ // Read the device every time: after a VICE restart the saved state is stale.
2029
+ const current = await client.resourceGet(resource);
2030
+ const currentId = current.type === "int" ? current.value : Number(current.value);
2031
+ if (currentId !== VICE_JOYPORT_IO_SIMULATION) {
2032
+ this.viceJoyportSavedDevice.set(port, currentId);
2033
+ await client.resourceSet(resource, VICE_JOYPORT_IO_SIMULATION);
2034
+ }
2035
+ // Attaching the device clears its lines to 0 (everything pressed), so set them in the same monitor session.
2036
+ await client.joyportSet(port, activeLowMask);
2037
+ });
2038
+ }
2039
+ /** Release a VICE control port and restore the joyport device it had before the press. */
2040
+ async viceJoystickRelease(port) {
2041
+ await this.withViceMonitor(async (client) => {
2042
+ await client.joyportSet(port, 0xff);
2043
+ const saved = this.viceJoyportSavedDevice.get(port);
2044
+ if (saved !== undefined) {
2045
+ await client.resourceSet(`JoyPort${port}Device`, saved);
2046
+ this.viceJoyportSavedDevice.delete(port);
2047
+ }
2048
+ });
2049
+ }
1974
2050
  async viceMemGet(address, length) {
1975
2051
  const end = (address + length - 1) & 0xffff;
1976
2052
  return this.withViceMonitor((client) => client.memGet(address, end));
@@ -2269,86 +2345,6 @@ function buildGreetingScreenRam(message) {
2269
2345
  writeScreenLine(screenRam, 3, "READY.");
2270
2346
  return screenRam;
2271
2347
  }
2272
- function buildSingleSpriteProgram(opts) {
2273
- const index = Math.max(0, Math.min(7, opts.spriteIndex ?? 0));
2274
- const mx = Math.max(0, Math.min(511, opts.x ?? 100));
2275
- const xLo = mx & 0xff;
2276
- const xMsbBit = (mx & 0x100) ? (1 << index) : 0;
2277
- const y = toByte(opts.y, 100);
2278
- const color = toByte(opts.color, 1);
2279
- const multicolour = !!opts.multicolour;
2280
- const spriteData = Buffer.from(opts.spriteBytes);
2281
- if (spriteData.length !== 63) {
2282
- throw new Error("spriteBytes must be exactly 63 bytes");
2283
- }
2284
- // We'll assemble a tiny machine-code program that:
2285
- // - Copies 63 bytes to a safe sprite data page ($2000 by default)
2286
- // - Sets screen memory base to $0400, sprite pointer to point into $2000
2287
- // - Positions and enables the sprite
2288
- // - Loops forever
2289
- // This avoids relying on KERNAL calls and works from a cold start.
2290
- const SPRITE_BASE = 0x2000; // must be 64-byte aligned
2291
- const POINTER_PAGE = 0x07f8 + index; // sprite pointer table location
2292
- const pointerValue = (SPRITE_BASE >> 6) & 0xff;
2293
- // Place code starting at $0801 so it runs as a program via SYS.
2294
- // We'll create an assembler source and reuse assemblyToPrg.
2295
- const lines = [];
2296
- lines.push("* = $0801");
2297
- // Tiny BASIC loader header not needed; we will use pure ML and jump via RESET runner which executes by SYS 2061
2298
- // Build code at $0810 to avoid conflicting with potential KERNAL vectors
2299
- lines.push("* = $0810");
2300
- lines.push("\nstart:");
2301
- // Copy 63 bytes from inlined table to SPRITE_BASE
2302
- lines.push(" LDY #$00");
2303
- lines.push("copy_loop:");
2304
- lines.push(" LDA sprite_data,Y");
2305
- lines.push(` STA $${hex16(SPRITE_BASE)},Y`);
2306
- lines.push(" INY");
2307
- lines.push(" CPY #$3F");
2308
- lines.push(" BNE copy_loop");
2309
- // Set sprite pointer, color, coordinates, enable
2310
- lines.push(` LDA #$${pointerValue.toString(16).toUpperCase().padStart(2, "0")}`);
2311
- lines.push(` STA $${(POINTER_PAGE).toString(16).toUpperCase()}`);
2312
- lines.push(` LDA #$${color.toString(16).toUpperCase().padStart(2, "0")}`);
2313
- lines.push(` STA $${hex16(0xD027 + index)}`);
2314
- lines.push(` LDA #$${xLo.toString(16).toUpperCase().padStart(2, "0")}`);
2315
- lines.push(` STA $${(0xD000 + index * 2).toString(16).toUpperCase()}`);
2316
- lines.push(` LDA #$${y.toString(16).toUpperCase().padStart(2, "0")}`);
2317
- lines.push(` STA $${(0xD001 + index * 2).toString(16).toUpperCase()}`);
2318
- // MSB X if needed
2319
- if (xMsbBit) {
2320
- const bit = xMsbBit;
2321
- lines.push(` LDA $D010`);
2322
- lines.push(` ORA #$${bit.toString(16).toUpperCase().padStart(2, "0")}`);
2323
- lines.push(` STA $D010`);
2324
- }
2325
- // Multicolour toggle per-sprite
2326
- if (multicolour) {
2327
- const bit = 1 << index;
2328
- lines.push(` LDA $D01C`);
2329
- lines.push(` ORA #$${bit.toString(16).toUpperCase().padStart(2, "0")}`);
2330
- lines.push(` STA $D01C`);
2331
- }
2332
- // Enable sprite
2333
- {
2334
- const bit = 1 << index;
2335
- lines.push(` LDA $D015`);
2336
- lines.push(` ORA #$${bit.toString(16).toUpperCase().padStart(2, "0")}`);
2337
- lines.push(` STA $D015`);
2338
- }
2339
- // Idle loop
2340
- lines.push("forever: JMP forever");
2341
- // Sprite data table
2342
- lines.push("\nsprite_data:");
2343
- for (let i = 0; i < 63; i += 3) {
2344
- const a = spriteData[i] ?? 0;
2345
- const b = spriteData[i + 1] ?? 0;
2346
- const c = spriteData[i + 2] ?? 0;
2347
- lines.push(` .byte $${hex2(a)},$${hex2(b)},$${hex2(c)}`);
2348
- }
2349
- const source = lines.join("\n");
2350
- return assemblyToPrg(source, { fileName: "sprite_gen.asm", loadAddress: 0x0801 });
2351
- }
2352
2348
  export function buildPetsciiScreenBasic(opts) {
2353
2349
  const border = toByte(opts.borderColor ?? 6); // default blue-ish
2354
2350
  const bg = toByte(opts.backgroundColor ?? 0); // default black
@@ -2548,9 +2544,3 @@ export function buildCommodoreDllBasicProgram(opts) {
2548
2544
  lines.push(`${ln} CLOSE1`);
2549
2545
  return lines.join("\n");
2550
2546
  }
2551
- function hex2(n) {
2552
- return (n & 0xff).toString(16).toUpperCase().padStart(2, "0");
2553
- }
2554
- function hex16(n) {
2555
- return (n & 0xffff).toString(16).toUpperCase().padStart(4, "0");
2556
- }
package/dist/config.js CHANGED
@@ -114,13 +114,28 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
114
114
  ...parsedBaseOverrides.map((entry) => entry.port),
115
115
  ];
116
116
  const host = firstDefined(...hostCandidates) ?? DEFAULT_HOST;
117
- const port = firstDefined(...portCandidates) ?? DEFAULT_PORT;
117
+ // When the host comes from a baseUrl, keep its scheme and path (for example
118
+ // https://proxy/c64u), applying only an explicit port override. An explicit
119
+ // host builds http://host:port as documented.
120
+ const baseOnly = parsedBaseOverrides.find((entry) => entry.hostname !== undefined);
121
+ const explicitHost = firstDefined(...hostCandidates.slice(0, -parsedBaseOverrides.length));
122
+ const explicitPort = firstDefined(...portCandidates.slice(0, -parsedBaseOverrides.length));
123
+ const keepBaseUrl = baseOnly?.fullUrl !== undefined && explicitHost === undefined;
124
+ const schemeDefaultPort = keepBaseUrl && baseOnly.fullUrl.startsWith("https:") ? 443 : DEFAULT_PORT;
125
+ const port = firstDefined(...portCandidates) ?? schemeDefaultPort;
118
126
  const hostLabel = formatHost(host);
127
+ let baseUrl = buildBaseUrl(host, port);
128
+ if (keepBaseUrl) {
129
+ const url = new URL(baseOnly.fullUrl);
130
+ if (explicitPort !== undefined)
131
+ url.port = String(explicitPort);
132
+ baseUrl = stripTrailingSlash(url.toString());
133
+ }
119
134
  return {
120
135
  host,
121
136
  port,
122
- baseUrl: buildBaseUrl(host, port),
123
- c64_host: port === DEFAULT_PORT ? hostLabel : `${hostLabel}:${port}`,
137
+ baseUrl,
138
+ c64_host: port === schemeDefaultPort ? hostLabel : `${hostLabel}:${port}`,
124
139
  networkPassword: firstDefined(nonEmptyString(envPassword), nonEmptyString(section?.networkPassword), nonEmptyString(legacy?.networkPassword)),
125
140
  };
126
141
  }
@@ -181,7 +196,9 @@ function parseEndpoint(value) {
181
196
  const hostname = url.hostname || undefined;
182
197
  const port = url.port ? configuredPort(url.port) : undefined;
183
198
  const baseUrl = stripTrailingSlash(`${url.protocol}//${url.host}`);
184
- return { hostname, port, baseUrl };
199
+ // Scheme and path prefix kept, for an Ultimate reached through a reverse proxy.
200
+ const fullUrl = stripTrailingSlash(`${url.protocol}//${url.host}${url.pathname}`);
201
+ return { hostname, port, baseUrl, fullUrl };
185
202
  }
186
203
  catch {
187
204
  return {};
package/dist/device.js CHANGED
@@ -9,6 +9,7 @@ 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
14
  import { loadConfig, resolveUltimateEndpoint } from "./config.js";
14
15
  const DEFAULT_VICE_HOST = "127.0.0.1";
@@ -302,6 +303,12 @@ export class ViceBackend {
302
303
  this.startupPromise = null;
303
304
  this.monitorQueue = Promise.resolve();
304
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;
305
312
  ViceBackend.ensureCleanupRegistration();
306
313
  const envBinary = configuredString(process.env.VICE_BINARY);
307
314
  const configBinary = config.exe !== undefined
@@ -734,19 +741,27 @@ export class ViceBackend {
734
741
  getEndpoint() {
735
742
  return { host: this.host, port: this.port };
736
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
+ }
737
752
  async drivesList() {
753
+ this.syncTrackedState();
738
754
  return await this.withClient(async (client) => {
739
755
  const drives = [];
740
756
  for (const n of [8, 9, 10, 11]) {
741
757
  try {
742
- const enabled = await client.resourceGet(`Drive${n}CPUEnabled`);
743
- const image = await client.resourceGet(`Drive${n}Image`);
744
758
  const typeRes = await client.resourceGet(`Drive${n}Type`);
759
+ const type = typeRes.type === "int" ? typeRes.value : Number(typeRes.value);
745
760
  drives.push({
746
761
  id: `drive${n}`,
747
- power: (typeof enabled.value === "number" ? enabled.value : 0) ? "on" : "off",
748
- image: typeof image.value === "string" && image.value ? image.value : null,
749
- type: typeRes.value,
762
+ power: type !== 0 ? "on" : "off",
763
+ image: this.attachedImages.get(n) ?? null,
764
+ type,
750
765
  });
751
766
  }
752
767
  catch {
@@ -761,44 +776,83 @@ export class ViceBackend {
761
776
  throw unsupported("driveMount type/mode overrides; omit these options on VICE");
762
777
  }
763
778
  const n = parseDriveNumber(drive);
764
- await this.withClient(async (client) => {
765
- await client.resourceSet(`Drive${n}CPUEnabled`, 1);
766
- 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);
767
801
  });
768
- 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
+ };
769
814
  }
770
- async driveRemove(drive) {
771
- const n = parseDriveNumber(drive);
772
- await this.withClient(async (client) => {
773
- await client.resourceSet(`Drive${n}Image`, "");
774
- });
775
- 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");
776
817
  }
777
818
  async driveReset(drive) {
778
819
  const n = parseDriveNumber(drive);
779
820
  await this.withClient(async (client) => {
780
- await client.resourceSet(`Drive${n}CPUEnabled`, 0);
781
- await client.resourceSet(`Drive${n}CPUEnabled`, 1);
821
+ await client.resetDrive(n);
782
822
  });
783
823
  return { success: true, details: { drive } };
784
824
  }
785
825
  async driveOn(drive) {
786
826
  const n = parseDriveNumber(drive);
787
827
  await this.withClient(async (client) => {
788
- await client.resourceSet(`Drive${n}CPUEnabled`, 1);
828
+ await this.ensureDriveOn(client, n);
789
829
  });
790
830
  return { success: true, details: { drive, power: "on" } };
791
831
  }
792
832
  async driveOff(drive) {
793
833
  const n = parseDriveNumber(drive);
834
+ this.syncTrackedState();
794
835
  await this.withClient(async (client) => {
795
- 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);
796
841
  });
797
842
  return { success: true, details: { drive, power: "off" } };
798
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
+ }
799
852
  async driveSetMode(drive, mode) {
800
853
  const n = parseDriveNumber(drive);
801
- 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 };
802
856
  const typeNum = DRIVE_TYPE[mode];
803
857
  if (typeNum === undefined)
804
858
  throw new Error(`Unknown drive mode: ${mode}`);
@@ -815,10 +869,11 @@ export class ViceBackend {
815
869
  categories: [
816
870
  {
817
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.
818
874
  items: [
819
- "WarpMode", "SoundVolume", "Drive8CPUEnabled", "Drive9CPUEnabled",
820
- "Drive10CPUEnabled", "Drive11CPUEnabled", "Drive8Image", "Drive9Image",
821
- "Drive8Type", "Drive9Type", "VICIIBorderMode", "VICIIFullscreen",
875
+ "SoundVolume", "Drive8Type", "Drive9Type", "Drive10Type", "Drive11Type",
876
+ "VICIIBorderMode", "VICIIFullscreen", "MachineVideoStandard", "SidModel",
822
877
  ],
823
878
  },
824
879
  ],
@@ -894,12 +949,63 @@ function parseViceConfigValue(value, type, item) {
894
949
  }
895
950
  return parsed;
896
951
  }
897
- 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; }
898
953
  /** Ultimate file APIs treat slash as a path separator. Encode each name once,
899
954
  * preserving leading and nested separators for firmware routing. */
900
955
  function encodeDevicePath(value) {
901
956
  return value.split("/").map((segment) => encodeURIComponent(segment)).join("/");
902
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
+ }
903
1009
  /** C64U drive letters with their factory bus numbers, so one drive id works on both backends. */
904
1010
  const VICE_DRIVE_LETTERS = { a: 8, b: 9 };
905
1011
  function parseDriveNumber(drive) {
@@ -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);
@@ -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);