c64bridge 1.0.4 → 1.0.6

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 (63) hide show
  1. package/README.md +2 -2
  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/data/sound/sidwave.md +113 -36
  8. package/data/sound/sidwave.schema.json +138 -28
  9. package/dist/.tsbuildinfo +1 -1
  10. package/dist/c64Client.js +144 -150
  11. package/dist/config.js +93 -39
  12. package/dist/device.js +252 -130
  13. package/dist/diagnostics.js +10 -2
  14. package/dist/knowledge.js +2 -1
  15. package/dist/mcp-server.js +12 -4
  16. package/dist/petscii.js +18 -15
  17. package/dist/petsciiArt.js +40 -14
  18. package/dist/rag/indexer.js +2 -1
  19. package/dist/sidwave.js +453 -61
  20. package/dist/sidwaveCompiler.js +1424 -275
  21. package/dist/tools/audio.js +97 -18
  22. package/dist/tools/debug.js +9 -9
  23. package/dist/tools/developer.js +7 -7
  24. package/dist/tools/errors.js +19 -1
  25. package/dist/tools/graphics.js +4 -4
  26. package/dist/tools/hex.js +9 -0
  27. package/dist/tools/input.js +21 -18
  28. package/dist/tools/machineControl.js +6 -6
  29. package/dist/tools/memory.js +11 -10
  30. package/dist/tools/meta/audio.js +8 -2
  31. package/dist/tools/meta/background.js +6 -1
  32. package/dist/tools/meta/filesystem.js +37 -10
  33. package/dist/tools/meta/graphics.js +11 -16
  34. package/dist/tools/meta/memory.js +16 -22
  35. package/dist/tools/meta/program.js +28 -9
  36. package/dist/tools/pollValidator.js +30 -2
  37. package/dist/tools/printer.js +4 -4
  38. package/dist/tools/programRunners.js +10 -9
  39. package/dist/tools/registry/disk.js +7 -5
  40. package/dist/tools/storage.js +21 -17
  41. package/dist/tools/streaming.js +2 -2
  42. package/dist/tools/translation/assembler.js +192 -57
  43. package/dist/tools/translation/basicTokenizer.js +44 -10
  44. package/dist/tools/translation/opcodeInventory.js +1 -1
  45. package/dist/tools/types.js +11 -4
  46. package/dist/tools/vice.js +1 -1
  47. package/dist/vicBitmap.js +16 -0
  48. package/dist/vice/mockServer.js +99 -8
  49. package/dist/vice/readiness.js +4 -9
  50. package/dist/vice/viceClient.js +34 -6
  51. package/mcp/schemas/c64_disk.schema.json +2 -2
  52. package/mcp/schemas/c64_input.schema.json +3 -3
  53. package/mcp/schemas/c64_program.schema.json +2 -2
  54. package/mcp/schemas/c64_system.schema.json +6 -4
  55. package/mcp/schemas/c64_vice.schema.json +3 -3
  56. package/mcp/server.json +1 -1
  57. package/mcp/tools.json +20 -14
  58. package/mcp.json +1 -1
  59. package/package.json +1 -1
  60. package/scripts/mockC64Server.mjs +113 -20
  61. package/scripts/run-coverage.mjs +31 -1
  62. package/scripts/update-readme.ts +3 -2
  63. package/server.json +2 -2
package/dist/c64Client.js CHANGED
@@ -11,7 +11,9 @@ import { createSocket } from "node:dgram";
11
11
  import { networkInterfaces } from "node:os";
12
12
  import axios from "axios";
13
13
  import { basicToPrg } from "./tools/translation/basicTokenizer.js";
14
- import { assemblyToPrg } from "./tools/translation/assembler.js";
14
+ import { assemblyToPrgDetailed, prgEntryAddress } from "./tools/translation/assembler.js";
15
+ import { stripHexByteSeparators } from "./tools/hex.js";
16
+ import { SIDWAVE_PLAY_ADDRESS } from "./sidwaveCompiler.js";
15
17
  import { petsciiToAscii, screenCodesToAscii } from "./petscii.js";
16
18
  import { resolveAddressSymbol } from "./knowledge.js";
17
19
  import { createAllFacades, createFacade, } from "./device.js";
@@ -32,6 +34,8 @@ export class KeyboardQueueDrainTimeoutError extends Error {
32
34
  this.timeoutMs = timeoutMs;
33
35
  }
34
36
  }
37
+ /** JOYPORT_ID_IO_SIMULATION in joyport.h (the same in VICE 3.9 and 3.10). */
38
+ const VICE_JOYPORT_IO_SIMULATION = 37;
35
39
  const C64U_SYSTEM_MODE_CATEGORY = "U64 Specific Settings";
36
40
  const C64U_SYSTEM_MODE_ITEM = "System Mode";
37
41
  // System Mode values that run the machine clock at the NTSC frequency
@@ -183,6 +187,8 @@ export class C64Client {
183
187
  this.greetingDd00Warmups = new Map();
184
188
  this.machineInputCapabilityProbes = new Map();
185
189
  this.c64uVideoCaptureSession = null;
190
+ /** VICE joyport device ids to restore when a joystick press ends. */
191
+ this.viceJoyportSavedDevice = new Map();
186
192
  this.activeType = "c64u";
187
193
  this.baseUrl = baseUrl;
188
194
  const headers = options.networkPassword ? { "X-Password": options.networkPassword } : undefined;
@@ -302,9 +308,6 @@ export class C64Client {
302
308
  }
303
309
  writeDiagnosticEvent("client_switch_backend", { backend: type });
304
310
  }
