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.
Files changed (41) hide show
  1. package/data/embeddings_basic.json +1 -1
  2. package/data/embeddings_hardware.json +1 -1
  3. package/data/embeddings_mixed.json +1 -1
  4. package/data/embeddings_other.json +1 -1
  5. package/data/graphics/character-set.csv +64 -64
  6. package/dist/.tsbuildinfo +1 -1
  7. package/dist/c64Client.js +65 -61
  8. package/dist/config.js +75 -38
  9. package/dist/device.js +122 -106
  10. package/dist/knowledge.js +2 -1
  11. package/dist/petscii.js +18 -15
  12. package/dist/petsciiArt.js +40 -14
  13. package/dist/rag/indexer.js +2 -1
  14. package/dist/sidwave.js +1 -1
  15. package/dist/sidwaveCompiler.js +3 -9
  16. package/dist/tools/audio.js +27 -6
  17. package/dist/tools/debug.js +9 -9
  18. package/dist/tools/errors.js +12 -1
  19. package/dist/tools/hex.js +9 -0
  20. package/dist/tools/input.js +13 -7
  21. package/dist/tools/memory.js +5 -4
  22. package/dist/tools/meta/background.js +6 -1
  23. package/dist/tools/meta/filesystem.js +37 -10
  24. package/dist/tools/meta/graphics.js +10 -15
  25. package/dist/tools/meta/memory.js +12 -18
  26. package/dist/tools/meta/program.js +19 -4
  27. package/dist/tools/pollValidator.js +30 -2
  28. package/dist/tools/programRunners.js +5 -4
  29. package/dist/tools/translation/assembler.js +150 -47
  30. package/dist/tools/translation/basicTokenizer.js +39 -9
  31. package/dist/tools/types.js +11 -4
  32. package/dist/vicBitmap.js +16 -0
  33. package/dist/vice/mockServer.js +7 -3
  34. package/dist/vice/viceClient.js +16 -6
  35. package/mcp/schemas/c64_system.schema.json +6 -4
  36. package/mcp/server.json +1 -1
  37. package/mcp/tools.json +6 -4
  38. package/mcp.json +1 -1
  39. package/package.json +1 -1
  40. package/scripts/mockC64Server.mjs +98 -19
  41. 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 sections = loadConfig().backendConfig;
57
- return Object.keys(sections).length === 0 ? null : {
58
- c64u: sections.c64u,
59
- u2: sections.u2,
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(config, type = "c64u") {
85
+ constructor(endpoint, type = "c64u") {
65
86
  this.type = type;
66
- const envHost = configuredString(type === "u2" ? process.env.U2_HOST ?? process.env.C64U_HOST : process.env.C64U_HOST);
67
- const envPort = configuredPort(type === "u2" ? process.env.U2_PORT ?? process.env.C64U_PORT : process.env.C64U_PORT);
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
- const form = { sid: Buffer.isBuffer(sid) ? sid : Buffer.from(sid) };
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
- form.songlengths = Buffer.isBuffer(options.songlengths) ? options.songlengths : Buffer.from(options.songlengths);
138
- const res = await this.api.v1.runnersSidplayCreate(":sidplay", form, options?.songnr !== undefined ? { songnr: options.songnr } : undefined);
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 drivesList() {
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).map(([id, info]) => ({
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.image_path ?? info.image_file ?? info.image ?? null),
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
- async configGet(cat, item) { const res = item ? await this.api.v1.configsDetail2(cat, item) : await this.api.v1.configsDetail(cat); return res.data; }
224
- async configSet(cat, item, value) { const res = await this.api.v1.configsUpdate(cat, item, { value }); return this.actionResult(res.data); }
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?.c64u);
983
- const hasU2 = Boolean(cfg?.u2);
984
- const hasVice = Boolean(cfg?.vice);
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?.c64u ?? {});
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?.u2 ?? {}, "u2");
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?.vice ?? {});
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 = resolveBaseUrl({});
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({ baseUrl: probeBase });
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?.vice ?? {});
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?.c64u && primary.selected !== "c64u") {
1106
+ if (config.c64u && primary.selected !== "c64u") {
1040
1107
  facades.set("c64u", new C64uBackend(config.c64u));
1041
1108
  }
1042
- if (config?.u2 && primary.selected !== "u2") {
1109
+ if (config.u2 && primary.selected !== "u2") {
1043
1110
  facades.set("u2", new C64uBackend(config.u2, "u2"));
1044
1111
  }
1045
- if (config?.vice && primary.selected !== "vice") {
1112
+ if (config.vice && primary.selected !== "vice") {
1046
1113
  facades.set("vice", new ViceBackend(config.vice));
1047
1114
  }
1048
- if (!facades.has("c64u") && !config?.u2 && shouldProvisionSecondaryC64u(config, options) && primary.selected !== "u2") {
1049
- facades.set("c64u", new C64uBackend({
1050
- ...(config?.c64u ?? {}),
1051
- ...(!config?.c64u?.baseUrl && options?.preferredC64uBaseUrl ? { baseUrl: options.preferredC64uBaseUrl } : {}),
1052
- ...(!config?.c64u?.networkPassword && options?.preferredC64uNetworkPassword
1053
- ? { networkPassword: options.preferredC64uNetworkPassword }
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?.c64u
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
- return SYMBOLS[key];
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": 81, // widely used PETSCII code for heart glyph
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
- return NAMED_CHARS[key];
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
- let idx = startIndex + 1;
164
- let name = "";
165
- while (idx < source.length && source[idx] !== close) {
166
- name += source[idx]
167
- .replace(/[^A-Za-z0-9_\s-]/g, "") // restrict to sane name characters
168
- .trim();
169
- idx += 1;
170
- }
171
- if (idx >= source.length || source[idx] !== close) {
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
- bytes.push(asciiToPetscii(input[i]));
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);
@@ -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
- const selected = bestMatch ?? glyphs[0];
274
- codes.push(selected.petsciiCode);
275
- matchedGlyphs.push(selected);
282
+ matchedGlyphs.push(bestMatch ?? glyphs[0]);
276
283
  }
277
- return { codes, glyphs: matchedGlyphs, columns, rows };
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
- lines.push(`${lineNumber} FORX=0TO${Math.max(0, params.columns - 1)}`);
294
- lineNumber += 10;
295
- lines.push(`${lineNumber} READC:PRINT CHR$(C);`);
296
- lineNumber += 10;
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
- lines.push(`${lineNumber} NEXTY`);
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 < params.codes.length; i += chunkSize) {
303
- const slice = params.codes.slice(i, i + chunkSize);
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
- codes,
363
+ rowCodes: converted.rowCodes,
338
364
  columns: converted.columns,
339
365
  rows: converted.rows,
340
366
  borderColor: options.borderColor,
@@ -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
- const DOC_EXCLUDE_DIRS = new Set(["plans"]);
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";
@@ -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
- if (f % stepFrames === 0)
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
- if (f % stepFrames === 0)
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
- if (f % stepFrames === 0)
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
  }