c64bridge 0.7.4 → 0.8.1
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/.github/prompts/README.md +14 -4
- package/.github/prompts/assembly-routine-session.prompt.md +9 -9
- package/.github/prompts/basic-program-session.prompt.md +11 -9
- package/.github/prompts/drive-management-session.prompt.md +9 -9
- package/.github/prompts/hello-world-session.prompt.md +17 -0
- package/.github/prompts/petscii-art-session.prompt.md +9 -9
- package/.github/prompts/play-classic-game.prompt.md +9 -9
- package/.github/prompts/pr-converge.prompt.md +13 -0
- package/.github/prompts/sid-music-session.prompt.md +9 -9
- package/AGENTS.md +59 -60
- package/README.md +550 -191
- package/data/assembly/assembly-spec.md +290 -1
- package/data/context/bootstrap.md +26 -2
- package/data/context/fast-paths.md +44 -0
- package/data/graphics/petscii-style-guide.md +6 -2
- package/data/graphics/sprite-charset-best-practices.md +5 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +990 -26
- package/dist/config.js +5 -1
- package/dist/device.js +680 -57
- package/dist/diagnostics.js +371 -0
- package/dist/mcp/metadata.js +39 -0
- package/dist/mcp-server.js +170 -20
- package/dist/platform.js +10 -2
- package/dist/prompts/registry.js +166 -309
- package/dist/rag/init.js +37 -1
- package/dist/rag/knowledgeIndex.js +18 -6
- package/dist/rag/rateLimiter.js +2 -2
- package/dist/streamCapture.js +135 -0
- package/dist/tools/audio.js +3 -1
- package/dist/tools/debug.js +470 -0
- package/dist/tools/developer.js +10 -4
- package/dist/tools/graphics.js +183 -17
- package/dist/tools/machineControl.js +11 -5
- package/dist/tools/memory.js +19 -10
- package/dist/tools/meta/audio.js +240 -1
- package/dist/tools/meta/config.js +20 -28
- package/dist/tools/meta/configInventory.js +104 -0
- package/dist/tools/meta/diagnostics.js +45 -2
- package/dist/tools/meta/graphics.js +14 -6
- package/dist/tools/meta/index.js +1 -0
- package/dist/tools/meta/memory.js +20 -6
- package/dist/tools/meta/program.js +473 -0
- package/dist/tools/pollValidator.js +4 -2
- package/dist/tools/programRunners.js +8 -4
- package/dist/tools/registry/config.js +17 -0
- package/dist/tools/registry/disk.js +7 -0
- package/dist/tools/registry/drive.js +3 -0
- package/dist/tools/registry/graphics.js +111 -27
- package/dist/tools/registry/index.js +6 -0
- package/dist/tools/registry/memory.js +1 -0
- package/dist/tools/registry/platform.js +121 -0
- package/dist/tools/registry/program.js +26 -8
- package/dist/tools/registry/sound.js +96 -0
- package/dist/tools/registry/system.js +15 -2
- package/dist/tools/storage.js +1 -0
- package/dist/tools/streaming.js +1 -0
- package/dist/tools/types.js +38 -4
- package/dist/tools/vice.js +160 -0
- package/dist/vicBitmap.js +317 -0
- package/dist/vice/mockServer.js +439 -0
- package/dist/vice/process.js +310 -0
- package/dist/vice/readiness.js +18 -2
- package/dist/vice/viceClient.js +382 -22
- package/doc/developer.md +71 -25
- package/doc/img/backend-switch/hello-c64u.png +0 -0
- package/doc/img/backend-switch/hello-vice-and-c64u.png +0 -0
- package/doc/img/backend-switch/hello-vice.png +0 -0
- package/doc/img/backend-switch/metadata.json +50 -0
- package/doc/plans/vice-backend-switch/plan.md +486 -0
- package/doc/plans/vice-backend-switch-2/plan.md +48 -0
- package/doc/plans/vice-support/notes.md +1 -0
- package/doc/plans/vice-support/plan.md +5 -0
- package/doc/plans/vice-support/rollout.md +23 -17
- package/doc/reviews/vice-support-review.md +185 -0
- package/doc/troubleshooting-mcp.md +35 -6
- package/doc/vice/support-matrix.md +54 -0
- package/mcp/prompts.json +292 -0
- package/mcp/protocol-examples.json +46 -0
- package/mcp/resources.json +940 -0
- package/mcp/schemas/c64_config.schema.json +350 -0
- package/mcp/schemas/c64_debug.schema.json +380 -0
- package/mcp/schemas/c64_disk.schema.json +278 -0
- package/mcp/schemas/c64_drive.schema.json +120 -0
- package/mcp/schemas/c64_extract.schema.json +302 -0
- package/mcp/schemas/c64_graphics.schema.json +333 -0
- package/mcp/schemas/c64_memory.schema.json +155 -0
- package/mcp/schemas/c64_printer.schema.json +218 -0
- package/mcp/schemas/c64_program.schema.json +391 -0
- package/mcp/schemas/c64_rag.schema.json +67 -0
- package/mcp/schemas/c64_select_backend.schema.json +32 -0
- package/mcp/schemas/c64_sound.schema.json +644 -0
- package/mcp/schemas/c64_stream.schema.json +61 -0
- package/mcp/schemas/c64_system.schema.json +227 -0
- package/mcp/schemas/c64_vice.schema.json +63 -0
- package/mcp/server.json +15 -0
- package/mcp/tools.json +4517 -0
- package/mcp.json +90 -15
- package/package.json +11 -4
- package/scripts/capture-backend-switch-screenshots.mjs +276 -0
- package/scripts/generate-mcp-interface.ts +289 -0
- package/scripts/invoke-bun.mjs +67 -21
- package/scripts/mockC64Server.mjs +162 -6
- package/scripts/prepare-release.mjs +59 -2
- package/scripts/run-coverage.mjs +284 -0
- package/scripts/run-tests.mjs +95 -39
- package/scripts/run-tests.ts +378 -73
- package/scripts/update-readme.ts +147 -13
- package/server.json +94 -0
package/dist/device.js
CHANGED
|
@@ -3,14 +3,43 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import axios from "axios";
|
|
5
5
|
import { Buffer } from "node:buffer";
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
7
6
|
import fs from "node:fs";
|
|
8
7
|
import os from "node:os";
|
|
9
8
|
import path from "node:path";
|
|
10
9
|
import { Api } from "../generated/c64/index.js";
|
|
11
10
|
import { createLoggingHttpClient } from "./loggingHttpClient.js";
|
|
11
|
+
import { ViceClient } from "./vice/viceClient.js";
|
|
12
|
+
import { waitForBasicReady } from "./vice/readiness.js";
|
|
13
|
+
import { startViceProcess } from "./vice/process.js";
|
|
12
14
|
const DEFAULT_C64U_HOST = "c64u";
|
|
13
15
|
const DEFAULT_C64U_PORT = 80;
|
|
16
|
+
const DEFAULT_VICE_HOST = "127.0.0.1";
|
|
17
|
+
const DEFAULT_VICE_PORT = 6502;
|
|
18
|
+
function delay(ms) {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
function coalesceMemoryWriteBlocks(blocks) {
|
|
22
|
+
const merged = [];
|
|
23
|
+
for (const block of blocks) {
|
|
24
|
+
if (!Number.isInteger(block.address) || block.address < 0 || block.address > 0xffff) {
|
|
25
|
+
throw new Error("Address must be within 0x0000-0xFFFF");
|
|
26
|
+
}
|
|
27
|
+
if (!(block.bytes instanceof Uint8Array) || block.bytes.length === 0) {
|
|
28
|
+
throw new Error("Bytes must be a non-empty Uint8Array");
|
|
29
|
+
}
|
|
30
|
+
const previous = merged[merged.length - 1];
|
|
31
|
+
const previousEnd = previous ? previous.address + previous.bytes.length : -1;
|
|
32
|
+
if (previous && previousEnd === block.address) {
|
|
33
|
+
const bytes = new Uint8Array(previous.bytes.length + block.bytes.length);
|
|
34
|
+
bytes.set(previous.bytes, 0);
|
|
35
|
+
bytes.set(block.bytes, previous.bytes.length);
|
|
36
|
+
previous.bytes = bytes;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
merged.push({ address: block.address, bytes: Uint8Array.from(block.bytes) });
|
|
40
|
+
}
|
|
41
|
+
return merged;
|
|
42
|
+
}
|
|
14
43
|
function readConfigFile() {
|
|
15
44
|
const envPath = process.env.C64BRIDGE_CONFIG;
|
|
16
45
|
const candidates = [];
|
|
@@ -25,24 +54,41 @@ function readConfigFile() {
|
|
|
25
54
|
const home = process.env.HOME || os.homedir();
|
|
26
55
|
if (home)
|
|
27
56
|
candidates.push(path.join(home, ".c64bridge.json"));
|
|
57
|
+
let foundCandidate = false;
|
|
58
|
+
const merged = {};
|
|
28
59
|
for (const p of candidates) {
|
|
29
60
|
try {
|
|
30
61
|
if (fs.existsSync(p)) {
|
|
62
|
+
foundCandidate = true;
|
|
31
63
|
const text = fs.readFileSync(p, "utf8");
|
|
32
64
|
const json = JSON.parse(text);
|
|
33
|
-
|
|
65
|
+
if (!merged.c64u && json?.c64u && typeof json.c64u === "object" && !Array.isArray(json.c64u)) {
|
|
66
|
+
merged.c64u = json.c64u;
|
|
67
|
+
}
|
|
68
|
+
if (!merged.vice && json?.vice && typeof json.vice === "object" && !Array.isArray(json.vice)) {
|
|
69
|
+
merged.vice = json.vice;
|
|
70
|
+
}
|
|
71
|
+
if (merged.c64u && merged.vice) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
34
74
|
}
|
|
35
75
|
}
|
|
36
76
|
catch { }
|
|
37
77
|
}
|
|
38
|
-
return null;
|
|
78
|
+
return foundCandidate ? merged : null;
|
|
39
79
|
}
|
|
40
80
|
class C64uBackend {
|
|
41
81
|
constructor(config) {
|
|
42
82
|
this.type = "c64u";
|
|
43
|
-
const
|
|
83
|
+
const envHost = configuredString(process.env.C64U_HOST);
|
|
84
|
+
const envPort = configuredPort(process.env.C64U_PORT);
|
|
85
|
+
const configBaseUrl = normaliseBaseUrl(config.baseUrl);
|
|
86
|
+
const parsedConfigBaseUrl = configBaseUrl ? parseEndpoint(configBaseUrl) : {};
|
|
87
|
+
const baseUrl = envHost !== undefined || envPort !== undefined
|
|
88
|
+
? buildBaseUrl(firstDefined(envHost, configuredString(config.host), configuredString(config.hostname), parsedConfigBaseUrl.hostname) ?? DEFAULT_C64U_HOST, firstDefined(envPort, configuredPort(config.port), parsedConfigBaseUrl.port) ?? DEFAULT_C64U_PORT)
|
|
89
|
+
: resolveBaseUrl(config);
|
|
44
90
|
this.baseUrl = baseUrl;
|
|
45
|
-
this.networkPassword = configuredString(config.networkPassword);
|
|
91
|
+
this.networkPassword = firstDefined(configuredString(process.env.C64U_PASSWORD), configuredString(config.networkPassword));
|
|
46
92
|
const http = createLoggingHttpClient({
|
|
47
93
|
baseURL: baseUrl,
|
|
48
94
|
timeout: 10000,
|
|
@@ -119,6 +165,10 @@ class C64uBackend {
|
|
|
119
165
|
await this.api.v1.machineWritememCreate(":writemem", { address: addrStr }, Buffer.from(bytes), { headers: { "Content-Type": "application/octet-stream" } });
|
|
120
166
|
}
|
|
121
167
|
}
|
|
168
|
+
async writeMemoryBlocks(blocks) {
|
|
169
|
+
const mergedBlocks = coalesceMemoryWriteBlocks(blocks);
|
|
170
|
+
await Promise.all(mergedBlocks.map(({ address, bytes }) => this.writeMemory(address, bytes)));
|
|
171
|
+
}
|
|
122
172
|
async reset() { const res = await this.api.v1.machineResetUpdate(":reset"); return { success: true, details: res.data }; }
|
|
123
173
|
async reboot() { const res = await this.api.v1.machineRebootUpdate(":reboot"); return { success: true, details: res.data }; }
|
|
124
174
|
async pause() { const res = await this.api.v1.machinePauseUpdate(":pause"); return { success: true, details: res.data }; }
|
|
@@ -153,61 +203,488 @@ class C64uBackend {
|
|
|
153
203
|
async filesCreateDnp(p, tracks, options) { const res = await this.api.v1.filesCreateDnpUpdate(encodeURIComponent(p), ":create_dnp", { tracks, diskname: options?.diskname }); return { success: true, details: res.data }; }
|
|
154
204
|
async modplayFile(pathStr) { const res = await this.api.v1.runnersModplayUpdate(":modplay", { file: pathStr }); return { success: true, details: res.data }; }
|
|
155
205
|
}
|
|
156
|
-
class ViceBackend {
|
|
206
|
+
export class ViceBackend {
|
|
157
207
|
constructor(config) {
|
|
158
208
|
this.type = "vice";
|
|
159
|
-
this.
|
|
209
|
+
this.debugEnabled = process.env.VICE_DEVICE_TEST_DEBUG === "1";
|
|
210
|
+
this.startupPromise = null;
|
|
211
|
+
this.monitorQueue = Promise.resolve();
|
|
212
|
+
this.lastProcessStart = 0;
|
|
213
|
+
ViceBackend.ensureCleanupRegistration();
|
|
214
|
+
const envBinary = configuredString(process.env.VICE_BINARY);
|
|
215
|
+
const configBinary = config.exe !== undefined
|
|
216
|
+
? configuredString(config.exe) ?? (typeof config.exe === "string" ? config.exe : String(config.exe))
|
|
217
|
+
: undefined;
|
|
218
|
+
this.exe = resolveViceBinary({ envBinary, configBinary });
|
|
219
|
+
const envHost = normaliseViceHost(process.env.VICE_HOST);
|
|
220
|
+
const envPort = normaliseVicePort(process.env.VICE_PORT);
|
|
221
|
+
this.host = firstDefined(envHost, normaliseViceHost(config.host)) ?? DEFAULT_VICE_HOST;
|
|
222
|
+
this.port = firstDefined(envPort, normaliseVicePort(config.port)) ?? DEFAULT_VICE_PORT;
|
|
223
|
+
this.directory = firstDefined(resolveViceDirectory(configuredString(process.env.VICE_DIRECTORY)), resolveViceDirectory(configuredString(config.directory)), findViceResourceDirectory(this.exe));
|
|
224
|
+
this.mockMode = (process.env.VICE_TEST_TARGET || "").toLowerCase() === "mock";
|
|
225
|
+
const hostLower = this.host.toLowerCase();
|
|
226
|
+
const isLocal = hostLower === "127.0.0.1" || hostLower === "localhost";
|
|
227
|
+
this.manageProcess = !this.mockMode && isLocal;
|
|
228
|
+
const warpEnv = configuredBoolean(process.env.VICE_WARP);
|
|
229
|
+
const visibleEnv = configuredBoolean(process.env.VICE_VISIBLE);
|
|
230
|
+
this.visible = visibleEnv ?? configuredBoolean(config.visible) ?? true;
|
|
231
|
+
this.warp = warpEnv ?? configuredBoolean(config.warp) ?? !this.visible;
|
|
232
|
+
this.extraArgs = resolveViceArgs(config);
|
|
160
233
|
}
|
|
161
|
-
async
|
|
162
|
-
|
|
163
|
-
const tmp = writeTempPrg(prg);
|
|
234
|
+
async tryPingExisting() {
|
|
235
|
+
const client = new ViceClient();
|
|
164
236
|
try {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
237
|
+
if (this.debugEnabled)
|
|
238
|
+
console.error("[vice-backend] probing existing VICE", this.host, this.port);
|
|
239
|
+
await client.connect(this.port, this.host);
|
|
240
|
+
await client.info();
|
|
241
|
+
if (this.debugEnabled)
|
|
242
|
+
console.error("[vice-backend] existing VICE is reachable");
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return false;
|
|
168
247
|
}
|
|
169
248
|
finally {
|
|
249
|
+
client.close();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async waitForResponsiveMonitor(timeoutMs = 10000) {
|
|
253
|
+
const deadline = Date.now() + timeoutMs;
|
|
254
|
+
let lastError;
|
|
255
|
+
while (Date.now() < deadline) {
|
|
256
|
+
const client = new ViceClient();
|
|
170
257
|
try {
|
|
171
|
-
|
|
258
|
+
await client.connect(this.port, this.host);
|
|
259
|
+
await client.info();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
lastError = error;
|
|
264
|
+
await delay(250);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
client.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
throw lastError instanceof Error
|
|
271
|
+
? lastError
|
|
272
|
+
: new Error(`VICE monitor at ${this.host}:${this.port} did not become responsive within ${timeoutMs}ms`);
|
|
273
|
+
}
|
|
274
|
+
async waitForUsableMachine(timeoutMs = 20000) {
|
|
275
|
+
const client = new ViceClient();
|
|
276
|
+
try {
|
|
277
|
+
await client.connect(this.port, this.host);
|
|
278
|
+
const readiness = await waitForBasicReady(client, { timeoutMs, ensurePrompt: true });
|
|
279
|
+
if (!readiness.pointersOk || !readiness.promptOk) {
|
|
280
|
+
throw new Error(`VICE machine at ${this.host}:${this.port} did not reach the BASIC prompt within ${timeoutMs}ms`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
client.close();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async ensureProcessInternal() {
|
|
288
|
+
if (!this.manageProcess)
|
|
289
|
+
return;
|
|
290
|
+
const key = `${this.host}:${this.port}`;
|
|
291
|
+
const existing = ViceBackend.supervisors.get(key);
|
|
292
|
+
if (existing) {
|
|
293
|
+
const running = existing.process.exitCode === null && existing.process.signalCode === null;
|
|
294
|
+
if (running)
|
|
295
|
+
return;
|
|
296
|
+
try {
|
|
297
|
+
await existing.stop();
|
|
172
298
|
}
|
|
173
299
|
catch { }
|
|
300
|
+
ViceBackend.supervisors.delete(key);
|
|
301
|
+
}
|
|
302
|
+
if (await this.tryPingExisting()) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (this.debugEnabled) {
|
|
306
|
+
console.error("[vice-backend] starting VICE process", {
|
|
307
|
+
binary: this.exe,
|
|
308
|
+
host: this.host,
|
|
309
|
+
port: this.port,
|
|
310
|
+
warp: this.warp,
|
|
311
|
+
visible: this.visible,
|
|
312
|
+
extraArgs: this.extraArgs,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const handle = await startViceProcess({
|
|
316
|
+
binary: this.exe,
|
|
317
|
+
directory: this.directory,
|
|
318
|
+
host: this.host,
|
|
319
|
+
port: this.port,
|
|
320
|
+
warp: this.warp,
|
|
321
|
+
visible: this.visible,
|
|
322
|
+
extraArgs: this.extraArgs.length > 0 ? this.extraArgs : undefined,
|
|
323
|
+
});
|
|
324
|
+
this.lastProcessStart = Date.now();
|
|
325
|
+
if (this.debugEnabled) {
|
|
326
|
+
console.error("[vice-backend] VICE process started", { pid: handle.process.pid });
|
|
327
|
+
}
|
|
328
|
+
ViceBackend.supervisors.set(key, handle);
|
|
329
|
+
handle.process.once("exit", () => {
|
|
330
|
+
ViceBackend.supervisors.delete(key);
|
|
331
|
+
});
|
|
332
|
+
// Allow VICE to fully initialize its display before connecting to the monitor.
|
|
333
|
+
// Early monitor connection can interfere with the boot sequence.
|
|
334
|
+
await delay(1000);
|
|
335
|
+
await this.waitForResponsiveMonitor(20000);
|
|
336
|
+
await this.waitForUsableMachine(20000);
|
|
337
|
+
}
|
|
338
|
+
async ensureProcess() {
|
|
339
|
+
if (!this.manageProcess) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!this.startupPromise) {
|
|
343
|
+
this.startupPromise = this.ensureProcessInternal().finally(() => {
|
|
344
|
+
this.startupPromise = null;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
await this.startupPromise;
|
|
348
|
+
}
|
|
349
|
+
static ensureCleanupRegistration() {
|
|
350
|
+
if (ViceBackend.cleanupRegistered) {
|
|
351
|
+
return;
|
|
174
352
|
}
|
|
353
|
+
ViceBackend.cleanupRegistered = true;
|
|
354
|
+
process.once("exit", () => {
|
|
355
|
+
for (const [, handle] of ViceBackend.supervisors) {
|
|
356
|
+
handle.stop().catch(() => { });
|
|
357
|
+
}
|
|
358
|
+
ViceBackend.supervisors.clear();
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
async withClient(fn) {
|
|
362
|
+
const previous = this.monitorQueue;
|
|
363
|
+
let releaseQueue = () => { };
|
|
364
|
+
this.monitorQueue = new Promise((resolve) => {
|
|
365
|
+
releaseQueue = resolve;
|
|
366
|
+
});
|
|
367
|
+
await previous;
|
|
368
|
+
try {
|
|
369
|
+
if (!this.mockMode)
|
|
370
|
+
await this.ensureProcess();
|
|
371
|
+
const client = new ViceClient();
|
|
372
|
+
if (this.debugEnabled)
|
|
373
|
+
console.error("[vice-backend] connecting to VICE monitor", { host: this.host, port: this.port });
|
|
374
|
+
await client.connect(this.port, this.host);
|
|
375
|
+
if (this.manageProcess && this.lastProcessStart > 0) {
|
|
376
|
+
const sinceStart = Date.now() - this.lastProcessStart;
|
|
377
|
+
const settleDelay = 500 - sinceStart;
|
|
378
|
+
if (settleDelay > 0) {
|
|
379
|
+
await delay(settleDelay);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
if (this.debugEnabled)
|
|
384
|
+
console.error("[vice-backend] connected to VICE monitor");
|
|
385
|
+
return await fn(client);
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
if (this.debugEnabled)
|
|
389
|
+
console.error("[vice-backend] closing VICE monitor connection");
|
|
390
|
+
client.close();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
releaseQueue();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async withMonitor(fn) {
|
|
398
|
+
return this.withClient(fn);
|
|
399
|
+
}
|
|
400
|
+
async ping() {
|
|
401
|
+
const attempts = 8;
|
|
402
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
403
|
+
try {
|
|
404
|
+
await this.withClient(async (client) => {
|
|
405
|
+
await client.info();
|
|
406
|
+
});
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
if (this.debugEnabled) {
|
|
411
|
+
console.error(`[vice-backend] ping failed (attempt ${attempt}/${attempts})`, error);
|
|
412
|
+
}
|
|
413
|
+
if (attempt < attempts) {
|
|
414
|
+
await delay(Math.min(1000, 200 * attempt));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
async injectPrg(buffer) {
|
|
421
|
+
if (buffer.length < 2)
|
|
422
|
+
throw new Error("PRG data too short");
|
|
423
|
+
const loadAddress = buffer.readUInt16LE(0);
|
|
424
|
+
const body = buffer.subarray(2);
|
|
425
|
+
await this.withClient(async (client) => {
|
|
426
|
+
await client.reset();
|
|
427
|
+
await waitForBasicReady(client, { timeoutMs: 10000, ensurePrompt: true });
|
|
428
|
+
if (body.length > 0)
|
|
429
|
+
await client.memSet(loadAddress, body);
|
|
430
|
+
const programEnd = loadAddress + body.length;
|
|
431
|
+
const ptrs = Buffer.alloc(8);
|
|
432
|
+
ptrs.writeUInt16LE(loadAddress, 0);
|
|
433
|
+
ptrs.writeUInt16LE(programEnd, 2);
|
|
434
|
+
ptrs.writeUInt16LE(programEnd, 4);
|
|
435
|
+
ptrs.writeUInt16LE(programEnd, 6);
|
|
436
|
+
await client.memSet(0x002B, ptrs);
|
|
437
|
+
await client.keyboardFeed("RUN\r");
|
|
438
|
+
await client.exitMonitor();
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
async runPrg(prg) {
|
|
442
|
+
const buffer = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
|
|
443
|
+
await this.injectPrg(buffer);
|
|
444
|
+
return { success: true };
|
|
175
445
|
}
|
|
176
446
|
async loadPrgFile(_path) { throw unsupported("loadPrgFile"); }
|
|
177
447
|
async runPrgFile(prgPath) {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
return { success:
|
|
448
|
+
const data = fs.readFileSync(prgPath);
|
|
449
|
+
await this.injectPrg(data);
|
|
450
|
+
return { success: true };
|
|
181
451
|
}
|
|
182
452
|
async runCrtFile(_path) { throw unsupported("runCrtFile"); }
|
|
183
453
|
async sidplayFile(_p) { throw unsupported("sidplayFile"); }
|
|
184
454
|
async sidplayAttachment(_sid) { throw unsupported("sidplayAttachment"); }
|
|
185
|
-
async readMemory(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
455
|
+
async readMemory(address, length) {
|
|
456
|
+
if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
|
|
457
|
+
throw new Error("Address must be within 0x0000-0xFFFF");
|
|
458
|
+
}
|
|
459
|
+
if (!Number.isInteger(length) || length <= 0) {
|
|
460
|
+
throw new Error("Length must be positive");
|
|
461
|
+
}
|
|
462
|
+
const end = Math.min(0xffff, address + length - 1);
|
|
463
|
+
return await this.withClient(async (client) => {
|
|
464
|
+
const buf = await client.memGet(address, end);
|
|
465
|
+
return buf.subarray(0, Math.min(buf.length, length));
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
async writeMemory(address, bytes) {
|
|
469
|
+
if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
|
|
470
|
+
throw new Error("Address must be within 0x0000-0xFFFF");
|
|
471
|
+
}
|
|
472
|
+
if (!(bytes instanceof Uint8Array) || bytes.length === 0) {
|
|
473
|
+
throw new Error("Bytes must be a non-empty Uint8Array");
|
|
474
|
+
}
|
|
475
|
+
await this.withClient(async (client) => {
|
|
476
|
+
await client.memSet(address, Buffer.from(bytes));
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
async writeMemoryBlocks(blocks) {
|
|
480
|
+
const mergedBlocks = coalesceMemoryWriteBlocks(blocks);
|
|
481
|
+
await this.withClient(async (client) => {
|
|
482
|
+
for (const { address, bytes } of mergedBlocks) {
|
|
483
|
+
await client.memSet(address, Buffer.from(bytes));
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
async reset() {
|
|
488
|
+
await this.withClient(async (client) => {
|
|
489
|
+
await client.reset();
|
|
490
|
+
const opts = this.debugEnabled
|
|
491
|
+
? {
|
|
492
|
+
timeoutMs: 20000,
|
|
493
|
+
ensurePrompt: true,
|
|
494
|
+
onPointersSample: (p) => {
|
|
495
|
+
console.error("[vice-backend] BASIC pointers", p);
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
: { timeoutMs: 20000, ensurePrompt: true };
|
|
499
|
+
const readiness = await waitForBasicReady(client, opts);
|
|
500
|
+
if (this.debugEnabled)
|
|
501
|
+
console.error("[vice-backend] waitForBasicReady result", readiness);
|
|
502
|
+
});
|
|
503
|
+
return { success: true };
|
|
504
|
+
}
|
|
505
|
+
async reboot() { return this.reset(); }
|
|
506
|
+
async pause() {
|
|
507
|
+
return {
|
|
508
|
+
success: false,
|
|
509
|
+
details: {
|
|
510
|
+
code: "UNSUPPORTED",
|
|
511
|
+
message: "VICE pause is not implemented as a durable machine stop; use per-operation monitor access instead.",
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
async resume() {
|
|
516
|
+
return {
|
|
517
|
+
success: false,
|
|
518
|
+
details: {
|
|
519
|
+
code: "UNSUPPORTED",
|
|
520
|
+
message: "VICE resume is unavailable because durable pause is not supported.",
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
async poweroff() {
|
|
525
|
+
const key = this.supervisorKey();
|
|
526
|
+
const managedHandle = this.manageProcess ? ViceBackend.supervisors.get(key) : null;
|
|
527
|
+
try {
|
|
528
|
+
await this.withClient(async (client) => {
|
|
529
|
+
try {
|
|
530
|
+
await client.quit();
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// Ignore transport errors during quit; the emulator will terminate regardless.
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
if (managedHandle) {
|
|
537
|
+
try {
|
|
538
|
+
await managedHandle.stop();
|
|
539
|
+
}
|
|
540
|
+
catch { }
|
|
541
|
+
ViceBackend.supervisors.delete(key);
|
|
542
|
+
}
|
|
543
|
+
return { success: true };
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
const details = error instanceof Error
|
|
547
|
+
? { message: error.message }
|
|
548
|
+
: error;
|
|
549
|
+
return { success: false, details };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
192
552
|
async menuButton() { throw unsupported("menuButton"); }
|
|
193
553
|
async debugregRead() { throw unsupported("debugregRead"); }
|
|
194
554
|
async debugregWrite(_v) { throw unsupported("debugregWrite"); }
|
|
195
|
-
async version() { return { emulator: "vice" }; }
|
|
196
|
-
async info() {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
555
|
+
async version() { return { emulator: "vice", host: this.host, port: this.port }; }
|
|
556
|
+
async info() {
|
|
557
|
+
return await this.withClient(async (client) => {
|
|
558
|
+
await client.info();
|
|
559
|
+
return { emulator: "vice", host: this.host, port: this.port };
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
getEndpoint() {
|
|
563
|
+
return { host: this.host, port: this.port };
|
|
564
|
+
}
|
|
565
|
+
async drivesList() {
|
|
566
|
+
return await this.withClient(async (client) => {
|
|
567
|
+
const drives = [];
|
|
568
|
+
for (const n of [8, 9, 10, 11]) {
|
|
569
|
+
try {
|
|
570
|
+
const enabled = await client.resourceGet(`Drive${n}CPUEnabled`);
|
|
571
|
+
const image = await client.resourceGet(`Drive${n}Image`);
|
|
572
|
+
const typeRes = await client.resourceGet(`Drive${n}Type`);
|
|
573
|
+
drives.push({
|
|
574
|
+
id: `drive${n}`,
|
|
575
|
+
power: (typeof enabled.value === "number" ? enabled.value : 0) ? "on" : "off",
|
|
576
|
+
image: typeof image.value === "string" && image.value ? image.value : null,
|
|
577
|
+
type: typeRes.value,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
drives.push({ id: `drive${n}`, power: "off", image: null, type: 0 });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return drives;
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
async driveMount(drive, imagePath) {
|
|
588
|
+
const n = parseDriveNumber(drive);
|
|
589
|
+
await this.withClient(async (client) => {
|
|
590
|
+
await client.resourceSet(`Drive${n}CPUEnabled`, 1);
|
|
591
|
+
await client.resourceSet(`Drive${n}Image`, imagePath);
|
|
592
|
+
});
|
|
593
|
+
return { success: true, details: { drive, image: imagePath } };
|
|
594
|
+
}
|
|
595
|
+
async driveRemove(drive) {
|
|
596
|
+
const n = parseDriveNumber(drive);
|
|
597
|
+
await this.withClient(async (client) => {
|
|
598
|
+
await client.resourceSet(`Drive${n}Image`, "");
|
|
599
|
+
});
|
|
600
|
+
return { success: true, details: { drive } };
|
|
601
|
+
}
|
|
602
|
+
async driveReset(drive) {
|
|
603
|
+
const n = parseDriveNumber(drive);
|
|
604
|
+
await this.withClient(async (client) => {
|
|
605
|
+
await client.resourceSet(`Drive${n}CPUEnabled`, 0);
|
|
606
|
+
await client.resourceSet(`Drive${n}CPUEnabled`, 1);
|
|
607
|
+
});
|
|
608
|
+
return { success: true, details: { drive } };
|
|
609
|
+
}
|
|
610
|
+
async driveOn(drive) {
|
|
611
|
+
const n = parseDriveNumber(drive);
|
|
612
|
+
await this.withClient(async (client) => {
|
|
613
|
+
await client.resourceSet(`Drive${n}CPUEnabled`, 1);
|
|
614
|
+
});
|
|
615
|
+
return { success: true, details: { drive, power: "on" } };
|
|
616
|
+
}
|
|
617
|
+
async driveOff(drive) {
|
|
618
|
+
const n = parseDriveNumber(drive);
|
|
619
|
+
await this.withClient(async (client) => {
|
|
620
|
+
await client.resourceSet(`Drive${n}CPUEnabled`, 0);
|
|
621
|
+
});
|
|
622
|
+
return { success: true, details: { drive, power: "off" } };
|
|
623
|
+
}
|
|
624
|
+
async driveSetMode(drive, mode) {
|
|
625
|
+
const n = parseDriveNumber(drive);
|
|
626
|
+
const DRIVE_TYPE = { "1541": 2, "1571": 8, "1581": 11 };
|
|
627
|
+
const typeNum = DRIVE_TYPE[mode];
|
|
628
|
+
if (typeNum === undefined)
|
|
629
|
+
throw new Error(`Unknown drive mode: ${mode}`);
|
|
630
|
+
await this.withClient(async (client) => {
|
|
631
|
+
await client.resourceSet(`Drive${n}Type`, typeNum);
|
|
632
|
+
});
|
|
633
|
+
return { success: true, details: { drive, mode } };
|
|
634
|
+
}
|
|
204
635
|
async driveLoadRom() { throw unsupported("driveLoadRom"); }
|
|
205
636
|
async streamStart() { throw unsupported("streamStart"); }
|
|
206
637
|
async streamStop() { throw unsupported("streamStop"); }
|
|
207
|
-
async configsList() {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
638
|
+
async configsList() {
|
|
639
|
+
return {
|
|
640
|
+
categories: [
|
|
641
|
+
{
|
|
642
|
+
name: "VICE",
|
|
643
|
+
items: [
|
|
644
|
+
"WarpMode", "SoundVolume", "Drive8CPUEnabled", "Drive9CPUEnabled",
|
|
645
|
+
"Drive10CPUEnabled", "Drive11CPUEnabled", "Drive8Image", "Drive9Image",
|
|
646
|
+
"Drive8Type", "Drive9Type", "VICIIBorderMode", "VICIIFullscreen",
|
|
647
|
+
],
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
async configGet(_category, item) {
|
|
653
|
+
if (!item)
|
|
654
|
+
throw unsupported("configGet without item name");
|
|
655
|
+
return await this.withClient(async (client) => {
|
|
656
|
+
const res = await client.resourceGet(item);
|
|
657
|
+
return { category: _category, item, value: res.value, type: res.type };
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
async configSet(_category, item, value) {
|
|
661
|
+
const numValue = Number(value);
|
|
662
|
+
const parsed = !isNaN(numValue) && value.trim() !== "" ? numValue : value;
|
|
663
|
+
await this.withClient(async (client) => {
|
|
664
|
+
await client.resourceSet(item, parsed);
|
|
665
|
+
});
|
|
666
|
+
return { success: true, details: { item, value: parsed } };
|
|
667
|
+
}
|
|
668
|
+
async configBatchUpdate(payload) {
|
|
669
|
+
const results = [];
|
|
670
|
+
await this.withClient(async (client) => {
|
|
671
|
+
for (const [category, items] of Object.entries(payload)) {
|
|
672
|
+
for (const [item, value] of Object.entries(items)) {
|
|
673
|
+
try {
|
|
674
|
+
const str = String(value ?? "");
|
|
675
|
+
const numValue = Number(str);
|
|
676
|
+
const parsed = !isNaN(numValue) && str.trim() !== "" ? numValue : str;
|
|
677
|
+
await client.resourceSet(item, parsed);
|
|
678
|
+
results.push({ item: `${category}/${item}`, success: true });
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
results.push({ item: `${category}/${item}`, success: false, error: err instanceof Error ? err.message : String(err) });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
return { success: results.every((r) => r.success), details: { results } };
|
|
687
|
+
}
|
|
211
688
|
async configLoadFromFlash() { throw unsupported("configLoadFromFlash"); }
|
|
212
689
|
async configSaveToFlash() { throw unsupported("configSaveToFlash"); }
|
|
213
690
|
async configResetToDefault() { throw unsupported("configResetToDefault"); }
|
|
@@ -216,8 +693,20 @@ class ViceBackend {
|
|
|
216
693
|
async filesCreateD71() { throw unsupported("filesCreateD71"); }
|
|
217
694
|
async filesCreateD81() { throw unsupported("filesCreateD81"); }
|
|
218
695
|
async filesCreateDnp() { throw unsupported("filesCreateDnp"); }
|
|
696
|
+
supervisorKey() {
|
|
697
|
+
return `${this.host}:${this.port}`;
|
|
698
|
+
}
|
|
219
699
|
}
|
|
700
|
+
ViceBackend.supervisors = new Map();
|
|
701
|
+
ViceBackend.cleanupRegistered = false;
|
|
220
702
|
function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend in phase one`); err.code = "UNSUPPORTED"; return err; }
|
|
703
|
+
function parseDriveNumber(drive) {
|
|
704
|
+
const match = /\d+/.exec(drive);
|
|
705
|
+
const n = match ? parseInt(match[0], 10) : NaN;
|
|
706
|
+
if (isNaN(n) || n < 8 || n > 11)
|
|
707
|
+
throw new Error(`Invalid drive specification: ${drive}`);
|
|
708
|
+
return n;
|
|
709
|
+
}
|
|
221
710
|
function extractBytes(data) {
|
|
222
711
|
if (!data)
|
|
223
712
|
return new Uint8Array();
|
|
@@ -271,24 +760,23 @@ function which(binary) {
|
|
|
271
760
|
}
|
|
272
761
|
return null;
|
|
273
762
|
}
|
|
274
|
-
function
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
763
|
+
function resolveViceBinary(options, findBinary = which) {
|
|
764
|
+
const explicit = firstDefined(options.envBinary, options.configBinary);
|
|
765
|
+
if (explicit) {
|
|
766
|
+
const resolvedExplicit = findBinary(explicit);
|
|
767
|
+
if (resolvedExplicit) {
|
|
768
|
+
return resolvedExplicit;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
for (const candidate of ["/usr/local/bin/x64sc", "/usr/local/bin/x64"]) {
|
|
772
|
+
if (findBinary(candidate)) {
|
|
773
|
+
return candidate;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return findBinary("x64sc") ?? findBinary("x64") ?? "x64sc";
|
|
280
777
|
}
|
|
281
|
-
function
|
|
282
|
-
|
|
283
|
-
return await new Promise((resolve) => {
|
|
284
|
-
const child = spawn(file, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
285
|
-
const timer = setTimeout(() => { try {
|
|
286
|
-
child.kill("SIGKILL");
|
|
287
|
-
}
|
|
288
|
-
catch { } }, Math.max(1000, timeoutMs));
|
|
289
|
-
child.on("close", (code) => { clearTimeout(timer); resolve(code ?? 0); });
|
|
290
|
-
child.on("error", () => { clearTimeout(timer); resolve(127); });
|
|
291
|
-
});
|
|
778
|
+
export function __resolveViceBinaryForTests(options, findBinary) {
|
|
779
|
+
return resolveViceBinary(options, findBinary ?? which);
|
|
292
780
|
}
|
|
293
781
|
export async function createFacade(logger, options) {
|
|
294
782
|
// Caller-forced preference: use c64u with provided base URL (used by tests and server wiring)
|
|
@@ -312,7 +800,8 @@ export async function createFacade(logger, options) {
|
|
|
312
800
|
if (envMode === "vice") {
|
|
313
801
|
const backend = new ViceBackend(cfg?.vice ?? {});
|
|
314
802
|
logger?.info?.("Active backend: vice (from env override)");
|
|
315
|
-
|
|
803
|
+
const endpoint = backend.getEndpoint();
|
|
804
|
+
return { facade: backend, selected: "vice", reason: "env override", details: { host: endpoint.host, port: endpoint.port } };
|
|
316
805
|
}
|
|
317
806
|
if (hasC64u && !hasVice) {
|
|
318
807
|
const backend = new C64uBackend(cfg.c64u);
|
|
@@ -322,7 +811,8 @@ export async function createFacade(logger, options) {
|
|
|
322
811
|
if (!hasC64u && hasVice) {
|
|
323
812
|
const backend = new ViceBackend(cfg.vice);
|
|
324
813
|
logger?.info?.("Active backend: vice (from config)");
|
|
325
|
-
|
|
814
|
+
const endpoint = backend.getEndpoint();
|
|
815
|
+
return { facade: backend, selected: "vice", reason: "config only", details: { host: endpoint.host, port: endpoint.port } };
|
|
326
816
|
}
|
|
327
817
|
if (hasC64u && hasVice) {
|
|
328
818
|
const backend = new C64uBackend(cfg.c64u);
|
|
@@ -342,7 +832,46 @@ export async function createFacade(logger, options) {
|
|
|
342
832
|
catch { }
|
|
343
833
|
const backend = new ViceBackend(cfg?.vice ?? {});
|
|
344
834
|
logger?.info?.("Active backend: vice (fallback – hardware unavailable)");
|
|
345
|
-
|
|
835
|
+
const endpoint = backend.getEndpoint();
|
|
836
|
+
return { facade: backend, selected: "vice", reason: "fallback (hardware unavailable)", details: { host: endpoint.host, port: endpoint.port } };
|
|
837
|
+
}
|
|
838
|
+
export async function createAllFacades(logger, options) {
|
|
839
|
+
const primary = await createFacade(logger);
|
|
840
|
+
const config = readConfigFile();
|
|
841
|
+
const secondaryType = primary.selected === "c64u" ? "vice" : "c64u";
|
|
842
|
+
if (secondaryType === "c64u" && shouldProvisionSecondaryC64u(config, options)) {
|
|
843
|
+
const secondaryConfig = {
|
|
844
|
+
...(config?.c64u ?? {}),
|
|
845
|
+
...(!config?.c64u?.baseUrl && options?.preferredC64uBaseUrl ? { baseUrl: options.preferredC64uBaseUrl } : {}),
|
|
846
|
+
...(!config?.c64u?.networkPassword && options?.preferredC64uNetworkPassword
|
|
847
|
+
? { networkPassword: options.preferredC64uNetworkPassword }
|
|
848
|
+
: {}),
|
|
849
|
+
};
|
|
850
|
+
return {
|
|
851
|
+
primary,
|
|
852
|
+
secondary: new C64uBackend(secondaryConfig),
|
|
853
|
+
secondaryType,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
if (secondaryType === "vice" && config?.vice) {
|
|
857
|
+
return {
|
|
858
|
+
primary,
|
|
859
|
+
secondary: new ViceBackend(config.vice),
|
|
860
|
+
secondaryType,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
primary,
|
|
865
|
+
secondary: null,
|
|
866
|
+
secondaryType: null,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
function shouldProvisionSecondaryC64u(config, options) {
|
|
870
|
+
return Boolean(config?.c64u
|
|
871
|
+
|| options?.preferredC64uBaseUrl
|
|
872
|
+
|| configuredString(process.env.C64U_HOST)
|
|
873
|
+
|| configuredPort(process.env.C64U_PORT)
|
|
874
|
+
|| configuredString(process.env.C64U_PASSWORD));
|
|
346
875
|
}
|
|
347
876
|
function resolveBaseUrl(config) {
|
|
348
877
|
const explicit = normaliseBaseUrl(config.baseUrl);
|
|
@@ -367,6 +896,83 @@ function configuredString(value) {
|
|
|
367
896
|
}
|
|
368
897
|
return undefined;
|
|
369
898
|
}
|
|
899
|
+
function configuredBoolean(value) {
|
|
900
|
+
if (typeof value === "boolean") {
|
|
901
|
+
return value;
|
|
902
|
+
}
|
|
903
|
+
const input = configuredString(value);
|
|
904
|
+
if (!input)
|
|
905
|
+
return undefined;
|
|
906
|
+
const normalised = input.toLowerCase();
|
|
907
|
+
if (normalised === "1" || normalised === "true" || normalised === "yes" || normalised === "on") {
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
if (normalised === "0" || normalised === "false" || normalised === "no" || normalised === "off") {
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
913
|
+
return undefined;
|
|
914
|
+
}
|
|
915
|
+
function resolveViceArgs(config) {
|
|
916
|
+
const argsEnv = configuredString(process.env.VICE_ARGS);
|
|
917
|
+
if (argsEnv) {
|
|
918
|
+
return parseArgsList(argsEnv);
|
|
919
|
+
}
|
|
920
|
+
if (Array.isArray(config.args)) {
|
|
921
|
+
return config.args
|
|
922
|
+
.filter((value) => typeof value === "string")
|
|
923
|
+
.map((value) => value.trim())
|
|
924
|
+
.filter((value) => value.length > 0);
|
|
925
|
+
}
|
|
926
|
+
if (typeof config.args === "string") {
|
|
927
|
+
return parseArgsList(config.args);
|
|
928
|
+
}
|
|
929
|
+
return [];
|
|
930
|
+
}
|
|
931
|
+
function resolveViceDirectory(value) {
|
|
932
|
+
const input = configuredString(value);
|
|
933
|
+
if (!input) {
|
|
934
|
+
return undefined;
|
|
935
|
+
}
|
|
936
|
+
return isViceResourceDirectory(input) ? input : undefined;
|
|
937
|
+
}
|
|
938
|
+
function findViceResourceDirectory(binaryPath) {
|
|
939
|
+
const candidates = new Set();
|
|
940
|
+
const resolvedBinary = configuredString(binaryPath);
|
|
941
|
+
if (resolvedBinary) {
|
|
942
|
+
const binaryDir = path.dirname(resolvedBinary);
|
|
943
|
+
candidates.add(path.resolve(binaryDir, "..", "share", "vice"));
|
|
944
|
+
candidates.add(path.resolve(binaryDir, "..", "..", "share", "vice"));
|
|
945
|
+
}
|
|
946
|
+
candidates.add("/usr/local/share/vice");
|
|
947
|
+
candidates.add("/usr/share/vice");
|
|
948
|
+
candidates.add("/opt/homebrew/share/vice");
|
|
949
|
+
candidates.add("/opt/local/share/vice");
|
|
950
|
+
for (const candidate of candidates) {
|
|
951
|
+
if (isViceResourceDirectory(candidate)) {
|
|
952
|
+
return candidate;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
function isViceResourceDirectory(candidate) {
|
|
958
|
+
const normalized = configuredString(candidate);
|
|
959
|
+
if (!normalized) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
const requiredFiles = [
|
|
963
|
+
path.join(normalized, "C64", "kernal-901227-03.bin"),
|
|
964
|
+
path.join(normalized, "C64", "basic-901226-01.bin"),
|
|
965
|
+
path.join(normalized, "C64", "chargen-901225-01.bin"),
|
|
966
|
+
];
|
|
967
|
+
return requiredFiles.every((filePath) => {
|
|
968
|
+
try {
|
|
969
|
+
return fs.existsSync(filePath);
|
|
970
|
+
}
|
|
971
|
+
catch {
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
}
|
|
370
976
|
function configuredPort(value) {
|
|
371
977
|
if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) {
|
|
372
978
|
return value;
|
|
@@ -418,6 +1024,16 @@ function formatHost(host) {
|
|
|
418
1024
|
function stripTrailingSlash(input) {
|
|
419
1025
|
return input.replace(/\/+$/, "");
|
|
420
1026
|
}
|
|
1027
|
+
function parseArgsList(input) {
|
|
1028
|
+
const args = [];
|
|
1029
|
+
const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
|
|
1030
|
+
let match;
|
|
1031
|
+
while ((match = pattern.exec(input)) !== null) {
|
|
1032
|
+
const value = match[1] ?? match[2] ?? match[3] ?? "";
|
|
1033
|
+
args.push(value.replace(/\\(["'\\])/g, "$1"));
|
|
1034
|
+
}
|
|
1035
|
+
return args;
|
|
1036
|
+
}
|
|
421
1037
|
function firstDefined(...values) {
|
|
422
1038
|
for (const value of values) {
|
|
423
1039
|
if (value !== undefined && value !== null)
|
|
@@ -425,3 +1041,10 @@ function firstDefined(...values) {
|
|
|
425
1041
|
}
|
|
426
1042
|
return undefined;
|
|
427
1043
|
}
|
|
1044
|
+
function normaliseViceHost(input) {
|
|
1045
|
+
const trimmed = configuredString(input);
|
|
1046
|
+
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
|
1047
|
+
}
|
|
1048
|
+
function normaliseVicePort(value) {
|
|
1049
|
+
return configuredPort(value);
|
|
1050
|
+
}
|