c64bridge 1.0.4 → 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/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 +65 -61
- package/dist/config.js +75 -38
- package/dist/device.js +122 -106
- 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 +27 -6
- 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/background.js +6 -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 +19 -4
- package/dist/tools/pollValidator.js +30 -2
- package/dist/tools/programRunners.js +5 -4
- 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_system.schema.json +6 -4
- package/mcp/server.json +1 -1
- package/mcp/tools.json +6 -4
- 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); }
|
|
@@ -838,7 +900,12 @@ function unsupported(name) { const err = new Error(`Operation '${name}' is not s
|
|
|
838
900
|
function encodeDevicePath(value) {
|
|
839
901
|
return value.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
840
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 };
|
|
841
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];
|
|
842
909
|
const match = /\d+/.exec(drive);
|
|
843
910
|
const n = match ? parseInt(match[0], 10) : NaN;
|
|
844
911
|
if (isNaN(n) || n < 8 || n > 11)
|
|
@@ -971,29 +1038,29 @@ export async function createFacade(logger, options) {
|
|
|
971
1038
|
// Caller-forced preference: use c64u with provided base URL (used by tests and server wiring)
|
|
972
1039
|
if (options?.preferredC64uBaseUrl) {
|
|
973
1040
|
const type = envMode === "u2" ? "u2" : "c64u";
|
|
974
|
-
const backend = new C64uBackend({
|
|
1041
|
+
const backend = new C64uBackend(resolveUltimateEndpoint(type, {
|
|
975
1042
|
baseUrl: options.preferredC64uBaseUrl,
|
|
976
1043
|
networkPassword: options.preferredC64uNetworkPassword,
|
|
977
|
-
}, type);
|
|
1044
|
+
}), type);
|
|
978
1045
|
logger?.info?.(`Active backend: ${type} (forced by caller)`);
|
|
979
1046
|
return { facade: backend, selected: type, reason: "forced by caller", details: { baseUrl: options.preferredC64uBaseUrl } };
|
|
980
1047
|
}
|
|
981
1048
|
const cfg = readConfigFile();
|
|
982
|
-
const hasC64u = Boolean(cfg
|
|
983
|
-
const hasU2 = Boolean(cfg
|
|
984
|
-
const hasVice = Boolean(cfg
|
|
1049
|
+
const hasC64u = Boolean(cfg.c64u);
|
|
1050
|
+
const hasU2 = Boolean(cfg.u2);
|
|
1051
|
+
const hasVice = Boolean(cfg.vice);
|
|
985
1052
|
if (envMode === "c64u") {
|
|
986
|
-
const backend = new C64uBackend(cfg
|
|
1053
|
+
const backend = new C64uBackend(cfg.endpoints.c64u);
|
|
987
1054
|
logger?.info?.("Active backend: c64u (from env override)");
|
|
988
1055
|
return { facade: backend, selected: "c64u", reason: "env override", details: { baseUrl: backend.getBaseUrl?.() } };
|
|
989
1056
|
}
|
|
990
1057
|
if (envMode === "u2") {
|
|
991
|
-
const backend = new C64uBackend(cfg
|
|
1058
|
+
const backend = new C64uBackend(cfg.endpoints.u2, "u2");
|
|
992
1059
|
logger?.info?.("Active backend: u2 (from env override)");
|
|
993
1060
|
return { facade: backend, selected: "u2", reason: "env override", details: { baseUrl: backend.getBaseUrl?.() } };
|
|
994
1061
|
}
|
|
995
1062
|
if (envMode === "vice") {
|
|
996
|
-
const backend = new ViceBackend(cfg
|
|
1063
|
+
const backend = new ViceBackend(cfg.vice ?? {});
|
|
997
1064
|
logger?.info?.("Active backend: vice (from env override)");
|
|
998
1065
|
const endpoint = backend.getEndpoint();
|
|
999
1066
|
return { facade: backend, selected: "vice", reason: "env override", details: { host: endpoint.host, port: endpoint.port } };
|
|
@@ -1015,17 +1082,17 @@ export async function createFacade(logger, options) {
|
|
|
1015
1082
|
return { facade: backend, selected: "vice", reason: "config only", details: { host: endpoint.host, port: endpoint.port } };
|
|
1016
1083
|
}
|
|
1017
1084
|
// No configuration
|
|
1018
|
-
const probeBase =
|
|
1085
|
+
const probeBase = cfg.endpoints.c64u.baseUrl;
|
|
1019
1086
|
try {
|
|
1020
1087
|
const res = await axios.get(probeBase, { timeout: 1500 });
|
|
1021
1088
|
if (res.status >= 200 && res.status < 500) {
|
|
1022
|
-
const backend = new C64uBackend(
|
|
1089
|
+
const backend = new C64uBackend(cfg.endpoints.c64u);
|
|
1023
1090
|
logger?.info?.("Active backend: c64u (fallback – hardware reachable)");
|
|
1024
1091
|
return { facade: backend, selected: "c64u", reason: "fallback (reachable)", details: { baseUrl: probeBase } };
|
|
1025
1092
|
}
|
|
1026
1093
|
}
|
|
1027
1094
|
catch { }
|
|
1028
|
-
const backend = new ViceBackend(cfg
|
|
1095
|
+
const backend = new ViceBackend(cfg.vice ?? {});
|
|
1029
1096
|
logger?.info?.("Active backend: vice (fallback – hardware unavailable)");
|
|
1030
1097
|
const endpoint = backend.getEndpoint();
|
|
1031
1098
|
return { facade: backend, selected: "vice", reason: "fallback (hardware unavailable)", details: { host: endpoint.host, port: endpoint.port } };
|
|
@@ -1036,49 +1103,31 @@ export async function createAllFacades(logger, options) {
|
|
|
1036
1103
|
const primary = await createFacade(logger, process.env.C64_MODE?.toLowerCase().trim() === "u2" ? options : undefined);
|
|
1037
1104
|
const config = readConfigFile();
|
|
1038
1105
|
const facades = new Map([[primary.selected, primary.facade]]);
|
|
1039
|
-
if (config
|
|
1106
|
+
if (config.c64u && primary.selected !== "c64u") {
|
|
1040
1107
|
facades.set("c64u", new C64uBackend(config.c64u));
|
|
1041
1108
|
}
|
|
1042
|
-
if (config
|
|
1109
|
+
if (config.u2 && primary.selected !== "u2") {
|
|
1043
1110
|
facades.set("u2", new C64uBackend(config.u2, "u2"));
|
|
1044
1111
|
}
|
|
1045
|
-
if (config
|
|
1112
|
+
if (config.vice && primary.selected !== "vice") {
|
|
1046
1113
|
facades.set("vice", new ViceBackend(config.vice));
|
|
1047
1114
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
: {}),
|
|
1055
|
-
}));
|
|
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
|
+
})));
|
|
1056
1121
|
}
|
|
1057
1122
|
return { primary, facades };
|
|
1058
1123
|
}
|
|
1059
1124
|
function shouldProvisionSecondaryC64u(config, options) {
|
|
1060
|
-
return Boolean(config
|
|
1125
|
+
return Boolean(config.c64u
|
|
1061
1126
|
|| options?.preferredC64uBaseUrl
|
|
1062
1127
|
|| configuredString(process.env.C64U_HOST)
|
|
1063
1128
|
|| configuredPort(process.env.C64U_PORT)
|
|
1064
1129
|
|| configuredString(process.env.C64U_PASSWORD));
|
|
1065
1130
|
}
|
|
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
1131
|
function configuredString(value) {
|
|
1083
1132
|
if (typeof value === "string") {
|
|
1084
1133
|
const trimmed = value.trim();
|
|
@@ -1188,42 +1237,9 @@ function configuredPort(value) {
|
|
|
1188
1237
|
}
|
|
1189
1238
|
return undefined;
|
|
1190
1239
|
}
|
|
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
1240
|
function buildC64uHeaders(networkPassword) {
|
|
1219
1241
|
return networkPassword ? { "X-Password": networkPassword } : undefined;
|
|
1220
1242
|
}
|
|
1221
|
-
function formatHost(host) {
|
|
1222
|
-
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
1223
|
-
}
|
|
1224
|
-
function stripTrailingSlash(input) {
|
|
1225
|
-
return input.replace(/\/+$/, "");
|
|
1226
|
-
}
|
|
1227
1243
|
function parseArgsList(input) {
|
|
1228
1244
|
const args = [];
|
|
1229
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";
|
package/dist/sidwaveCompiler.js
CHANGED
|
@@ -71,19 +71,15 @@ export function compileSidwaveToPrg(doc) {
|
|
|
71
71
|
}
|
|
72
72
|
if (pat.type === "arpeggio" && Array.isArray(pat.notes) && pat.notes.length) {
|
|
73
73
|
const stepFrames = Math.max(1, Math.round((pat.frame_rate ? fps / pat.frame_rate : framesPerBeat)));
|
|
74
|
-
let idx = 0;
|
|
75
74
|
for (let f = 0; f < totalFramesInSection; f += 1) {
|
|
76
|
-
|
|
77
|
-
idx = (idx + 1) % pat.notes.length;
|
|
75
|
+
const idx = Math.floor(f / stepFrames) % pat.notes.length;
|
|
78
76
|
notesForSong.push(String(pat.notes[idx]));
|
|
79
77
|
}
|
|
80
78
|
}
|
|
81
79
|
else if (Array.isArray(pat.groove) && pat.groove.length) {
|
|
82
80
|
const stepFrames = framesPerBeat; // one note per beat
|
|
83
|
-
let gi = 0;
|
|
84
81
|
for (let f = 0; f < totalFramesInSection; f += 1) {
|
|
85
|
-
|
|
86
|
-
gi = (gi + 1) % pat.groove.length;
|
|
82
|
+
const gi = Math.floor(f / stepFrames) % pat.groove.length;
|
|
87
83
|
notesForSong.push(String(pat.groove[gi]));
|
|
88
84
|
}
|
|
89
85
|
}
|
|
@@ -95,10 +91,8 @@ export function compileSidwaveToPrg(doc) {
|
|
|
95
91
|
const lengthBeats = Math.max(1, Math.floor(pat.length ?? 1));
|
|
96
92
|
const stepFrames = framesPerBeat * lengthBeats;
|
|
97
93
|
const baseNote = "C3";
|
|
98
|
-
let mi = 0;
|
|
99
94
|
for (let f = 0; f < totalFramesInSection; f += 1) {
|
|
100
|
-
|
|
101
|
-
mi = (mi + 1) % Math.max(1, intervals.length);
|
|
95
|
+
const mi = Math.floor(f / stepFrames) % Math.max(1, intervals.length);
|
|
102
96
|
const iv = intervals.length ? intervals[mi] : 0;
|
|
103
97
|
notesForSong.push(transposeNote(baseNote, iv));
|
|
104
98
|
}
|