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.
- package/AGENTS.md +1 -1
- package/README.md +3 -3
- package/data/sound/sidwave.md +113 -36
- package/data/sound/sidwave.schema.json +138 -28
- package/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +83 -93
- package/dist/chargen.js +0 -4
- package/dist/config.js +26 -6
- package/dist/device.js +130 -24
- package/dist/diagnostics.js +14 -5
- package/dist/logger.js +3 -2
- package/dist/loggingHttpClient.js +12 -1
- package/dist/mcp-server.js +19 -9
- package/dist/petscii.js +0 -5
- package/dist/redact.js +166 -0
- package/dist/sidwave.js +453 -61
- package/dist/sidwaveCompiler.js +1424 -269
- package/dist/tools/audio.js +70 -12
- package/dist/tools/developer.js +17 -9
- package/dist/tools/errors.js +7 -0
- package/dist/tools/graphics.js +4 -4
- package/dist/tools/input.js +10 -13
- package/dist/tools/machineControl.js +7 -7
- package/dist/tools/memory.js +6 -6
- package/dist/tools/meta/audio.js +8 -2
- package/dist/tools/meta/config.js +5 -4
- package/dist/tools/meta/configInventory.js +25 -2
- package/dist/tools/meta/diagnostics.js +2 -1
- package/dist/tools/meta/graphics.js +1 -1
- package/dist/tools/meta/memory.js +4 -4
- package/dist/tools/meta/program.js +12 -8
- package/dist/tools/printer.js +4 -4
- package/dist/tools/programRunners.js +8 -8
- package/dist/tools/registry/batch.js +1 -7
- package/dist/tools/registry/disk.js +7 -5
- package/dist/tools/storage.js +21 -17
- package/dist/tools/streaming.js +2 -2
- package/dist/tools/translation/assembler.js +44 -12
- package/dist/tools/translation/basicTokenizer.js +5 -1
- package/dist/tools/translation/opcodeInventory.js +1 -1
- package/dist/tools/vice.js +1 -1
- package/dist/vice/mockServer.js +92 -5
- package/dist/vice/readiness.js +4 -9
- package/dist/vice/viceClient.js +18 -0
- package/doc/audit/secret-handling.md +61 -0
- package/mcp/schemas/c64_config.schema.json +19 -2
- package/mcp/schemas/c64_disk.schema.json +2 -2
- package/mcp/schemas/c64_input.schema.json +3 -3
- package/mcp/schemas/c64_program.schema.json +2 -2
- package/mcp/schemas/c64_vice.schema.json +3 -3
- package/mcp/server.json +1 -1
- package/mcp/tools.json +33 -12
- package/mcp.json +1 -1
- package/package.json +5 -2
- package/scripts/mockC64Server.mjs +15 -1
- package/scripts/run-coverage.mjs +31 -1
- package/scripts/update-readme.ts +3 -2
- 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 {
|
|
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
|
|
553
|
+
* the first assembled instruction.
|
|
549
554
|
*/
|
|
550
555
|
async uploadAndRunAsm(program) {
|
|
551
556
|
try {
|
|
552
|
-
const
|
|
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 =
|
|
557
|
-
if (
|
|
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.
|
|
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
|
-
|
|
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
|
|
123
|
-
c64_host: port ===
|
|
124
|
-
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
|
-
|
|
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:
|
|
748
|
-
image:
|
|
749
|
-
type
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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(
|
|
771
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
820
|
-
"
|
|
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
|
|
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) {
|
package/dist/diagnostics.js
CHANGED
|
@@ -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
|
-
|
|
141
|
-
|
|
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;
|