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