305
- async shouldUseC64uMockBypass() {
306
- return process.env.C64_TEST_TARGET === "mock" && (await this.getBackendType()) === "c64u";
307
- }
308
311
  async withViceMonitor(fn) {
309
312
  const backend = await this.requireViceBackend();
310
313
  return backend.withMonitor(fn);
@@ -546,15 +549,26 @@ export class C64Client {
546
549
  * tokens, and setting BASIC's TXTTAB/VARTAB/ARYTAB/STREND to the code
547
550
  * range corrupts the workspace. Instead this loads the assembled bytes
548
551
  * without running them, then types `SYS <entry>` to transfer control
549
- * directly to the first assembled instruction, whatever its address.
552
+ * to the entry point: the target of a leading SYS stub at $0801, or else
553
+ * the first assembled instruction.
550
554
  */
551
555
  async uploadAndRunAsm(program) {
552
556
  try {
553
- const prg = assemblyToPrg(program);
557
+ const assembled = assemblyToPrgDetailed(program);
558
+ const prg = assembled.prg;
554
559
  if (prg.length < 2) {
555
560
  return { success: false, details: { message: "Assembled program produced no output" } };
556
561
  }
557
- const entryAddress = prg.readUInt16LE(0);
562
+ const entryAddress = assembled.entryAddress;
563
+ if (prg.readUInt16LE(0) === 0x0801 && prgEntryAddress(prg) === 0x0801) {
564
+ // Loading makes BASIC relink $0801 as program lines, which overwrites raw code there.
565
+ return {
566
+ success: false,
567
+ details: {
568
+ message: "Machine code at $0801 is overwritten when BASIC relinks the loaded program. Omit the origin to get a BASIC SYS stub automatically, start the code with a BASIC SYS line, or assemble at another address such as $C000.",
569
+ },
570
+ };
571
+ }
558
572
  const facade = await this.facadePromise;
559
573
  const loadResult = await facade.loadPrg(prg);
560
574
  if (!loadResult.success) {
@@ -573,13 +587,6 @@ export class C64Client {
573
587
  }
574
588
  async runPrg(prg) {
575
589
  try {
576
- if (await this.shouldUseC64uMockBypass()) {
577
- const payload = Buffer.isBuffer(prg) ? prg : Buffer.from(prg);
578
- const response = await this.api.v1.runnersRunPrgCreate(":run_prg", payload, {
579
- headers: { "Content-Type": "application/octet-stream" },
580
- });
581
- return { success: true, details: response.data };
582
- }
583
590
  const facade = await this.facadePromise;
584
591
  const res = await facade.runPrg(prg);
585
592
  return { success: res.success, details: res.details };
@@ -659,10 +666,6 @@ export class C64Client {
659
666
  }
660
667
  async reset() {
661
668
  try {
662
- if (await this.shouldUseC64uMockBypass()) {
663
- const response = await this.api.v1.machineResetUpdate(":reset");
664
- return { success: true, details: response.data };
665
- }
666
669
  const facade = await this.facadePromise;
667
670
  const res = await facade.reset();
668
671
  return { success: res.success, details: res.details };
@@ -673,10 +676,6 @@ export class C64Client {
673
676
  }
674
677
  async reboot() {
675
678
  try {
676
- if (await this.shouldUseC64uMockBypass()) {
677
- const response = await this.api.v1.machineRebootUpdate(":reboot");
678
- return { success: true, details: response.data };
679
- }
680
679
  const facade = await this.facadePromise;
681
680
  const res = await facade.reboot();
682
681
  return { success: res.success, details: res.details };
@@ -770,31 +769,65 @@ export class C64Client {
770
769
  async sidReset(hard = false) {
771
770
  try {
772
771
  const facade = await this.facadePromise;
772
+ // Unhooking a running SIDWAVE player is best-effort: a failed read must not stop the SID being silenced.
773
+ const stoppedSidwavePlayer = await this.stopSidwaveIrqPlayer(facade).catch(() => false);
773
774
  if (hard) {
774
775
  const span = 0x19;
775
776
  const ff = Buffer.alloc(span, 0xff);
776
777
  const zz = Buffer.alloc(span, 0x00);
777
778
  await facade.writeMemory(0xd400, ff);
778
779
  await facade.writeMemory(0xd400, zz);
779
- return { success: true };
780
+ return { success: true, ...(stoppedSidwavePlayer ? { details: { stoppedSidwavePlayer } } : {}) };
780
781
  }
781
782
  await facade.writeMemory(0xd404, Buffer.from([0x00]));
782
783
  await facade.writeMemory(0xd40b, Buffer.from([0x00]));
783
784
  await facade.writeMemory(0xd412, Buffer.from([0x00]));
784
785
  await facade.writeMemory(0xd418, Buffer.from([0x00]));
785
- return { success: true };
786
+ return { success: true, ...(stoppedSidwavePlayer ? { details: { stoppedSidwavePlayer } } : {}) };
786
787
  }
787
788
  catch (error) {
788
789
  return { success: false, details: this.normaliseError(error) };
789
790
  }
790
791
  }
792
+ /**
793
+ * A SIDWAVE PRG keeps playing from the IRQ after the SID is silenced. Its
794
+ * IRQ handler is "JSR play / JMP $EA31", and its play vector at $0813 is
795
+ * "JMP play". When $0314 points at such a handler, point it back at the
796
+ * KERNAL handler so silencing sticks; other programs' IRQ handlers are left
797
+ * alone. Both vector bytes go in one write.
798
+ */
799
+ async stopSidwaveIrqPlayer(facade) {
800
+ const vector = await facade.readMemory(0x0314, 2);
801
+ const handler = (vector[0] ?? 0) | ((vector[1] ?? 0) << 8);
802
+ // A handler too close to $FFFF cannot hold the 6-byte SIDWAVE handler (and the read would cross $FFFF).
803
+ if (handler === 0xea31 || handler > 0xfffa)
804
+ return false;
805
+ const code = await facade.readMemory(handler, 6);
806
+ const playVector = await facade.readMemory(SIDWAVE_PLAY_ADDRESS, 3);
807
+ const isSidwaveHandler = code[0] === 0x20 && code[3] === 0x4c && code[4] === 0x31 && code[5] === 0xea
808
+ && playVector[0] === 0x4c && playVector[1] === code[1] && playVector[2] === code[2];
809
+ if (!isSidwaveHandler)
810
+ return false;
811
+ await facade.writeMemory(0x0314, Uint8Array.of(0x31, 0xea));
812
+ return true;
813
+ }
791
814
  async sidNoteOn(options, pinnedFacade) {
792
815
  const voice = options.voice ?? 1;
793
816
  if (voice < 1 || voice > 3) {
794
817
  return { success: false, details: { message: "Voice must be 1..3" } };
795
818
  }
796
819
  const system = options.system ?? "PAL";
797
- const hz = options.frequencyHz ?? (options.note ? this.noteNameToHz(options.note) : 440);
820
+ // The tool schema documents that a note name overrides frequencyHz.
821
+ const hz = options.note ? this.noteNameToHz(options.note) : options.frequencyHz ?? 440;
822
+ // The 16-bit frequency register tops out at about 3848 Hz on PAL and 3995 Hz
823
+ // on NTSC (B7 is already above the PAL limit); report it instead of clamping.
824
+ const maxHz = (0xffff * (system === "PAL" ? 985248 : 1022727)) / 16777216;
825
+ if (hz > maxHz + 0.5) {
826
+ return {
827
+ success: false,
828
+ details: { message: `${options.note ?? `${hz} Hz`} (${hz.toFixed(1)} Hz) is above the SID's highest ${system} frequency of ${maxHz.toFixed(1)} Hz` },
829
+ };
830
+ }
798
831
  const freq16 = this.hzToSidFrequency(hz, system);
799
832
  const freqLo = freq16 & 0xff;
800
833
  const freqHi = (freq16 >> 8) & 0xff;
@@ -857,10 +890,6 @@ export class C64Client {
857
890
  }
858
891
  async pause(pinnedFacade) {
859
892
  try {
860
- if (!pinnedFacade && await this.shouldUseC64uMockBypass()) {
861
- const res = await this.api.v1.machinePauseUpdate(":pause");
862
- return { success: true, details: res.data };
863
- }
864
893
  const facade = pinnedFacade ?? await this.facadePromise;
865
894
  return await facade.pause();
866
895
  }
@@ -870,10 +899,6 @@ export class C64Client {
870
899
  }
871
900
  async resume(pinnedFacade) {
872
901
  try {
873
- if (!pinnedFacade && await this.shouldUseC64uMockBypass()) {
874
- const res = await this.api.v1.machineResumeUpdate(":resume");
875
- return { success: true, details: res.data };
876
- }
877
902
  const facade = pinnedFacade ?? await this.facadePromise;
878
903
  return await facade.resume();
879
904
  }
@@ -981,10 +1006,6 @@ export class C64Client {
981
1006
  }
982
1007
  async menuButton() {
983
1008
  try {
984
- if (await this.shouldUseC64uMockBypass()) {
985
- const res = await this.api.v1.machineMenuButtonUpdate(":menu_button");
986
- return { success: true, details: res.data };
987
- }
988
1009
  const facade = await this.facadePromise;
989
1010
  return await facade.menuButton();
990
1011
  }
@@ -1006,10 +1027,6 @@ export class C64Client {
1006
1027
  }
1007
1028
  async debugregRead() {
1008
1029
  try {
1009
- if (await this.shouldUseC64uMockBypass()) {
1010
- const res = await this.api.v1.machineDebugregList(":debugreg");
1011
- return { success: true, value: res.data.value, details: res.data };
1012
- }
1013
1030
  const facade = await this.facadePromise;
1014
1031
  return await facade.debugregRead();
1015
1032
  }
@@ -1019,10 +1036,6 @@ export class C64Client {
1019
1036
  }
1020
1037
  async debugregWrite(value) {
1021
1038
  try {
1022
- if (await this.shouldUseC64uMockBypass()) {
1023
- const res = await this.api.v1.machineDebugregUpdate(":debugreg", { value });
1024
- return { success: true, value: res.data.value, details: res.data };
1025
- }
1026
1039
  const facade = await this.facadePromise;
1027
1040
  return await facade.debugregWrite(value);
1028
1041
  }
@@ -1083,10 +1096,6 @@ export class C64Client {
1083
1096
  try {
1084
1097
  if (!drive || !path)
1085
1098
  throw new Error("Drive and path are required");
1086
- if (await this.shouldUseC64uMockBypass()) {
1087
- const res = await this.api.v1.drivesLoadRomUpdate(drive, ":load_rom", { file: path });
1088
- return { success: true, details: res.data };
1089
- }
1090
1099
  const facade = await this.facadePromise;
1091
1100
  const result = await facade.driveLoadRom(drive, path);
1092
1101
  return { success: result.success, details: result.details };
@@ -2001,6 +2010,43 @@ export class C64Client {
2001
2010
  async viceJoyportSet(port, activeLowMask) {
2002
2011
  await this.withViceMonitor((client) => client.joyportSet(port, activeLowMask));
2003
2012
  }
2013
+ /**
2014
+ * Hold joystick lines on a VICE control port. Binary Monitor command 0xA2
2015
+ * only drives VICE's "I/O simulation" joyport device, so that device is
2016
+ * attached for the duration of the press and the previous device is
2017
+ * restored on release. The device reads bits 5-7 as low, which the
2018
+ * keyboard scan sees as extra keys, so it must not stay attached.
2019
+ *
2020
+ * While a press is held: on port 1 the keyboard scan sees phantom keys
2021
+ * (for example SHIFT+C=, which toggles the character set); on port 2,
2022
+ * $DC00 reads with bits 5-7 clear (up = $1E where a joystick gives $7E),
2023
+ * so programs that compare the whole byte will not see the input.
2024
+ */
2025
+ async viceJoystickPress(port, activeLowMask) {
2026
+ await this.withViceMonitor(async (client) => {
2027
+ const resource = `JoyPort${port}Device`;
2028
+ // Read the device every time: after a VICE restart the saved state is stale.
2029
+ const current = await client.resourceGet(resource);
2030
+ const currentId = current.type === "int" ? current.value : Number(current.value);
2031
+ if (currentId !== VICE_JOYPORT_IO_SIMULATION) {
2032
+ this.viceJoyportSavedDevice.set(port, currentId);
2033
+ await client.resourceSet(resource, VICE_JOYPORT_IO_SIMULATION);
2034
+ }
2035
+ // Attaching the device clears its lines to 0 (everything pressed), so set them in the same monitor session.
2036
+ await client.joyportSet(port, activeLowMask);
2037
+ });
2038
+ }
2039
+ /** Release a VICE control port and restore the joyport device it had before the press. */
2040
+ async viceJoystickRelease(port) {
2041
+ await this.withViceMonitor(async (client) => {
2042
+ await client.joyportSet(port, 0xff);
2043
+ const saved = this.viceJoyportSavedDevice.get(port);
2044
+ if (saved !== undefined) {
2045
+ await client.resourceSet(`JoyPort${port}Device`, saved);
2046
+ this.viceJoyportSavedDevice.delete(port);
2047
+ }
2048
+ });
2049
+ }
2004
2050
  async viceMemGet(address, length) {
2005
2051
  const end = (address + length - 1) & 0xffff;
2006
2052
  return this.withViceMonitor((client) => client.memGet(address, end));
@@ -2184,8 +2230,7 @@ export class C64Client {
2184
2230
  if (trimmed.length === 0) {
2185
2231
  throw new Error("Expected non-empty byte string");
2186
2232
  }
2187
- const withoutPrefix = trimmed.startsWith("$") ? trimmed.slice(1) : trimmed;
2188
- const cleaned = withoutPrefix.replace(/[\s_]/g, "").toLowerCase();
2233
+ const cleaned = stripHexByteSeparators(trimmed).toLowerCase();
2189
2234
  if (cleaned.length === 0) {
2190
2235
  throw new Error("No hexadecimal data provided");
2191
2236
  }
@@ -2300,86 +2345,6 @@ function buildGreetingScreenRam(message) {
2300
2345
  writeScreenLine(screenRam, 3, "READY.");
2301
2346
  return screenRam;
2302
2347
  }
2303
- function buildSingleSpriteProgram(opts) {
2304
- const index = Math.max(0, Math.min(7, opts.spriteIndex ?? 0));
2305
- const mx = Math.max(0, Math.min(511, opts.x ?? 100));
2306
- const xLo = mx & 0xff;
2307
- const xMsbBit = (mx & 0x100) ? (1 << index) : 0;
2308
- const y = toByte(opts.y, 100);
2309
- const color = toByte(opts.color, 1);
2310
- const multicolour = !!opts.multicolour;
2311
- const spriteData = Buffer.from(opts.spriteBytes);
2312
- if (spriteData.length !== 63) {
2313
- throw new Error("spriteBytes must be exactly 63 bytes");
2314
- }
2315
- // We'll assemble a tiny machine-code program that:
2316
- // - Copies 63 bytes to a safe sprite data page ($2000 by default)
2317
- // - Sets screen memory base to $0400, sprite pointer to point into $2000
2318
- // - Positions and enables the sprite
2319
- // - Loops forever
2320
- // This avoids relying on KERNAL calls and works from a cold start.
2321
- const SPRITE_BASE = 0x2000; // must be 64-byte aligned
2322
- const POINTER_PAGE = 0x07f8 + index; // sprite pointer table location
2323
- const pointerValue = (SPRITE_BASE >> 6) & 0xff;
2324
- // Place code starting at $0801 so it runs as a program via SYS.
2325
- // We'll create an assembler source and reuse assemblyToPrg.
2326
- const lines = [];
2327
- lines.push("* = $0801");
2328
- // Tiny BASIC loader header not needed; we will use pure ML and jump via RESET runner which executes by SYS 2061
2329
- // Build code at $0810 to avoid conflicting with potential KERNAL vectors
2330
- lines.push("* = $0810");
2331
- lines.push("\nstart:");
2332
- // Copy 63 bytes from inlined table to SPRITE_BASE
2333
- lines.push(" LDY #$00");
2334
- lines.push("copy_loop:");
2335
- lines.push(" LDA sprite_data,Y");
2336
- lines.push(` STA $${hex16(SPRITE_BASE)},Y`);
2337
- lines.push(" INY");
2338
- lines.push(" CPY #$3F");
2339
- lines.push(" BNE copy_loop");
2340
- // Set sprite pointer, color, coordinates, enable
2341
- lines.push(` LDA #$${pointerValue.toString(16).toUpperCase().padStart(2, "0")}`);
2342
- lines.push(` STA $${(POINTER_PAGE).toString(16).toUpperCase()}`);
2343
- lines.push(` LDA #$${color.toString(16).toUpperCase().padStart(2, "0")}`);
2344
- lines.push(` STA $${hex16(0xD027 + index)}`);
2345
- lines.push(` LDA #$${xLo.toString(16).toUpperCase().padStart(2, "0")}`);
2346
- lines.push(` STA $${(0xD000 + index * 2).toString(16).toUpperCase()}`);
2347
- lines.push(` LDA #$${y.toString(16).toUpperCase().padStart(2, "0")}`);
2348
- lines.push(` STA $${(0xD001 + index * 2).toString(16).toUpperCase()}`);
2349
- // MSB X if needed
2350
- if (xMsbBit) {
2351
- const bit = xMsbBit;
2352
- lines.push(` LDA $D010`);
2353
- lines.push(` ORA #$${bit.toString(16).toUpperCase().padStart(2, "0")}`);
2354
- lines.push(` STA $D010`);
2355
- }
2356
- // Multicolour toggle per-sprite
2357
- if (multicolour) {
2358
- const bit = 1 << index;
2359
- lines.push(` LDA $D01C`);
2360
- lines.push(` ORA #$${bit.toString(16).toUpperCase().padStart(2, "0")}`);
2361
- lines.push(` STA $D01C`);
2362
- }
2363
- // Enable sprite
2364
- {
2365
- const bit = 1 << index;
2366
- lines.push(` LDA $D015`);
2367
- lines.push(` ORA #$${bit.toString(16).toUpperCase().padStart(2, "0")}`);
2368
- lines.push(` STA $D015`);
2369
- }
2370
- // Idle loop
2371
- lines.push("forever: JMP forever");
2372
- // Sprite data table
2373
- lines.push("\nsprite_data:");
2374
- for (let i = 0; i < 63; i += 3) {
2375
- const a = spriteData[i] ?? 0;
2376
- const b = spriteData[i + 1] ?? 0;
2377
- const c = spriteData[i + 2] ?? 0;
2378
- lines.push(` .byte $${hex2(a)},$${hex2(b)},$${hex2(c)}`);
2379
- }
2380
- const source = lines.join("\n");
2381
- return assemblyToPrg(source, { fileName: "sprite_gen.asm", loadAddress: 0x0801 });
2382
- }
2383
2348
  export function buildPetsciiScreenBasic(opts) {
2384
2349
  const border = toByte(opts.borderColor ?? 6); // default blue-ish
2385
2350
  const bg = toByte(opts.backgroundColor ?? 0); // default black
@@ -2415,9 +2380,12 @@ export function buildPrinterBasicProgram(opts) {
2415
2380
  ln += 10;
2416
2381
  continue;
2417
2382
  }
2418
- const chunks = chunkString(logical, 45);
2383
+ // Chunk by the length of the expression, not the text: CHR$ splicing can make
2384
+ // an expression up to ten times longer, and a BASIC line must stay short
2385
+ // (the ROM's LINKPRG scans each line with an 8-bit index).
2386
+ const chunks = chunkByExpressionLength(logical, (text) => printerTextExpression(text, target, saddr), PRINTER_EXPRESSION_MAX);
2419
2387
  for (let i = 0; i < chunks.length; i += 1) {
2420
- const chunk = basicStringExpression(chunks[i]);
2388
+ const chunk = printerTextExpression(chunks[i], target, saddr);
2421
2389
  const tail = i < chunks.length - 1 ? ";" : ""; // avoid CR between chunks within the same logical line
2422
2390
  lines.push(`${ln} PRINT#1,${chunk}${tail}`);
2423
2391
  ln += 10;
@@ -2432,21 +2400,53 @@ export function buildPrinterBasicProgram(opts) {
2432
2400
  lines.push(`${ln} CLOSE1`);
2433
2401
  return lines.join("\n");
2434
2402
  }
2435
- function chunkString(input, maxLen) {
2436
- if (input.length <= maxLen)
2437
- return [input];
2403
+ /** Longest PRINT#1 expression per BASIC line; keeps each line under the 80-character input limit. */
2404
+ const PRINTER_EXPRESSION_MAX = 64;
2405
+ function chunkByExpressionLength(input, expression, maxLen) {
2438
2406
  const parts = [];
2439
- let i = 0;
2440
- while (i < input.length) {
2441
- parts.push(input.slice(i, i + maxLen));
2442
- i += maxLen;
2407
+ let current = "";
2408
+ for (const char of input) {
2409
+ if (current && expression(current + char).length > maxLen) {
2410
+ parts.push(current);
2411
+ current = "";
2412
+ }
2413
+ current += char;
2443
2414
  }
2415
+ if (current)
2416
+ parts.push(current);
2444
2417
  return parts;
2445
2418
  }
2446
- function basicStringExpression(input) {
2447
- // BASIC V2 has no doubled-quote escaping. Splice literal quote characters
2448
- // through CHR$(34), including leading/trailing/consecutive quote cases.
2449
- return input.split('"').map((part) => `"${part}"`).join(";CHR$(34);");
2419
+ /**
2420
+ * BASIC V2 has no doubled-quote escaping, so quotes are spliced in as CHR$(34).
2421
+ * String literals also store a-z as the PETSCII letters $41-$5A, so printer
2422
+ * text that needs other codes is spliced in with CHR$: an Epson printer takes
2423
+ * ASCII, so lowercase stays $61-$7A; a Commodore printer in lowercase mode
2424
+ * (secondary address 7) prints $41-$5A as lowercase and $C1-$DA as capitals.
2425
+ */
2426
+ function printerTextExpression(input, target, secondaryAddress) {
2427
+ const needsChr = (char) => {
2428
+ if (target === "epson" && char >= "a" && char <= "z")
2429
+ return char.charCodeAt(0);
2430
+ if (target === "commodore" && secondaryAddress === 7 && char >= "A" && char <= "Z")
2431
+ return char.charCodeAt(0) + 0x80;
2432
+ return undefined;
2433
+ };
2434
+ const pieces = [];
2435
+ let literal = "";
2436
+ for (const char of input) {
2437
+ const code = char === '"' ? 34 : needsChr(char);
2438
+ if (code === undefined) {
2439
+ literal += char;
2440
+ continue;
2441
+ }
2442
+ if (literal)
2443
+ pieces.push(`"${literal}"`);
2444
+ literal = "";
2445
+ pieces.push(`CHR$(${code})`);
2446
+ }
2447
+ if (literal || pieces.length === 0)
2448
+ pieces.push(`"${literal}"`);
2449
+ return pieces.join(";");
2450
2450
  }
2451
2451
  export function buildCommodoreBitmapBasicProgram(opts) {
2452
2452
  const repeats = Math.max(1, Math.floor(opts.repeats ?? 1));
@@ -2544,9 +2544,3 @@ export function buildCommodoreDllBasicProgram(opts) {
2544
2544
  lines.push(`${ln} CLOSE1`);
2545
2545
  return lines.join("\n");
2546
2546
  }
2547
- function hex2(n) {
2548
- return (n & 0xff).toString(16).toUpperCase().padStart(2, "0");
2549
- }
2550
- function hex16(n) {
2551
- return (n & 0xffff).toString(16).toUpperCase().padStart(4, "0");
2552
- }
package/dist/config.js CHANGED
@@ -4,13 +4,6 @@ import os from "node:os";
4
4
  import { fileURLToPath } from "url";
5
5
  const DEFAULT_HOST = "c64u";
6
6
  const DEFAULT_PORT = 80;
7
- const DEFAULT_CONFIG = {
8
- c64_host: DEFAULT_HOST,
9
- baseUrl: buildBaseUrl(DEFAULT_HOST, DEFAULT_PORT),
10
- c64_port: DEFAULT_PORT,
11
- vicePrewarm: false,
12
- backendConfig: {},
13
- };
14
7
  export function loadConfig() {
15
8
  const configPath = process.env.C64BRIDGE_CONFIG;
16
9
  const cwdConfigPath = join(process.cwd(), ".c64bridge.json");
@@ -39,61 +32,120 @@ export function loadConfig() {
39
32
  rawConfig ?? (rawConfig = {});
40
33
  // New schema: choose the configured Ultimate hardware profile; retain
41
34
  // c64u and legacy fields as the default when C64_MODE is not set to u2.
42
- const c64u = rawConfig?.c64u;
43
- const u2 = rawConfig?.u2;
35
+ const c64u = ultimateSection(rawConfig?.c64u);
36
+ const u2 = ultimateSection(rawConfig?.u2);
44
37
  const useU2 = process.env.C64_MODE?.trim().toLowerCase() === "u2";
45
- const hardware = useU2 ? (u2 ?? c64u) : (c64u ?? u2);
38
+ // Legacy top-level endpoint fields describe the single Ultimate of a
39
+ // pre-profile config, so they configure a backend only when the document
40
+ // has neither a c64u nor a u2 section. Each backend otherwise uses its own
41
+ // section, so one device's host, port or password never reaches the other.
42
+ const legacyUltimateEndpoint = !c64u && !u2 && LEGACY_ENDPOINT_KEYS.some((key) => hasLegacyValue(rawConfig?.[key]));
43
+ const ultimate = {
44
+ c64u: resolveUltimateEndpoint("c64u", c64u, c64u || !u2 ? rawConfig : undefined),
45
+ u2: resolveUltimateEndpoint("u2", u2 ?? c64u, u2 ? undefined : rawConfig),
46
+ };
47
+ // The selected profile reported by loadConfig keeps the documented fallback
48
+ // between sections and to the legacy fields.
49
+ const selected = useU2
50
+ ? resolveUltimateEndpoint("u2", u2 ?? c64u, rawConfig)
51
+ : resolveUltimateEndpoint("c64u", c64u ?? u2, rawConfig);
46
52
  const vice = rawConfig?.vice;
47
- const parsedC64uHost = parseEndpoint(configuredString(hardware?.host));
48
- const parsedC64uHostname = parseEndpoint(configuredString(hardware?.hostname));
49
- const parsedLegacyHost = parseEndpoint(configuredString(rawConfig?.c64_host));
50
- const parsedLegacyIp = parseEndpoint(configuredString(rawConfig?.c64_ip));
51
- const parsedEnvHost = parseEndpoint(configuredString(useU2 ? process.env.U2_HOST ?? process.env.C64U_HOST : process.env.C64U_HOST));
53
+ const config = {
54
+ c64_host: selected.c64_host,
55
+ baseUrl: selected.baseUrl,
56
+ c64_port: selected.port,
57
+ networkPassword: selected.networkPassword,
58
+ vicePrewarm: firstDefined(configuredBoolean(process.env.VICE_PREWARM), configuredBoolean(vice?.prewarm), configuredBoolean(rawConfig?.vicePrewarm)) ?? false,
59
+ ultimate,
60
+ legacyUltimateEndpoint,
61
+ backendConfig: {
62
+ ...(c64u ? { c64u } : {}),
63
+ ...(u2 ? { u2 } : {}),
64
+ ...(rawConfig?.vice && typeof rawConfig.vice === "object" && !Array.isArray(rawConfig.vice) ? { vice: rawConfig.vice } : {}),
65
+ },
66
+ };
67
+ return config;
68
+ }
69
+ /** Top-level fields from the pre-profile config schema that name an endpoint. */
70
+ const LEGACY_ENDPOINT_KEYS = ["baseUrl", "c64_host", "c64_ip", "c64_port"];
71
+ function hasLegacyValue(value) {
72
+ return (typeof value === "string" && value.trim().length > 0) || (typeof value === "number" && Number.isFinite(value));
73
+ }
74
+ function ultimateSection(value) {
75
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
76
+ }
77
+ /**
78
+ * The single host/port/password precedence for an Ultimate profile, used by
79
+ * loadConfig() and by every C64U/U2 backend construction:
80
+ * env > section host/hostname/port > legacy top-level c64_host/c64_ip/c64_port
81
+ * > section baseUrl > legacy top-level baseUrl > defaults. Passwords: env >
82
+ * section > legacy top-level networkPassword.
83
+ */
84
+ export function resolveUltimateEndpoint(profile, section, legacy) {
85
+ const envHost = profile === "u2" ? process.env.U2_HOST ?? process.env.C64U_HOST : process.env.C64U_HOST;
86
+ const envPort = profile === "u2" ? process.env.U2_PORT ?? process.env.C64U_PORT : process.env.C64U_PORT;
87
+ const envPassword = profile === "u2" ? process.env.U2_PASSWORD ?? process.env.C64U_PASSWORD : process.env.C64U_PASSWORD;
88
+ const parsedSectionHost = parseEndpoint(configuredString(section?.host));
89
+ const parsedSectionHostname = parseEndpoint(configuredString(section?.hostname));
90
+ const parsedLegacyHost = parseEndpoint(configuredString(legacy?.c64_host));
91
+ const parsedLegacyIp = parseEndpoint(configuredString(legacy?.c64_ip));
92
+ const parsedEnvHost = parseEndpoint(configuredString(envHost));
52
93
  const parsedBaseOverrides = [
53
- parseEndpoint(normaliseBaseUrl(hardware?.baseUrl)),
54
- parseEndpoint(normaliseBaseUrl(rawConfig?.baseUrl)),
94
+ parseEndpoint(normaliseBaseUrl(configuredString(section?.baseUrl))),
95
+ parseEndpoint(normaliseBaseUrl(configuredString(legacy?.baseUrl))),
55
96
  ];
56
97
  const hostCandidates = [
57
98
  parsedEnvHost.hostname,
58
- parsedC64uHost.hostname,
59
- parsedC64uHostname.hostname,
99
+ parsedSectionHost.hostname,
100
+ parsedSectionHostname.hostname,
60
101
  parsedLegacyHost.hostname,
61
102
  parsedLegacyIp.hostname,
62
103
  ...parsedBaseOverrides.map((entry) => entry.hostname),
63
104
  ];
64
105
  const portCandidates = [
65
- configuredPort(useU2 ? process.env.U2_PORT ?? process.env.C64U_PORT : process.env.C64U_PORT),
106
+ configuredPort(envPort),
66
107
  parsedEnvHost.port,
67
- configuredPort(hardware?.port),
68
- parsedC64uHost.port,
69
- parsedC64uHostname.port,
70
- configuredPort(rawConfig?.c64_port),
108
+ configuredPort(section?.port),
109
+ parsedSectionHost.port,
110
+ parsedSectionHostname.port,
111
+ configuredPort(legacy?.c64_port),
71
112
  parsedLegacyHost.port,
72
113
  parsedLegacyIp.port,
73
114
  ...parsedBaseOverrides.map((entry) => entry.port),
74
115
  ];
75
116
  const host = firstDefined(...hostCandidates) ?? DEFAULT_HOST;
76
- const port = firstDefined(...portCandidates) ?? DEFAULT_PORT;
77
- const baseUrl = buildBaseUrl(host, port);
117
+ // When the host comes from a baseUrl, keep its scheme and path (for example
118
+ // https://proxy/c64u), applying only an explicit port override. An explicit
119
+ // host builds http://host:port as documented.
120
+ const baseOnly = parsedBaseOverrides.find((entry) => entry.hostname !== undefined);
121
+ const explicitHost = firstDefined(...hostCandidates.slice(0, -parsedBaseOverrides.length));
122
+ const explicitPort = firstDefined(...portCandidates.slice(0, -parsedBaseOverrides.length));
123
+ const keepBaseUrl = baseOnly?.fullUrl !== undefined && explicitHost === undefined;
124
+ const schemeDefaultPort = keepBaseUrl && baseOnly.fullUrl.startsWith("https:") ? 443 : DEFAULT_PORT;
125
+ const port = firstDefined(...portCandidates) ?? schemeDefaultPort;
78
126
  const hostLabel = formatHost(host);
79
- const hostWithPort = port === DEFAULT_PORT ? hostLabel : `${hostLabel}:${port}`;
80
- const config = {
81
- c64_host: hostWithPort,
127
+ let baseUrl = buildBaseUrl(host, port);
128
+ if (keepBaseUrl) {
129
+ const url = new URL(baseOnly.fullUrl);
130
+ if (explicitPort !== undefined)
131
+ url.port = String(explicitPort);
132
+ baseUrl = stripTrailingSlash(url.toString());
133
+ }
134
+ return {
135
+ host,
136
+ port,
82
137
  baseUrl,
83
- c64_port: port,
84
- networkPassword: firstDefined(configuredString(useU2 ? process.env.U2_PASSWORD ?? process.env.C64U_PASSWORD : process.env.C64U_PASSWORD), configuredString(hardware?.networkPassword), configuredString(rawConfig?.networkPassword)),
85
- vicePrewarm: firstDefined(configuredBoolean(process.env.VICE_PREWARM), configuredBoolean(vice?.prewarm), configuredBoolean(rawConfig?.vicePrewarm)) ?? false,
86
- backendConfig: {
87
- ...(rawConfig?.c64u && typeof rawConfig.c64u === "object" && !Array.isArray(rawConfig.c64u) ? { c64u: rawConfig.c64u } : {}),
88
- ...(rawConfig?.u2 && typeof rawConfig.u2 === "object" && !Array.isArray(rawConfig.u2) ? { u2: rawConfig.u2 } : {}),
89
- ...(rawConfig?.vice && typeof rawConfig.vice === "object" && !Array.isArray(rawConfig.vice) ? { vice: rawConfig.vice } : {}),
90
- },
138
+ c64_host: port === schemeDefaultPort ? hostLabel : `${hostLabel}:${port}`,
139
+ networkPassword: firstDefined(nonEmptyString(envPassword), nonEmptyString(section?.networkPassword), nonEmptyString(legacy?.networkPassword)),
91
140
  };
92
- return config;
93
141
  }
94
142
  function configuredString(value) {
95
143
  return typeof value === "string" ? value.trim() : undefined;
96
144
  }
145
+ function nonEmptyString(value) {
146
+ const trimmed = configuredString(value);
147
+ return trimmed ? trimmed : undefined;
148
+ }
97
149
  function configuredPort(value) {
98
150
  if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) {
99
151
  return value;
@@ -144,7 +196,9 @@ function parseEndpoint(value) {
144
196
  const hostname = url.hostname || undefined;
145
197
  const port = url.port ? configuredPort(url.port) : undefined;
146
198
  const baseUrl = stripTrailingSlash(`${url.protocol}//${url.host}`);
147
- return { hostname, port, baseUrl };
199
+ // Scheme and path prefix kept, for an Ultimate reached through a reverse proxy.
200
+ const fullUrl = stripTrailingSlash(`${url.protocol}//${url.host}${url.pathname}`);
201
+ return { hostname, port, baseUrl, fullUrl };
148
202
  }
149
203
  catch {
150
204
  return {};