c64bridge 1.0.5 → 1.0.7

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 (58) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +3 -3
  3. package/data/sound/sidwave.md +113 -36
  4. package/data/sound/sidwave.schema.json +138 -28
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/c64Client.js +83 -93
  7. package/dist/chargen.js +0 -4
  8. package/dist/config.js +26 -6
  9. package/dist/device.js +130 -24
  10. package/dist/diagnostics.js +14 -5
  11. package/dist/logger.js +3 -2
  12. package/dist/loggingHttpClient.js +12 -1
  13. package/dist/mcp-server.js +19 -9
  14. package/dist/petscii.js +0 -5
  15. package/dist/redact.js +166 -0
  16. package/dist/sidwave.js +453 -61
  17. package/dist/sidwaveCompiler.js +1424 -269
  18. package/dist/tools/audio.js +70 -12
  19. package/dist/tools/developer.js +17 -9
  20. package/dist/tools/errors.js +7 -0
  21. package/dist/tools/graphics.js +4 -4
  22. package/dist/tools/input.js +10 -13
  23. package/dist/tools/machineControl.js +7 -7
  24. package/dist/tools/memory.js +6 -6
  25. package/dist/tools/meta/audio.js +8 -2
  26. package/dist/tools/meta/config.js +5 -4
  27. package/dist/tools/meta/configInventory.js +25 -2
  28. package/dist/tools/meta/diagnostics.js +2 -1
  29. package/dist/tools/meta/graphics.js +1 -1
  30. package/dist/tools/meta/memory.js +4 -4
  31. package/dist/tools/meta/program.js +12 -8
  32. package/dist/tools/printer.js +4 -4
  33. package/dist/tools/programRunners.js +8 -8
  34. package/dist/tools/registry/batch.js +1 -7
  35. package/dist/tools/registry/disk.js +7 -5
  36. package/dist/tools/storage.js +21 -17
  37. package/dist/tools/streaming.js +2 -2
  38. package/dist/tools/translation/assembler.js +44 -12
  39. package/dist/tools/translation/basicTokenizer.js +5 -1
  40. package/dist/tools/translation/opcodeInventory.js +1 -1
  41. package/dist/tools/vice.js +1 -1
  42. package/dist/vice/mockServer.js +92 -5
  43. package/dist/vice/readiness.js +4 -9
  44. package/dist/vice/viceClient.js +18 -0
  45. package/doc/audit/secret-handling.md +61 -0
  46. package/mcp/schemas/c64_config.schema.json +19 -2
  47. package/mcp/schemas/c64_disk.schema.json +2 -2
  48. package/mcp/schemas/c64_input.schema.json +3 -3
  49. package/mcp/schemas/c64_program.schema.json +2 -2
  50. package/mcp/schemas/c64_vice.schema.json +3 -3
  51. package/mcp/server.json +1 -1
  52. package/mcp/tools.json +33 -12
  53. package/mcp.json +1 -1
  54. package/package.json +5 -2
  55. package/scripts/mockC64Server.mjs +15 -1
  56. package/scripts/run-coverage.mjs +31 -1
  57. package/scripts/update-readme.ts +3 -2
  58. package/server.json +20 -3
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/chargen.js CHANGED
@@ -184,10 +184,6 @@ export function getChargenGlyphs() {
184
184
  loadGlyphs();
185
185
  return glyphCache.glyphs;
186
186
  }
