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.
Files changed (58) hide show
  1. package/README.md +12 -12
  2. package/data/embeddings_basic.json +1 -1
  3. package/data/embeddings_hardware.json +1 -1
  4. package/data/embeddings_mixed.json +1 -1
  5. package/data/embeddings_other.json +1 -1
  6. package/data/graphics/character-set.csv +64 -64
  7. package/dist/.tsbuildinfo +1 -1
  8. package/dist/c64Client.js +68 -61
  9. package/dist/config.js +75 -38
  10. package/dist/device.js +154 -120
  11. package/dist/knowledge.js +2 -1
  12. package/dist/petscii.js +18 -15
  13. package/dist/petsciiArt.js +40 -14
  14. package/dist/rag/indexer.js +2 -1
  15. package/dist/sidwave.js +1 -1
  16. package/dist/sidwaveCompiler.js +3 -9
  17. package/dist/tools/audio.js +34 -8
  18. package/dist/tools/debug.js +9 -9
  19. package/dist/tools/errors.js +12 -1
  20. package/dist/tools/hex.js +9 -0
  21. package/dist/tools/input.js +13 -7
  22. package/dist/tools/memory.js +5 -4
  23. package/dist/tools/meta/artifacts.js +8 -3
  24. package/dist/tools/meta/audio.js +25 -28
  25. package/dist/tools/meta/background.js +6 -1
  26. package/dist/tools/meta/config.js +12 -4
  27. package/dist/tools/meta/configInventory.js +4 -1
  28. package/dist/tools/meta/filesystem.js +37 -10
  29. package/dist/tools/meta/graphics.js +10 -15
  30. package/dist/tools/meta/memory.js +12 -18
  31. package/dist/tools/meta/program.js +44 -16
  32. package/dist/tools/pollValidator.js +30 -2
  33. package/dist/tools/programRunners.js +5 -4
  34. package/dist/tools/registry/batch.js +4 -2
  35. package/dist/tools/registry/config.js +1 -1
  36. package/dist/tools/registry/disk.js +5 -0
  37. package/dist/tools/registry/extract.js +3 -0
  38. package/dist/tools/registry/graphics.js +1 -0
  39. package/dist/tools/registry/program.js +1 -1
  40. package/dist/tools/registry/sound.js +3 -3
  41. package/dist/tools/registry/system.js +3 -3
  42. package/dist/tools/storage.js +2 -2
  43. package/dist/tools/translation/assembler.js +150 -47
  44. package/dist/tools/translation/basicTokenizer.js +39 -9
  45. package/dist/tools/types.js +11 -4
  46. package/dist/vicBitmap.js +16 -0
  47. package/dist/vice/mockServer.js +7 -3
  48. package/dist/vice/viceClient.js +16 -6
  49. package/mcp/schemas/c64_config.schema.json +2 -2
  50. package/mcp/schemas/c64_program.schema.json +10 -18
  51. package/mcp/schemas/c64_sound.schema.json +8 -6
  52. package/mcp/schemas/c64_system.schema.json +9 -7
  53. package/mcp/server.json +1 -1
  54. package/mcp/tools.json +58 -43
  55. package/mcp.json +1 -1
  56. package/package.json +1 -1
  57. package/scripts/mockC64Server.mjs +98 -19
  58. 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); }
@@ -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
- throw unsupported("configGet without item name");
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 === "int" ? Number(value) : value;
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 === "int" ? Number(str) : str;
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?.c64u);
965
- const hasU2 = Boolean(cfg?.u2);
966
- const hasVice = Boolean(cfg?.vice);
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?.c64u ?? {});
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?.u2 ?? {}, "u2");
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?.vice ?? {});
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 = resolveBaseUrl({});
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({ baseUrl: probeBase });
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?.vice ?? {});
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?.c64u && primary.selected !== "c64u") {
1106
+ if (config.c64u && primary.selected !== "c64u") {
1022
1107
  facades.set("c64u", new C64uBackend(config.c64u));
1023
1108
  }
1024
- if (config?.u2 && primary.selected !== "u2") {
1109
+ if (config.u2 && primary.selected !== "u2") {
1025
1110
  facades.set("u2", new C64uBackend(config.u2, "u2"));
1026
1111
  }
1027
- if (config?.vice && primary.selected !== "vice") {
1112
+ if (config.vice && primary.selected !== "vice") {
1028
1113
  facades.set("vice", new ViceBackend(config.vice));
1029
1114
  }
1030
- if (!facades.has("c64u") && !config?.u2 && shouldProvisionSecondaryC64u(config, options) && primary.selected !== "u2") {
1031
- facades.set("c64u", new C64uBackend({
1032
- ...(config?.c64u ?? {}),
1033
- ...(!config?.c64u?.baseUrl && options?.preferredC64uBaseUrl ? { baseUrl: options.preferredC64uBaseUrl } : {}),
1034
- ...(!config?.c64u?.networkPassword && options?.preferredC64uNetworkPassword
1035
- ? { networkPassword: options.preferredC64uNetworkPassword }
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?.c64u
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
- 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";