c64bridge 1.0.3 → 1.0.5
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 +12 -12
- 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/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +68 -61
- package/dist/config.js +75 -38
- package/dist/device.js +154 -120
- package/dist/knowledge.js +2 -1
- package/dist/petscii.js +18 -15
- package/dist/petsciiArt.js +40 -14
- package/dist/rag/indexer.js +2 -1
- package/dist/sidwave.js +1 -1
- package/dist/sidwaveCompiler.js +3 -9
- package/dist/tools/audio.js +34 -8
- package/dist/tools/debug.js +9 -9
- package/dist/tools/errors.js +12 -1
- package/dist/tools/hex.js +9 -0
- package/dist/tools/input.js +13 -7
- package/dist/tools/memory.js +5 -4
- package/dist/tools/meta/artifacts.js +8 -3
- package/dist/tools/meta/audio.js +25 -28
- package/dist/tools/meta/background.js +6 -1
- package/dist/tools/meta/config.js +12 -4
- package/dist/tools/meta/configInventory.js +4 -1
- package/dist/tools/meta/filesystem.js +37 -10
- package/dist/tools/meta/graphics.js +10 -15
- package/dist/tools/meta/memory.js +12 -18
- package/dist/tools/meta/program.js +44 -16
- package/dist/tools/pollValidator.js +30 -2
- package/dist/tools/programRunners.js +5 -4
- package/dist/tools/registry/batch.js +4 -2
- package/dist/tools/registry/config.js +1 -1
- package/dist/tools/registry/disk.js +5 -0
- package/dist/tools/registry/extract.js +3 -0
- package/dist/tools/registry/graphics.js +1 -0
- package/dist/tools/registry/program.js +1 -1
- package/dist/tools/registry/sound.js +3 -3
- package/dist/tools/registry/system.js +3 -3
- package/dist/tools/storage.js +2 -2
- package/dist/tools/translation/assembler.js +150 -47
- package/dist/tools/translation/basicTokenizer.js +39 -9
- package/dist/tools/types.js +11 -4
- package/dist/vicBitmap.js +16 -0
- package/dist/vice/mockServer.js +7 -3
- package/dist/vice/viceClient.js +16 -6
- package/mcp/schemas/c64_config.schema.json +2 -2
- package/mcp/schemas/c64_program.schema.json +10 -18
- package/mcp/schemas/c64_sound.schema.json +8 -6
- package/mcp/schemas/c64_system.schema.json +9 -7
- package/mcp/server.json +1 -1
- package/mcp/tools.json +58 -43
- package/mcp.json +1 -1
- package/package.json +1 -1
- package/scripts/mockC64Server.mjs +98 -19
- package/server.json +2 -2
package/dist/device.js
CHANGED
|
@@ -10,9 +10,7 @@ import { createLoggingHttpClient } from "./loggingHttpClient.js";
|
|
|
10
10
|
import { ViceClient } from "./vice/viceClient.js";
|
|
11
11
|
import { waitForBasicReady } from "./vice/readiness.js";
|
|
12
12
|
import { startViceProcess } from "./vice/process.js";
|
|
13
|
-
import { loadConfig } from "./config.js";
|
|
14
|
-
const DEFAULT_C64U_HOST = "c64u";
|
|
15
|
-
const DEFAULT_C64U_PORT = 80;
|
|
13
|
+
import { loadConfig, resolveUltimateEndpoint } from "./config.js";
|
|
16
14
|
const DEFAULT_VICE_HOST = "127.0.0.1";
|
|
17
15
|
const DEFAULT_VICE_PORT = 6502;
|
|
18
16
|
function delay(ms) {
|
|
@@ -52,28 +50,44 @@ function coalesceMemoryWriteBlocks(blocks) {
|
|
|
52
50
|
}
|
|
53
51
|
return merged;
|
|
54
52
|
}
|
|
53
|
+
/** All C64U/U2 endpoints come from loadConfig(), so the MCP server and every
|
|
54
|
+
* backend agree on host, port and password. */
|
|
55
55
|
function readConfigFile() {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
const config = loadConfig();
|
|
57
|
+
const sections = config.backendConfig;
|
|
58
|
+
return {
|
|
59
|
+
c64u: sections.c64u || config.legacyUltimateEndpoint ? config.ultimate.c64u : undefined,
|
|
60
|
+
u2: sections.u2 ? config.ultimate.u2 : undefined,
|
|
60
61
|
vice: sections.vice,
|
|
62
|
+
endpoints: config.ultimate,
|
|
61
63
|
};
|
|
62
64
|
}
|
|
65
|
+
/** Firmware drive ids (route_drives.h DriveToSubsys, matched case-insensitively). */
|
|
66
|
+
const FIRMWARE_DRIVE_IDS = new Set(["a", "b", "softiec"]);
|
|
67
|
+
/** GET /v1/drives reports the soft IEC drive under this name; the drive routes call it softiec. */
|
|
68
|
+
const SOFT_IEC_LISTING_ID = "iec drive";
|
|
69
|
+
/** Factory bus numbers of drives a and b, used when the drive listing is unavailable. */
|
|
70
|
+
const DEFAULT_DRIVE_BY_BUS_ID = { 8: "a", 9: "b" };
|
|
71
|
+
/** The firmware reports a mounted image as directory (image_path) plus file name (image_file). */
|
|
72
|
+
function driveImage(info) {
|
|
73
|
+
const file = typeof info.image_file === "string" ? info.image_file : "";
|
|
74
|
+
const dir = typeof info.image_path === "string" ? info.image_path : "";
|
|
75
|
+
if (file)
|
|
76
|
+
return dir ? `${dir.replace(/\/+$/, "")}/${file}` : file;
|
|
77
|
+
if (dir)
|
|
78
|
+
return dir;
|
|
79
|
+
return typeof info.image === "string" && info.image ? info.image : null;
|
|
80
|
+
}
|
|
81
|
+
function uploadFile(bytes, name) {
|
|
82
|
+
return new File([Uint8Array.from(bytes)], name, { type: "application/octet-stream" });
|
|
83
|
+
}
|
|
63
84
|
class C64uBackend {
|
|
64
|
-
constructor(
|
|
85
|
+
constructor(endpoint, type = "c64u") {
|
|
65
86
|
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));
|
|
87
|
+
this.baseUrl = endpoint.baseUrl;
|
|
88
|
+
this.networkPassword = endpoint.networkPassword;
|
|
75
89
|
const http = createLoggingHttpClient({
|
|
76
|
-
baseURL: baseUrl,
|
|
90
|
+
baseURL: this.baseUrl,
|
|
77
91
|
timeout: 10000,
|
|
78
92
|
headers: buildC64uHeaders(this.networkPassword),
|
|
79
93
|
});
|
|
@@ -132,10 +146,15 @@ class C64uBackend {
|
|
|
132
146
|
return this.actionResult(res.data);
|
|
133
147
|
}
|
|
134
148
|
async sidplayAttachment(sid, options) {
|
|
135
|
-
|
|
149
|
+
// The generated multipart builder JSON-encodes anything that is not a Blob,
|
|
150
|
+
// so the bytes must be File parts. The firmware ignores part names and
|
|
151
|
+
// takes the first file as the SID and the second as song lengths; the spec
|
|
152
|
+
// names both `file`. The temp file keeps the upload name, and its extension
|
|
153
|
+
// selects SID versus MUS playback.
|
|
154
|
+
const file = [uploadFile(sid, "upload.sid")];
|
|
136
155
|
if (options?.songlengths)
|
|
137
|
-
|
|
138
|
-
const res = await this.api.v1.runnersSidplayCreate(":sidplay",
|
|
156
|
+
file.push(uploadFile(options.songlengths, "upload.ssl"));
|
|
157
|
+
const res = await this.api.v1.runnersSidplayCreate(":sidplay", { file }, options?.songnr !== undefined ? { songnr: options.songnr } : undefined);
|
|
139
158
|
return this.actionResult(res.data);
|
|
140
159
|
}
|
|
141
160
|
async readMemory(address, length) {
|
|
@@ -196,32 +215,75 @@ class C64uBackend {
|
|
|
196
215
|
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
216
|
async version() { const res = await this.api.v1.versionList(); return res.data; }
|
|
198
217
|
async info() { const res = await this.api.v1.infoList(); return res.data; }
|
|
199
|
-
async
|
|
218
|
+
async driveEntries() {
|
|
200
219
|
const res = await this.api.v1.drivesList();
|
|
201
220
|
const raw = res.data;
|
|
202
221
|
const entries = Array.isArray(raw.drives)
|
|
203
222
|
? raw.drives
|
|
204
223
|
: raw.drives && typeof raw.drives === "object" ? [raw.drives] : [];
|
|
205
|
-
return entries.flatMap((entry) => Object.entries(entry)
|
|
224
|
+
return entries.flatMap((entry) => Object.entries(entry));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Map a drive id to the firmware's `a`, `b` or `softiec`. Bus numbers such
|
|
228
|
+
* as `drive8` or `8` are looked up by `bus_id` among drives a and b in
|
|
229
|
+
* GET /v1/drives, because a drive letter's bus number is configurable; the
|
|
230
|
+
* factory a=8, b=9 applies only when the listing is unavailable. The soft
|
|
231
|
+
* IEC drive is reachable only by name, never by bus number.
|
|
232
|
+
*/
|
|
233
|
+
async resolveDriveId(drive) {
|
|
234
|
+
const lower = drive.trim().toLowerCase();
|
|
235
|
+
if (FIRMWARE_DRIVE_IDS.has(lower))
|
|
236
|
+
return lower;
|
|
237
|
+
if (lower === SOFT_IEC_LISTING_ID)
|
|
238
|
+
return "softiec";
|
|
239
|
+
const match = /^(?:drive)?\s*(\d+)$/.exec(lower);
|
|
240
|
+
if (!match)
|
|
241
|
+
return drive;
|
|
242
|
+
const busId = Number(match[1]);
|
|
243
|
+
let listed = [];
|
|
244
|
+
try {
|
|
245
|
+
listed = (await this.driveEntries()).flatMap(([id, info]) => {
|
|
246
|
+
const listedBusId = Number(info?.bus_id);
|
|
247
|
+
return (id === "a" || id === "b") && Number.isInteger(listedBusId) ? [{ id, busId: listedBusId, enabled: info?.enabled !== false }] : [];
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
listed = [];
|
|
252
|
+
}
|
|
253
|
+
// A disabled drive keeps its bus_id setting, so prefer the enabled drive the C64 actually sees on that bus.
|
|
254
|
+
const onBus = listed.filter((entry) => entry.busId === busId);
|
|
255
|
+
const resolved = listed.length > 0 ? (onBus.find((entry) => entry.enabled) ?? onBus[0])?.id : DEFAULT_DRIVE_BY_BUS_ID[busId];
|
|
256
|
+
if (!resolved) {
|
|
257
|
+
const known = listed.length > 0 ? listed.map((entry) => `${entry.id}=${entry.busId}`).join(", ") : "a=8, b=9";
|
|
258
|
+
throw new Error(`No drive found for '${drive}' (bus IDs: ${known})`);
|
|
259
|
+
}
|
|
260
|
+
return resolved;
|
|
261
|
+
}
|
|
262
|
+
async drivesList() {
|
|
263
|
+
const entries = await this.driveEntries();
|
|
264
|
+
return entries.map(([id, info]) => ({
|
|
206
265
|
id,
|
|
207
266
|
power: info.enabled === true || info.enabled === "on" || info.power === "on" ? "on" : "off",
|
|
208
|
-
image: (info
|
|
267
|
+
image: driveImage(info),
|
|
209
268
|
type: info.type ?? null,
|
|
210
269
|
raw: info,
|
|
211
|
-
}))
|
|
270
|
+
}));
|
|
212
271
|
}
|
|
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); }
|
|
272
|
+
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); }
|
|
273
|
+
async driveRemove(d) { const res = await this.api.v1.drivesRemoveUpdate(await this.resolveDriveId(d), ":remove"); return this.actionResult(res.data); }
|
|
274
|
+
async driveReset(d) { const res = await this.api.v1.drivesResetUpdate(await this.resolveDriveId(d), ":reset"); return this.actionResult(res.data); }
|
|
275
|
+
async driveOn(d) { const res = await this.api.v1.drivesOnUpdate(await this.resolveDriveId(d), ":on"); return this.actionResult(res.data); }
|
|
276
|
+
async driveOff(d) { const res = await this.api.v1.drivesOffUpdate(await this.resolveDriveId(d), ":off"); return this.actionResult(res.data); }
|
|
277
|
+
async driveSetMode(d, mode) { const res = await this.api.v1.drivesSetModeUpdate(await this.resolveDriveId(d), ":set_mode", { mode }); return this.actionResult(res.data); }
|
|
278
|
+
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
279
|
async streamStart(s, ip) { this.requireC64u("streamStart"); const res = await this.api.v1.streamsStartUpdate(s, ":start", { ip }); return this.actionResult(res.data); }
|
|
221
280
|
async streamStop(s) { this.requireC64u("streamStop"); const res = await this.api.v1.streamsStopUpdate(s, ":stop"); return this.actionResult(res.data); }
|
|
222
281
|
async configsList() { const res = await this.api.v1.configsList(); return res.data; }
|
|
223
|
-
|
|
224
|
-
|
|
282
|
+
// The generated client interpolates path parameters unencoded and the firmware
|
|
283
|
+
// splits the path on "/" before decoding, so names such as "Bus Sharing - I/O1"
|
|
284
|
+
// must be percent-encoded here.
|
|
285
|
+
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; }
|
|
286
|
+
async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(encodeURIComponent(cat), encodeURIComponent(item), { value }); return this.actionResult(res.data); }
|
|
225
287
|
async configBatchUpdate(payload) { const res = await this.api.v1.configsCreate(payload); return this.actionResult(res.data); }
|
|
226
288
|
async configLoadFromFlash() { const res = await this.api.v1.configsLoadFromFlashUpdate(":load_from_flash"); return this.actionResult(res.data); }
|
|
227
289
|
async configSaveToFlash() { const res = await this.api.v1.configsSaveToFlashUpdate(":save_to_flash"); return this.actionResult(res.data); }
|
|
@@ -632,10 +694,6 @@ export class ViceBackend {
|
|
|
632
694
|
}
|
|
633
695
|
async powerCycle() { return this.nuclearReset(); }
|
|
634
696
|
async nuclearReset() {
|
|
635
|
-
const poweroffResult = await this.poweroff();
|
|
636
|
-
if (!poweroffResult.success) {
|
|
637
|
-
return poweroffResult;
|
|
638
|
-
}
|
|
639
697
|
if (!this.manageProcess) {
|
|
640
698
|
return {
|
|
641
699
|
success: false,
|
|
@@ -645,6 +703,10 @@ export class ViceBackend {
|
|
|
645
703
|
},
|
|
646
704
|
};
|
|
647
705
|
}
|
|
706
|
+
const poweroffResult = await this.poweroff();
|
|
707
|
+
if (!poweroffResult.success) {
|
|
708
|
+
return poweroffResult;
|
|
709
|
+
}
|
|
648
710
|
try {
|
|
649
711
|
await this.ensureProcess();
|
|
650
712
|
return { success: true };
|
|
@@ -694,7 +756,10 @@ export class ViceBackend {
|
|
|
694
756
|
return drives;
|
|
695
757
|
});
|
|
696
758
|
}
|
|
697
|
-
async driveMount(drive, imagePath) {
|
|
759
|
+
async driveMount(drive, imagePath, options) {
|
|
760
|
+
if (options?.type !== undefined || options?.mode !== undefined) {
|
|
761
|
+
throw unsupported("driveMount type/mode overrides; omit these options on VICE");
|
|
762
|
+
}
|
|
698
763
|
const n = parseDriveNumber(drive);
|
|
699
764
|
await this.withClient(async (client) => {
|
|
700
765
|
await client.resourceSet(`Drive${n}CPUEnabled`, 1);
|
|
@@ -760,8 +825,18 @@ export class ViceBackend {
|
|
|
760
825
|
};
|
|
761
826
|
}
|
|
762
827
|
async configGet(_category, item) {
|
|
763
|
-
if (!item)
|
|
764
|
-
|
|
828
|
+
if (!item) {
|
|
829
|
+
if (_category !== "VICE")
|
|
830
|
+
throw new Error(`Unknown VICE configuration category '${_category}'`);
|
|
831
|
+
const inventory = await this.configsList();
|
|
832
|
+
return this.withClient(async (client) => {
|
|
833
|
+
const values = {};
|
|
834
|
+
for (const name of inventory.categories[0].items) {
|
|
835
|
+
values[name] = (await client.resourceGet(name)).value;
|
|
836
|
+
}
|
|
837
|
+
return values;
|
|
838
|
+
});
|
|
839
|
+
}
|
|
765
840
|
return await this.withClient(async (client) => {
|
|
766
841
|
const res = await client.resourceGet(item);
|
|
767
842
|
return { category: _category, item, value: res.value, type: res.type };
|
|
@@ -771,9 +846,7 @@ export class ViceBackend {
|
|
|
771
846
|
let parsed = value;
|
|
772
847
|
await this.withClient(async (client) => {
|
|
773
848
|
const current = await client.resourceGet(item);
|
|
774
|
-
parsed = current.type
|
|
775
|
-
if (current.type === "int" && !Number.isFinite(parsed))
|
|
776
|
-
throw new Error(`VICE resource '${item}' requires an integer value`);
|
|
849
|
+
parsed = parseViceConfigValue(value, current.type, item);
|
|
777
850
|
await client.resourceSet(item, parsed);
|
|
778
851
|
});
|
|
779
852
|
return { success: true, details: { item, value: parsed } };
|
|
@@ -784,11 +857,8 @@ export class ViceBackend {
|
|
|
784
857
|
for (const [category, items] of Object.entries(payload)) {
|
|
785
858
|
for (const [item, value] of Object.entries(items)) {
|
|
786
859
|
try {
|
|
787
|
-
const str = String(value ?? "");
|
|
788
860
|
const current = await client.resourceGet(item);
|
|
789
|
-
const parsed = current.type
|
|
790
|
-
if (current.type === "int" && !Number.isFinite(parsed))
|
|
791
|
-
throw new Error(`VICE resource '${item}' requires an integer value`);
|
|
861
|
+
const parsed = parseViceConfigValue(value, current.type, item);
|
|
792
862
|
await client.resourceSet(item, parsed);
|
|
793
863
|
results.push({ item: `${category}/${item}`, success: true });
|
|
794
864
|
}
|
|
@@ -814,13 +884,28 @@ export class ViceBackend {
|
|
|
814
884
|
}
|
|
815
885
|
ViceBackend.supervisors = new Map();
|
|
816
886
|
ViceBackend.cleanupRegistered = false;
|
|
887
|
+
function parseViceConfigValue(value, type, item) {
|
|
888
|
+
if (type !== "int")
|
|
889
|
+
return String(value ?? "");
|
|
890
|
+
const parsed = value === true || value === "true" ? 1
|
|
891
|
+
: value === false || value === "false" ? 0 : Number(value);
|
|
892
|
+
if (!Number.isInteger(parsed) || parsed < -0x80000000 || parsed > 0x7fffffff) {
|
|
893
|
+
throw new Error(`VICE resource '${item}' requires a 32-bit integer value`);
|
|
894
|
+
}
|
|
895
|
+
return parsed;
|
|
896
|
+
}
|
|
817
897
|
function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend in phase one`); err.code = "UNSUPPORTED"; return err; }
|
|
818
898
|
/** Ultimate file APIs treat slash as a path separator. Encode each name once,
|
|
819
899
|
* preserving leading and nested separators for firmware routing. */
|
|
820
900
|
function encodeDevicePath(value) {
|
|
821
901
|
return value.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
822
902
|
}
|
|
903
|
+
/** C64U drive letters with their factory bus numbers, so one drive id works on both backends. */
|
|
904
|
+
const VICE_DRIVE_LETTERS = { a: 8, b: 9 };
|
|
823
905
|
function parseDriveNumber(drive) {
|
|
906
|
+
const key = drive.trim().toLowerCase();
|
|
907
|
+
if (Object.prototype.hasOwnProperty.call(VICE_DRIVE_LETTERS, key))
|
|
908
|
+
return VICE_DRIVE_LETTERS[key];
|
|
824
909
|
const match = /\d+/.exec(drive);
|
|
825
910
|
const n = match ? parseInt(match[0], 10) : NaN;
|
|
826
911
|
if (isNaN(n) || n < 8 || n > 11)
|
|
@@ -953,29 +1038,29 @@ export async function createFacade(logger, options) {
|
|
|
953
1038
|
// Caller-forced preference: use c64u with provided base URL (used by tests and server wiring)
|
|
954
1039
|
if (options?.preferredC64uBaseUrl) {
|
|
955
1040
|
const type = envMode === "u2" ? "u2" : "c64u";
|
|
956
|
-
const backend = new C64uBackend({
|
|
1041
|
+
const backend = new C64uBackend(resolveUltimateEndpoint(type, {
|
|
957
1042
|
baseUrl: options.preferredC64uBaseUrl,
|
|
958
1043
|
networkPassword: options.preferredC64uNetworkPassword,
|
|
959
|
-
}, type);
|
|
1044
|
+
}), type);
|
|
960
1045
|
logger?.info?.(`Active backend: ${type} (forced by caller)`);
|
|
961
1046
|
return { facade: backend, selected: type, reason: "forced by caller", details: { baseUrl: options.preferredC64uBaseUrl } };
|
|
962
1047
|
}
|
|
963
1048
|
const cfg = readConfigFile();
|
|
964
|
-
const hasC64u = Boolean(cfg
|
|
965
|
-
const hasU2 = Boolean(cfg
|
|
966
|
-
const hasVice = Boolean(cfg
|
|
1049
|
+
const hasC64u = Boolean(cfg.c64u);
|
|
1050
|
+
const hasU2 = Boolean(cfg.u2);
|
|
1051
|
+
const hasVice = Boolean(cfg.vice);
|
|
967
1052
|
if (envMode === "c64u") {
|
|
968
|
-
const backend = new C64uBackend(cfg
|
|
1053
|
+
const backend = new C64uBackend(cfg.endpoints.c64u);
|
|
969
1054
|
logger?.info?.("Active backend: c64u (from env override)");
|
|
970
1055
|
return { facade: backend, selected: "c64u", reason: "env override", details: { baseUrl: backend.getBaseUrl?.() } };
|
|
971
1056
|
}
|
|
972
1057
|
if (envMode === "u2") {
|
|
973
|
-
const backend = new C64uBackend(cfg
|
|
1058
|
+
const backend = new C64uBackend(cfg.endpoints.u2, "u2");
|
|
974
1059
|
logger?.info?.("Active backend: u2 (from env override)");
|
|
975
1060
|
return { facade: backend, selected: "u2", reason: "env override", details: { baseUrl: backend.getBaseUrl?.() } };
|
|
976
1061
|
}
|
|
977
1062
|
if (envMode === "vice") {
|
|
978
|
-
const backend = new ViceBackend(cfg
|
|
1063
|
+
const backend = new ViceBackend(cfg.vice ?? {});
|
|
979
1064
|
logger?.info?.("Active backend: vice (from env override)");
|
|
980
1065
|
const endpoint = backend.getEndpoint();
|
|
981
1066
|
return { facade: backend, selected: "vice", reason: "env override", details: { host: endpoint.host, port: endpoint.port } };
|
|
@@ -997,17 +1082,17 @@ export async function createFacade(logger, options) {
|
|
|
997
1082
|
return { facade: backend, selected: "vice", reason: "config only", details: { host: endpoint.host, port: endpoint.port } };
|
|
998
1083
|
}
|
|
999
1084
|
// No configuration
|
|
1000
|
-
const probeBase =
|
|
1085
|
+
const probeBase = cfg.endpoints.c64u.baseUrl;
|
|
1001
1086
|
try {
|
|
1002
1087
|
const res = await axios.get(probeBase, { timeout: 1500 });
|
|
1003
1088
|
if (res.status >= 200 && res.status < 500) {
|
|
1004
|
-
const backend = new C64uBackend(
|
|
1089
|
+
const backend = new C64uBackend(cfg.endpoints.c64u);
|
|
1005
1090
|
logger?.info?.("Active backend: c64u (fallback – hardware reachable)");
|
|
1006
1091
|
return { facade: backend, selected: "c64u", reason: "fallback (reachable)", details: { baseUrl: probeBase } };
|
|
1007
1092
|
}
|
|
1008
1093
|
}
|
|
1009
1094
|
catch { }
|
|
1010
|
-
const backend = new ViceBackend(cfg
|
|
1095
|
+
const backend = new ViceBackend(cfg.vice ?? {});
|
|
1011
1096
|
logger?.info?.("Active backend: vice (fallback – hardware unavailable)");
|
|
1012
1097
|
const endpoint = backend.getEndpoint();
|
|
1013
1098
|
return { facade: backend, selected: "vice", reason: "fallback (hardware unavailable)", details: { host: endpoint.host, port: endpoint.port } };
|
|
@@ -1018,49 +1103,31 @@ export async function createAllFacades(logger, options) {
|
|
|
1018
1103
|
const primary = await createFacade(logger, process.env.C64_MODE?.toLowerCase().trim() === "u2" ? options : undefined);
|
|
1019
1104
|
const config = readConfigFile();
|
|
1020
1105
|
const facades = new Map([[primary.selected, primary.facade]]);
|
|
1021
|
-
if (config
|
|
1106
|
+
if (config.c64u && primary.selected !== "c64u") {
|
|
1022
1107
|
facades.set("c64u", new C64uBackend(config.c64u));
|
|
1023
1108
|
}
|
|
1024
|
-
if (config
|
|
1109
|
+
if (config.u2 && primary.selected !== "u2") {
|
|
1025
1110
|
facades.set("u2", new C64uBackend(config.u2, "u2"));
|
|
1026
1111
|
}
|
|
1027
|
-
if (config
|
|
1112
|
+
if (config.vice && primary.selected !== "vice") {
|
|
1028
1113
|
facades.set("vice", new ViceBackend(config.vice));
|
|
1029
1114
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
: {}),
|
|
1037
|
-
}));
|
|
1115
|
+
// Reached only without a configured c64u profile, so the caller's endpoint applies.
|
|
1116
|
+
if (!facades.has("c64u") && !config.u2 && shouldProvisionSecondaryC64u(config, options) && primary.selected !== "u2") {
|
|
1117
|
+
facades.set("c64u", new C64uBackend(resolveUltimateEndpoint("c64u", {
|
|
1118
|
+
baseUrl: options?.preferredC64uBaseUrl,
|
|
1119
|
+
networkPassword: options?.preferredC64uNetworkPassword,
|
|
1120
|
+
})));
|
|
1038
1121
|
}
|
|
1039
1122
|
return { primary, facades };
|
|
1040
1123
|
}
|
|
1041
1124
|
function shouldProvisionSecondaryC64u(config, options) {
|
|
1042
|
-
return Boolean(config
|
|
1125
|
+
return Boolean(config.c64u
|
|
1043
1126
|
|| options?.preferredC64uBaseUrl
|
|
1044
1127
|
|| configuredString(process.env.C64U_HOST)
|
|
1045
1128
|
|| configuredPort(process.env.C64U_PORT)
|
|
1046
1129
|
|| configuredString(process.env.C64U_PASSWORD));
|
|
1047
1130
|
}
|
|
1048
|
-
function resolveBaseUrl(config) {
|
|
1049
|
-
const explicit = normaliseBaseUrl(config.baseUrl);
|
|
1050
|
-
if (explicit)
|
|
1051
|
-
return explicit;
|
|
1052
|
-
const hostEntries = [configuredString(config.host), configuredString(config.hostname)];
|
|
1053
|
-
for (const entry of hostEntries) {
|
|
1054
|
-
if (!entry)
|
|
1055
|
-
continue;
|
|
1056
|
-
const parsed = parseEndpoint(entry);
|
|
1057
|
-
if (parsed.hostname) {
|
|
1058
|
-
const port = firstDefined(configuredPort(config.port), parsed.port) ?? DEFAULT_C64U_PORT;
|
|
1059
|
-
return buildBaseUrl(parsed.hostname, port);
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
return buildBaseUrl(DEFAULT_C64U_HOST, DEFAULT_C64U_PORT);
|
|
1063
|
-
}
|
|
1064
1131
|
function configuredString(value) {
|
|
1065
1132
|
if (typeof value === "string") {
|
|
1066
1133
|
const trimmed = value.trim();
|
|
@@ -1170,42 +1237,9 @@ function configuredPort(value) {
|
|
|
1170
1237
|
}
|
|
1171
1238
|
return undefined;
|
|
1172
1239
|
}
|
|
1173
|
-
function normaliseBaseUrl(value) {
|
|
1174
|
-
const input = configuredString(value);
|
|
1175
|
-
if (!input)
|
|
1176
|
-
return undefined;
|
|
1177
|
-
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) {
|
|
1178
|
-
return `http://${input}`;
|
|
1179
|
-
}
|
|
1180
|
-
return stripTrailingSlash(input);
|
|
1181
|
-
}
|
|
1182
|
-
function parseEndpoint(value) {
|
|
1183
|
-
try {
|
|
1184
|
-
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
|
|
1185
|
-
const url = new URL(hasScheme ? value : `http://${value}`);
|
|
1186
|
-
const hostname = url.hostname || undefined;
|
|
1187
|
-
const port = url.port ? configuredPort(url.port) : undefined;
|
|
1188
|
-
return { hostname, port };
|
|
1189
|
-
}
|
|
1190
|
-
catch {
|
|
1191
|
-
return {};
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
function buildBaseUrl(host, port) {
|
|
1195
|
-
const normalizedPort = Number.isInteger(port) && port > 0 ? port : DEFAULT_C64U_PORT;
|
|
1196
|
-
const hostPart = formatHost(host);
|
|
1197
|
-
const suffix = normalizedPort === DEFAULT_C64U_PORT ? "" : `:${normalizedPort}`;
|
|
1198
|
-
return `http://${hostPart}${suffix}`;
|
|
1199
|
-
}
|
|
1200
1240
|
function buildC64uHeaders(networkPassword) {
|
|
1201
1241
|
return networkPassword ? { "X-Password": networkPassword } : undefined;
|
|
1202
1242
|
}
|
|
1203
|
-
function formatHost(host) {
|
|
1204
|
-
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
1205
|
-
}
|
|
1206
|
-
function stripTrailingSlash(input) {
|
|
1207
|
-
return input.replace(/\/+$/, "");
|
|
1208
|
-
}
|
|
1209
1243
|
function parseArgsList(input) {
|
|
1210
1244
|
const args = [];
|
|
1211
1245
|
const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
|
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/petscii.js
CHANGED
|
@@ -61,8 +61,6 @@ function ensureScreenCodeMap() {
|
|
|
61
61
|
}
|
|
62
62
|
SCREEN_CODE_TO_ASCII.set(code, deriveGlyphChar(glyph));
|
|
63
63
|
}
|
|
64
|
-
// Common control characters / gaps default to space to keep output tidy.
|
|
65
|
-
SCREEN_CODE_TO_ASCII.set(0x00, " ");
|
|
66
64
|
screenCodeMapInitialised = true;
|
|
67
65
|
}
|
|
68
66
|
export function screenCodesToAscii(bytes, options) {
|
|
@@ -132,7 +130,7 @@ const NAMED_CHARS = Object.freeze({
|
|
|
132
130
|
"cursor_left": 157,
|
|
133
131
|
// Graphic glyphs (commonly referenced by name)
|
|
134
132
|
// Note: Exact byte shown on screen depends on mode; these are PETSCII/CHR$ values
|
|
135
|
-
"heart":
|
|
133
|
+
"heart": 115, // $73 (shifted S); $51 would print the letter Q
|
|
136
134
|
"up_arrow": 94,
|
|
137
135
|
"uparrow": 94,
|
|
138
136
|
"left_arrow": 95,
|
|
@@ -146,7 +144,8 @@ export function listNamedPetscii() {
|
|
|
146
144
|
}
|
|
147
145
|
export function lookupNamedPetscii(name) {
|
|
148
146
|
const key = name.trim().toLowerCase().replace(/\s+/g, "_");
|
|
149
|
-
|
|
147
|
+
// Own keys only, so names like "constructor" don't resolve to Object.prototype members.
|
|
148
|
+
return Object.prototype.hasOwnProperty.call(NAMED_CHARS, key) ? NAMED_CHARS[key] : undefined;
|
|
150
149
|
}
|
|
151
150
|
/**
|
|
152
151
|
* Try to parse a named character token starting at the given index.
|
|
@@ -160,15 +159,16 @@ export function parseNamedCharToken(source, startIndex) {
|
|
|
160
159
|
return null;
|
|
161
160
|
}
|
|
162
161
|
const close = open === "{" ? "}" : open === ":" ? ":" : ">";
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
162
|
+
const idx = source.indexOf(close, startIndex + 1);
|
|
163
|
+
if (idx < 0) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
// Only sane name characters; anything else is ordinary text that happens to use the delimiters.
|
|
167
|
+
// Multi-word names such as {cursor up} need braces: ":" and "<" also appear in
|
|
168
|
+
// ordinary text ("Mode: reverse on: yes"), so they only delimit single words.
|
|
169
|
+
const name = source.slice(startIndex + 1, idx).trim();
|
|
170
|
+
const namePattern = open === "{" ? /^[A-Za-z0-9_\s-]+$/ : /^[A-Za-z0-9_-]+$/;
|
|
171
|
+
if (!namePattern.test(name)) {
|
|
172
172
|
return null;
|
|
173
173
|
}
|
|
174
174
|
const value = lookupNamedPetscii(name.replace(/[-\s]+/g, "_"));
|
|
@@ -181,7 +181,7 @@ export function parseNamedCharToken(source, startIndex) {
|
|
|
181
181
|
* Encode a text containing optional named tokens into PETSCII bytes.
|
|
182
182
|
* Named tokens may appear as {heart}, :heart:, or <heart>.
|
|
183
183
|
*/
|
|
184
|
-
export function encodeStringWithNames(input) {
|
|
184
|
+
export function encodeStringWithNames(input, options = {}) {
|
|
185
185
|
const bytes = [];
|
|
186
186
|
let i = 0;
|
|
187
187
|
while (i < input.length) {
|
|
@@ -191,7 +191,10 @@ export function encodeStringWithNames(input) {
|
|
|
191
191
|
i = maybe.endIndex;
|
|
192
192
|
continue;
|
|
193
193
|
}
|
|
194
|
-
|
|
194
|
+
// ASCII a-z are PETSCII graphics characters in the default character set,
|
|
195
|
+
// so callers can fold them to the letters $41-$5A. Named codes are not folded.
|
|
196
|
+
const char = input[i];
|
|
197
|
+
bytes.push(asciiToPetscii(options.foldLowercase && char >= "a" && char <= "z" ? char.toUpperCase() : char));
|
|
195
198
|
i += 1;
|
|
196
199
|
}
|
|
197
200
|
return Uint8Array.from(bytes);
|
package/dist/petsciiArt.js
CHANGED
|
@@ -19,6 +19,11 @@ const POPCOUNT = (() => {
|
|
|
19
19
|
}
|
|
20
20
|
return table;
|
|
21
21
|
})();
|
|
22
|
+
const RVS_ON = 18;
|
|
23
|
+
const RVS_OFF = 146;
|
|
24
|
+
// Marks the end of a character row in the generated DATA statements.
|
|
25
|
+
const ROW_END = -1;
|
|
26
|
+
const SCREEN_COLUMNS = 40;
|
|
22
27
|
const DEFAULT_MAX_WIDTH = 320;
|
|
23
28
|
const DEFAULT_MAX_HEIGHT = 200;
|
|
24
29
|
const SHAPES = [
|
|
@@ -252,10 +257,14 @@ function distanceBetween(block, glyph) {
|
|
|
252
257
|
}
|
|
253
258
|
return score;
|
|
254
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Match each 8x8 block to a glyph. `glyphs` has one entry per cell; `codes` is the
|
|
262
|
+
* PETSCII stream that prints them, with each run of reverse-video glyphs (screen
|
|
263
|
+
* codes $80-$FF) wrapped in RVS ON/RVS OFF. `rowCodes` holds that stream per row.
|
|
264
|
+
*/
|
|
255
265
|
export function bitmapToPetsciiCodes(bitmap) {
|
|
256
266
|
const { blocks, columns, rows } = splitIntoBlocks(bitmap);
|
|
257
267
|
const glyphs = getChargenGlyphs();
|
|
258
|
-
const codes = [];
|
|
259
268
|
const matchedGlyphs = [];
|
|
260
269
|
for (const block of blocks) {
|
|
261
270
|
let bestMatch;
|
|
@@ -270,11 +279,26 @@ export function bitmapToPetsciiCodes(bitmap) {
|
|
|
270
279
|
}
|
|
271
280
|
}
|
|
272
281
|
}
|
|
273
|
-
|
|
274
|
-
codes.push(selected.petsciiCode);
|
|
275
|
-
matchedGlyphs.push(selected);
|
|
282
|
+
matchedGlyphs.push(bestMatch ?? glyphs[0]);
|
|
276
283
|
}
|
|
277
|
-
|
|
284
|
+
const rowCodes = [];
|
|
285
|
+
for (let row = 0; row < rows; row += 1) {
|
|
286
|
+
const stream = [];
|
|
287
|
+
let reverse = false;
|
|
288
|
+
for (const glyph of matchedGlyphs.slice(row * columns, (row + 1) * columns)) {
|
|
289
|
+
const glyphReverse = (glyph.screenCode & 0x80) !== 0;
|
|
290
|
+
if (glyphReverse !== reverse) {
|
|
291
|
+
stream.push(glyphReverse ? RVS_ON : RVS_OFF);
|
|
292
|
+
reverse = glyphReverse;
|
|
293
|
+
}
|
|
294
|
+
stream.push(glyph.petsciiCode);
|
|
295
|
+
}
|
|
296
|
+
if (reverse) {
|
|
297
|
+
stream.push(RVS_OFF);
|
|
298
|
+
}
|
|
299
|
+
rowCodes.push(stream);
|
|
300
|
+
}
|
|
301
|
+
return { codes: rowCodes.flat(), rowCodes, glyphs: matchedGlyphs, columns, rows };
|
|
278
302
|
}
|
|
279
303
|
function buildBasicProgram(params) {
|
|
280
304
|
const lines = [];
|
|
@@ -288,19 +312,21 @@ function buildBasicProgram(params) {
|
|
|
288
312
|
lines.push(`${lineNumber} POKE 646,${fg}`);
|
|
289
313
|
lineNumber += 10;
|
|
290
314
|
}
|
|
315
|
+
// Rows vary in length because of the RVS ON/OFF codes, so each row's DATA ends with ROW_END.
|
|
291
316
|
lines.push(`${lineNumber} FORY=0TO${Math.max(0, params.rows - 1)}`);
|
|
292
317
|
lineNumber += 10;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
lines.push(`${lineNumber} NEXTX:PRINT`);
|
|
318
|
+
const readLine = lineNumber;
|
|
319
|
+
// POKE212,0 clears quote mode: after a printed '"' glyph the editor would
|
|
320
|
+
// otherwise draw the following RVS ON/OFF codes as characters.
|
|
321
|
+
lines.push(`${readLine} READC:IFC>=0THENPRINT CHR$(C);:POKE212,0:GOTO${readLine}`);
|
|
298
322
|
lineNumber += 10;
|
|
299
|
-
|
|
323
|
+
// A full 40-column row already wraps the cursor; an extra PRINT would leave a blank line.
|
|
324
|
+
lines.push(params.columns >= SCREEN_COLUMNS ? `${lineNumber} NEXTY` : `${lineNumber} PRINT:NEXTY`);
|
|
300
325
|
lineNumber += 10;
|
|
326
|
+
const data = params.rowCodes.flatMap((row) => [...row, ROW_END]);
|
|
301
327
|
const chunkSize = 16;
|
|
302
|
-
for (let i = 0; i <
|
|
303
|
-
const slice =
|
|
328
|
+
for (let i = 0; i < data.length; i += chunkSize) {
|
|
329
|
+
const slice = data.slice(i, i + chunkSize);
|
|
304
330
|
const dataLine = slice.join(",");
|
|
305
331
|
lines.push(`${lineNumber} DATA ${dataLine}`);
|
|
306
332
|
lineNumber += 10;
|
|
@@ -334,7 +360,7 @@ export function createPetsciiArt(options) {
|
|
|
334
360
|
const converted = bitmapToPetsciiCodes(bitmap);
|
|
335
361
|
const codes = converted.codes;
|
|
336
362
|
const program = buildBasicProgram({
|
|
337
|
-
|
|
363
|
+
rowCodes: converted.rowCodes,
|
|
338
364
|
columns: converted.columns,
|
|
339
365
|
rows: converted.rows,
|
|
340
366
|
borderColor: options.borderColor,
|
package/dist/rag/indexer.js
CHANGED
|
@@ -12,7 +12,8 @@ const BASIC_DIR = path.join(ASSET_ROOT, "data/basic/examples");
|
|
|
12
12
|
const ASM_DIR = path.join(ASSET_ROOT, "data/assembly/examples");
|
|
13
13
|
const EXTERNAL_DIR = path.join(ASSET_ROOT, "external");
|
|
14
14
|
const DOC_ROOT = path.join(ASSET_ROOT, "doc");
|
|
15
|
-
|
|
15
|
+
// Plans and reviews are internal engineering notes, not C64 reference material.
|
|
16
|
+
const DOC_EXCLUDE_DIRS = new Set(["plans", "reviews"]);
|
|
16
17
|
const CONTEXT_DIR = path.join(ASSET_ROOT, "data/context");
|
|
17
18
|
const BOOTSTRAP_PATH = path.join(CONTEXT_DIR, "bootstrap.md");
|
|
18
19
|
const AGENTS_PATH = path.join(ASSET_ROOT, "AGENTS.md");
|
package/dist/sidwave.js
CHANGED
|
@@ -16,7 +16,7 @@ export function parseSidwave(input) {
|
|
|
16
16
|
const legacy = !!obj.song;
|
|
17
17
|
const header = legacy
|
|
18
18
|
? { title: obj.song?.title, tempo: obj.song?.tempo, mode: obj.song?.mode, length_bars: obj.song?.length_bars, global_fx: obj.song?.global_fx }
|
|
19
|
-
: { title: obj.title, author: obj.author, tempo: obj.tempo, mode: obj.mode, length_bars: obj, global_fx: obj.global_fx };
|
|
19
|
+
: { title: obj.title, author: obj.author, tempo: obj.tempo, mode: obj.mode, length_bars: obj.length_bars, global_fx: obj.global_fx };
|
|
20
20
|
const title = header.title ?? "Untitled";
|
|
21
21
|
const tempo = normalizeTempo(header.tempo);
|
|
22
22
|
const mode = header.mode ?? "PAL";
|