187
- export function findGlyphByPetscii(code) {
188
- loadGlyphs();
189
- return glyphCache.byPetscii.get(code);
190
- }
191
187
  export function findGlyphByBasicChar(char) {
192
188
  if (!char) {
193
189
  return undefined;
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
3
  import os from "node:os";
4
4
  import { fileURLToPath } from "url";
5
+ import { registerSecret } from "./redact.js";
5
6
  const DEFAULT_HOST = "c64u";
6
7
  const DEFAULT_PORT = 80;
7
8
  export function loadConfig() {
@@ -25,7 +26,7 @@ export function loadConfig() {
25
26
  if (error.code !== "ENOENT") {
26
27
  // Deliberately omit file contents/credentials. Configuration mistakes
27
28
  // should not prevent the MCP server from exposing a recoverable path.
28
- console.error(`[c64bridge] ignoring configuration '${candidatePath}': ${error instanceof Error ? error.message : String(error)}`);
29
+ console.error(`[c64bridge] ignoring configuration '${candidatePath}': ${error instanceof Error ? error.name : "unreadable"}`);
29
30
  }
30
31
  }
31
32
  }
@@ -114,14 +115,31 @@ export function resolveUltimateEndpoint(profile, section, legacy) {
114
115
  ...parsedBaseOverrides.map((entry) => entry.port),
115
116
  ];
116
117
  const host = firstDefined(...hostCandidates) ?? DEFAULT_HOST;
117
- const port = firstDefined(...portCandidates) ?? DEFAULT_PORT;
118
+ // When the host comes from a baseUrl, keep its scheme and path (for example
119
+ // https://proxy/c64u), applying only an explicit port override. An explicit
120
+ // host builds http://host:port as documented.
121
+ const baseOnly = parsedBaseOverrides.find((entry) => entry.hostname !== undefined);
122
+ const explicitHost = firstDefined(...hostCandidates.slice(0, -parsedBaseOverrides.length));
123
+ const explicitPort = firstDefined(...portCandidates.slice(0, -parsedBaseOverrides.length));
124
+ const keepBaseUrl = baseOnly?.fullUrl !== undefined && explicitHost === undefined;
125
+ const schemeDefaultPort = keepBaseUrl && baseOnly.fullUrl.startsWith("https:") ? 443 : DEFAULT_PORT;
126
+ const port = firstDefined(...portCandidates) ?? schemeDefaultPort;
118
127
  const hostLabel = formatHost(host);
128
+ let baseUrl = buildBaseUrl(host, port);
129
+ if (keepBaseUrl) {
130
+ const url = new URL(baseOnly.fullUrl);
131
+ if (explicitPort !== undefined)
132
+ url.port = String(explicitPort);
133
+ baseUrl = stripTrailingSlash(url.toString());
134
+ }
135
+ const networkPassword = firstDefined(nonEmptyString(envPassword), nonEmptyString(section?.networkPassword), nonEmptyString(legacy?.networkPassword));
136
+ registerSecret(networkPassword);
119
137
  return {
120
138
  host,
121
139
  port,
122
- baseUrl: buildBaseUrl(host, port),
123
- c64_host: port === DEFAULT_PORT ? hostLabel : `${hostLabel}:${port}`,
124
- networkPassword: firstDefined(nonEmptyString(envPassword), nonEmptyString(section?.networkPassword), nonEmptyString(legacy?.networkPassword)),
140
+ baseUrl,
141
+ c64_host: port === schemeDefaultPort ? hostLabel : `${hostLabel}:${port}`,
142
+ networkPassword,
125
143
  };
126
144
  }
127
145
  function configuredString(value) {
@@ -181,7 +199,9 @@ function parseEndpoint(value) {
181
199
  const hostname = url.hostname || undefined;
182
200
  const port = url.port ? configuredPort(url.port) : undefined;
183
201
  const baseUrl = stripTrailingSlash(`${url.protocol}//${url.host}`);
184
- return { hostname, port, baseUrl };
202
+ // Scheme and path prefix kept, for an Ultimate reached through a reverse proxy.
203
+ const fullUrl = stripTrailingSlash(`${url.protocol}//${url.host}${url.pathname}`);
204
+ return { hostname, port, baseUrl, fullUrl };
185
205
  }
186
206
  catch {
187
207
  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) {
@@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { redactValue } from "./redact.js";
5
6
  let state = null;
6
7
  let handlersInstalled = false;
7
8
  let spanCounter = 0;
@@ -21,9 +22,6 @@ export function installProcessDiagnostics(component) {
21
22
  export function getDiagnosticsSessionInfo() {
22
23
  return state?.info ?? null;
23
24
  }
24
- export function getDiagnosticsDirectory() {
25
- return resolveDiagnosticsDirectory();
26
- }
27
25
  export function writeDiagnosticEvent(event, details) {
28
26
  if (!isDiagnosticsEnabled()) {
29
27
  return;
@@ -137,8 +135,16 @@ export function readDiagnosticRecords(filePath) {
137
135
  if (!text) {
138
136
  return [];
139
137
  }
140
- return text.split("\n")
141
- .map((line) => JSON.parse(line));
138
+ // The file is appended while the server runs, so a crash or a concurrent
139
+ // read can leave a partial last line; skip lines that are not whole records.
140
+ return text.split("\n").flatMap((line) => {
141
+ try {
142
+ return [JSON.parse(line)];
143
+ }
144
+ catch {
145
+ return [];
146
+ }
147
+ });
142
148
  }
143
149
  export function summarizeDiagnosticsSession(filePath, options = {}) {
144
150
  const records = readDiagnosticRecords(filePath);
@@ -292,6 +298,9 @@ function isDiagnosticsEnabled() {
292
298
  return true;
293
299
  }
294
300
  function sanitize(value, depth = 0) {
301
+ return sanitizeUnredacted(redactValue(value), depth);
302
+ }
303
+ function sanitizeUnredacted(value, depth = 0) {
295
304
  if (value instanceof Error) {
296
305
  const extra = value;
297
306
  return {
package/dist/logger.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
+ import { redactText, redactValue } from "./redact.js";
2
3
  const LEVEL_ORDER = {
3
4
  debug: 0,
4
5
  info: 1,
@@ -24,9 +25,9 @@ function createPrefixedLogger(prefix) {
24
25
  return;
25
26
  }
26
27
  const consoleMethod = selectConsole(level);
27
- const label = `[${prefix}] ${message}`;
28
+ const label = `[${prefix}] ${redactText(message)}`;
28
29
  if (details && Object.keys(details).length > 0) {
29
- consoleMethod(label, details);
30
+ consoleMethod(label, redactValue(details));
30
31
  }
31
32
  else {
32
33
  consoleMethod(label);
@@ -1,4 +1,5 @@
1
1
  import { HttpClient } from "../generated/c64/index.js";
2
+ import { registerSecret, scrubRequestError } from "./redact.js";
2
3
  import { formatErrorMessage, formatPayloadForDebug, loggerFor, payloadByteLength } from "./logger.js";
3
4
  /** Headers are useful in diagnostics, but credentials must never leave the
4
5
  * transport layer. Axios normalises some keys and preserves others, so do a
@@ -14,6 +15,7 @@ function redactHeaders(headers) {
14
15
  }
15
16
  export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
16
17
  const http = new HttpClient(config);
18
+ registerSecret(readPasswordHeader(config?.headers));
17
19
  http.instance.interceptors.request.use((request) => {
18
20
  const method = (request.method ?? "get").toUpperCase();
19
21
  const path = resolvePath(request);
@@ -33,10 +35,19 @@ export function createLoggingHttpClient(config, logger = loggerFor("c64u")) {
33
35
  return response;
34
36
  }, (error) => {
35
37
  handleError(error, logger);
36
- return Promise.reject(error);
38
+ return Promise.reject(scrubRequestError(error));
37
39
  });
38
40
  return http;
39
41
  }
42
+ function readPasswordHeader(headers) {
43
+ if (!headers || typeof headers !== "object")
44
+ return undefined;
45
+ for (const [key, value] of Object.entries(headers)) {
46
+ if (/^x-password$/i.test(key) && typeof value === "string")
47
+ return value;
48
+ }
49
+ return undefined;
50
+ }
40
51
  function handleResponse(response, logger) {
41
52
  const meta = extractMeta(response.config);
42
53
  const latency = meta ? Date.now() - meta.startedAt : 0;