c64bridge 0.9.3 → 1.0.1

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 (65) hide show
  1. package/AGENTS.md +19 -3
  2. package/README.md +190 -148
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/c64Client.js +277 -41
  5. package/dist/config.js +30 -18
  6. package/dist/device.js +191 -125
  7. package/dist/knowledge.js +1 -1
  8. package/dist/loggingHttpClient.js +15 -3
  9. package/dist/mcp-server.js +65 -16
  10. package/dist/platform.js +13 -2
  11. package/dist/rag/indexer.js +11 -8
  12. package/dist/rag/init.js +11 -8
  13. package/dist/sidwaveCompiler.js +2 -2
  14. package/dist/tools/audio.js +27 -6
  15. package/dist/tools/developer.js +6 -6
  16. package/dist/tools/graphics.js +4 -4
  17. package/dist/tools/input.js +136 -24
  18. package/dist/tools/machineControl.js +85 -6
  19. package/dist/tools/memory.js +21 -6
  20. package/dist/tools/meta/artifacts.js +20 -8
  21. package/dist/tools/meta/background.js +11 -1
  22. package/dist/tools/meta/index.js +1 -1
  23. package/dist/tools/pollValidator.js +19 -51
  24. package/dist/tools/printer.js +1 -0
  25. package/dist/tools/programRunners.js +5 -5
  26. package/dist/tools/rag.js +1 -1
  27. package/dist/tools/registry/batch.js +1 -1
  28. package/dist/tools/registry/config.js +5 -5
  29. package/dist/tools/registry/disk.js +2 -2
  30. package/dist/tools/registry/drive.js +2 -2
  31. package/dist/tools/registry/graphics.js +2 -2
  32. package/dist/tools/registry/memory.js +2 -2
  33. package/dist/tools/registry/platform.js +9 -5
  34. package/dist/tools/registry/printer.js +1 -0
  35. package/dist/tools/registry/program.js +5 -5
  36. package/dist/tools/registry/rag.js +1 -1
  37. package/dist/tools/registry/sound.js +4 -4
  38. package/dist/tools/registry/system.js +13 -3
  39. package/dist/tools/storage.js +1 -1
  40. package/dist/vice/mockServer.js +33 -0
  41. package/dist/vice/process.js +11 -1
  42. package/dist/vice/viceClient.js +35 -6
  43. package/doc/c64u/c64-openapi.yaml +1130 -774
  44. package/doc/reviews/hardening/01-fable/prompt-handover.md +125 -0
  45. package/doc/reviews/hardening/01-fable/prompt.md +98 -0
  46. package/doc/reviews/hardening/01-fable/review-prompt.md +468 -0
  47. package/doc/reviews/hardening/01-fable/review.md +1234 -0
  48. package/doc/u2/u2-openapi.yaml +1192 -0
  49. package/generated/c64/index.js +142 -9
  50. package/generated/openapi/c64u/index.js +1117 -0
  51. package/generated/openapi/u2/index.js +929 -0
  52. package/mcp/schemas/c64_graphics.schema.json +3 -3
  53. package/mcp/schemas/c64_input.schema.json +249 -8
  54. package/mcp/schemas/c64_memory.schema.json +3 -3
  55. package/mcp/schemas/c64_program.schema.json +3 -3
  56. package/mcp/schemas/c64_select_backend.schema.json +2 -0
  57. package/mcp/schemas/c64_system.schema.json +41 -1
  58. package/mcp/server.json +1 -1
  59. package/mcp/tools.json +386 -44
  60. package/mcp.json +14 -2
  61. package/package.json +4 -2
  62. package/scripts/mockC64Server.mjs +171 -2
  63. package/scripts/prepare-release.mjs +13 -2
  64. package/scripts/update-readme.ts +18 -30
  65. package/server.json +3 -3
package/dist/c64Client.js CHANGED
@@ -10,14 +10,69 @@ import { createSocket } from "node:dgram";
10
10
  import axios from "axios";
11
11
  import { basicToPrg } from "./tools/translation/basicTokenizer.js";
12
12
  import { assemblyToPrg } from "./tools/translation/assembler.js";
13
- import { screenCodesToAscii } from "./petscii.js";
13
+ import { petsciiToAscii, screenCodesToAscii } from "./petscii.js";
14
14
  import { resolveAddressSymbol } from "./knowledge.js";
15
- import { createAllFacades, createFacade } from "./device.js";
15
+ import { createAllFacades, createFacade, } from "./device.js";
16
16
  import { Api } from "../generated/c64/index.js";
17
17
  import { createLoggingHttpClient } from "./loggingHttpClient.js";
18
18
  import { withDiagnosticSpan, writeDiagnosticEvent } from "./diagnostics.js";
19
19
  import { C64U_NTSC_AUDIO_SAMPLE_RATE, C64U_PAL_AUDIO_SAMPLE_RATE, collectCompleteVideoFrames, parseAudioPacket, parseVideoPacket, } from "./streamCapture.js";
20
20
  import { buildVicBitmapRegisters, resolveVicBitmapMemoryLayout, } from "./vicBitmap.js";
21
+ /** Raised when the KERNAL keyboard buffer did not drain before another chunk
22
+ * could safely be queued. Callers must report this as partial delivery. */
23
+ export class KeyboardQueueDrainTimeoutError extends Error {
24
+ constructor(delivered, requested, timeoutMs) {
25
+ super(`Keyboard queue did not drain after ${timeoutMs}ms; delivered ${delivered} of ${requested} byte(s). Resume the machine and retry the remaining input.`);
26
+ this.name = "KeyboardQueueDrainTimeoutError";
27
+ this.delivered = delivered;
28
+ this.requested = requested;
29
+ this.timeoutMs = timeoutMs;
30
+ }
31
+ }
32
+ function menuMatrixIncludes(matrix, expected) {
33
+ // Firmware supplies the character matrix followed by its colour matrix. The
34
+ // documented wire format is raw bytes, so only inspect the first 40×25
35
+ // character cells when that complete layout is present.
36
+ const characters = matrix.length >= 2000 ? matrix.subarray(0, 1000) : matrix;
37
+ const normalizedExpected = expected.replace(/\s+/g, " ").toUpperCase();
38
+ return [screenCodesToAscii(characters), petsciiToAscii(characters)]
39
+ .some((text) => text.replace(/\s+/g, " ").toUpperCase().includes(normalizedExpected));
40
+ }
41
+ function selectedMenuRow(matrix) {
42
+ // The firmware contract exposes a 40x25 character matrix followed by its
43
+ // colour matrix. A selected menu row is rendered with one uniform colour
44
+ // distinct from the normal rows. Reject malformed/ambiguous matrices.
45
+ if (matrix.length < 2000)
46
+ return null;
47
+ const colours = matrix.subarray(1000, 2000);
48
+ const rows = [];
49
+ for (let row = 0; row < 25; row += 1) {
50
+ const cells = colours.subarray(row * 40, row * 40 + 40);
51
+ if (cells.length === 40 && cells.every((value) => value === cells[0]))
52
+ rows.push(row);
53
+ }
54
+ if (rows.length === 0)
55
+ return null;
56
+ const normal = new Map();
57
+ for (const row of rows)
58
+ normal.set(colours[row * 40], (normal.get(colours[row * 40]) ?? 0) + 1);
59
+ const [colour, count] = [...normal.entries()].sort((a, b) => b[1] - a[1])[0] ?? [];
60
+ if (colour === undefined || count === undefined)
61
+ return null;
62
+ const selected = rows.filter((row) => colours[row * 40] !== colour);
63
+ return selected.length === 1 ? selected[0] : null;
64
+ }
65
+ function selectedMenuRowIncludes(matrix, expected) {
66
+ const row = selectedMenuRow(matrix);
67
+ if (row === null)
68
+ return false;
69
+ const text = [screenCodesToAscii(matrix.subarray(row * 40, row * 40 + 40)), petsciiToAscii(matrix.subarray(row * 40, row * 40 + 40))]
70
+ .map((value) => value.replace(/\s+/g, " ").toUpperCase());
71
+ return text.some((value) => value.includes(expected.replace(/\s+/g, " ").toUpperCase()));
72
+ }
73
+ function isMissingEndpoint(error) {
74
+ return axios.isAxiosError(error) && error.response?.status === 404;
75
+ }
21
76
  const TEXT_SCREEN_COLUMNS = 40;
22
77
  const TEXT_SCREEN_ROWS = 25;
23
78
  const TEXT_SCREEN_SIZE = TEXT_SCREEN_COLUMNS * TEXT_SCREEN_ROWS;
@@ -45,6 +100,7 @@ export class C64Client {
45
100
  this.warmupPromises = new Map();
46
101
  this.greetingDd00Cache = new Map();
47
102
  this.greetingDd00Warmups = new Map();
103
+ this.machineInputCapabilityProbes = new Map();
48
104
  this.c64uVideoCaptureSession = null;
49
105
  this.activeType = "c64u";
50
106
  this.baseUrl = baseUrl;
@@ -66,13 +122,15 @@ export class C64Client {
66
122
  preferredC64uNetworkPassword: options.networkPassword,
67
123
  });
68
124
  this.facadePromise = allFacadesPromise.then(({ primary }) => primary.facade);
69
- this.initPromise = allFacadesPromise.then(({ primary, secondary, secondaryType }) => {
125
+ this.initPromise = allFacadesPromise.then(({ primary, facades }) => {
70
126
  const primaryPromise = Promise.resolve(primary.facade);
71
127
  this.activeType = primary.selected;
72
128
  this.facadePromise = primaryPromise;
73
129
  this.allFacades.set(primary.selected, primaryPromise);
74
- if (secondary && secondaryType) {
75
- this.allFacades.set(secondaryType, Promise.resolve(secondary));
130
+ for (const [type, facade] of facades) {
131
+ if (type !== primary.selected) {
132
+ this.allFacades.set(type, Promise.resolve(facade));
133
+ }
76
134
  }
77
135
  });
78
136
  }
@@ -86,10 +144,48 @@ export class C64Client {
86
144
  async getBackendType() {
87
145
  return this.getActiveBackendType();
88
146
  }
147
+ /**
148
+ * Snapshot the facade currently active for this invocation. A multi-step
149
+ * flow (pause/read/write/resume, or repeated polling) must resolve the
150
+ * facade once and reuse that same instance for every step, so a concurrent
151
+ * `switchBackend` cannot retarget an operation already underway.
152
+ */
153
+ async pinFacade() {
154
+ return this.facadePromise;
155
+ }
89
156
  async getActiveBackendType() {
90
157
  await this.initPromise;
91
158
  return this.activeType;
92
159
  }
160
+ /**
161
+ * Discover native endpoint availability without changing machine state.
162
+ * `machine:input` has a safe GET probe; `machine:menu_screen` is only
163
+ * distinguishable from an inactive menu once a menu is visible.
164
+ */
165
+ async getNativeEndpointCapabilities() {
166
+ const facade = await this.facadePromise;
167
+ if (facade.type === "vice") {
168
+ return { machineInput: "unavailable", machineMenuScreen: "unavailable" };
169
+ }
170
+ if (facade.type === "u2") {
171
+ return { machineInput: "unavailable", machineMenuScreen: "unknown" };
172
+ }
173
+ let probe = this.machineInputCapabilityProbes.get(facade.type);
174
+ if (!probe) {
175
+ probe = facade.getInputState()
176
+ .then(() => "available")
177
+ .catch((error) => isMissingEndpoint(error) ? "unavailable" : "unknown");
178
+ this.machineInputCapabilityProbes.set(facade.type, probe);
179
+ // A transport error is not a capability result. Keep confirmed states,
180
+ // but make a later resource read retry rather than poisoning the cache.
181
+ void probe.then((result) => {
182
+ if (result === "unknown" && this.machineInputCapabilityProbes.get(facade.type) === probe) {
183
+ this.machineInputCapabilityProbes.delete(facade.type);
184
+ }
185
+ });
186
+ }
187
+ return { machineInput: await probe, machineMenuScreen: "unknown" };
188
+ }
93
189
  getAvailableBackends() {
94
190
  return Array.from(this.allFacades.keys());
95
191
  }
@@ -362,9 +458,37 @@ export class C64Client {
362
458
  };
363
459
  }
364
460
  }
461
+ /**
462
+ * Assemble, load, and execute a pure machine-code program. Loading a PRG
463
+ * with LOAD+RUN (the generic path used by {@link runPrg}) is only correct
464
+ * for tokenized BASIC text: RUN misinterprets raw 6510 opcodes as BASIC
465
+ * tokens, and setting BASIC's TXTTAB/VARTAB/ARYTAB/STREND to the code
466
+ * range corrupts the workspace. Instead this loads the assembled bytes
467
+ * without running them, then types `SYS <entry>` to transfer control
468
+ * directly to the first assembled instruction, whatever its address.
469
+ */
365
470
  async uploadAndRunAsm(program) {
366
- const prg = assemblyToPrg(program);
367
- return this.runPrg(prg);
471
+ try {
472
+ const prg = assemblyToPrg(program);
473
+ if (prg.length < 2) {
474
+ return { success: false, details: { message: "Assembled program produced no output" } };
475
+ }
476
+ const entryAddress = prg.readUInt16LE(0);
477
+ const facade = await this.facadePromise;
478
+ const loadResult = await facade.loadPrg(prg);
479
+ if (!loadResult.success) {
480
+ return { success: false, details: loadResult.details };
481
+ }
482
+ const sysCommand = Buffer.from(`SYS${entryAddress}\r`, "ascii");
483
+ const injectResult = await this.injectKeyboardQueue(sysCommand, { pinnedFacade: facade });
484
+ return {
485
+ success: true,
486
+ details: { load: loadResult.details, entryAddress, delivered: injectResult.delivered },
487
+ };
488
+ }
489
+ catch (error) {
490
+ return { success: false, details: this.normaliseError(error) };
491
+ }
368
492
  }
369
493
  async runPrg(prg) {
370
494
  try {
@@ -480,13 +604,13 @@ export class C64Client {
480
604
  return { success: false, details: this.normaliseError(error) };
481
605
  }
482
606
  }
483
- async readMemory(addressInput, lengthInput) {
607
+ async readMemory(addressInput, lengthInput, pinnedFacade) {
484
608
  try {
485
609
  const resolved = resolveAddressSymbol(addressInput);
486
610
  const address = resolved ?? this.parseNumeric(addressInput);
487
611
  const length = this.parseNumeric(lengthInput);
488
612
  validateMemoryRange(address, length);
489
- const rawBytes = await this.readMemoryRaw(address, length);
613
+ const rawBytes = await this.readMemoryRaw(address, length, pinnedFacade);
490
614
  const bytes = rawBytes.slice(0, length);
491
615
  return {
492
616
  success: true,
@@ -504,7 +628,7 @@ export class C64Client {
504
628
  };
505
629
  }
506
630
  }
507
- async writeMemory(addressInput, bytesInput) {
631
+ async writeMemory(addressInput, bytesInput, pinnedFacade) {
508
632
  try {
509
633
  const resolved = resolveAddressSymbol(addressInput);
510
634
  const address = resolved ?? this.parseNumeric(addressInput);
@@ -513,7 +637,7 @@ export class C64Client {
513
637
  // Prefer PUT with hex data for up to 128 bytes; fall back to POST binary for larger writes
514
638
  const addrStr = this.formatAddress(address);
515
639
  try {
516
- const facade = await this.facadePromise;
640
+ const facade = pinnedFacade ?? await this.facadePromise;
517
641
  await facade.writeMemory(address, dataBuffer);
518
642
  return {
519
643
  success: true,
@@ -557,10 +681,10 @@ export class C64Client {
557
681
  }
558
682
  }
559
683
  // --- SID/Music helpers ---
560
- async sidSetVolume(volume) {
684
+ async sidSetVolume(volume, pinnedFacade) {
561
685
  const clamped = Math.max(0, Math.min(15, Math.floor(volume)));
562
686
  const byte = Buffer.from([clamped]);
563
- return this.writeMemory("$D418", this.bytesToHex(byte));
687
+ return this.writeMemory("$D418", this.bytesToHex(byte), pinnedFacade);
564
688
  }
565
689
  async sidReset(hard = false) {
566
690
  try {
@@ -583,7 +707,7 @@ export class C64Client {
583
707
  return { success: false, details: this.normaliseError(error) };
584
708
  }
585
709
  }
586
- async sidNoteOn(options) {
710
+ async sidNoteOn(options, pinnedFacade) {
587
711
  const voice = options.voice ?? 1;
588
712
  if (voice < 1 || voice > 3) {
589
713
  return { success: false, details: { message: "Voice must be 1..3" } };
@@ -616,7 +740,7 @@ export class C64Client {
616
740
  const base = 0xd400 + (voice - 1) * 7;
617
741
  const bytes = Buffer.from([freqLo, freqHi, pwLo, pwHi, ctrl, ad, sr]);
618
742
  try {
619
- const facade = await this.facadePromise;
743
+ const facade = pinnedFacade ?? await this.facadePromise;
620
744
  await facade.writeMemory(base, bytes);
621
745
  return { success: true };
622
746
  }
@@ -624,13 +748,13 @@ export class C64Client {
624
748
  return { success: false, details: this.normaliseError(error) };
625
749
  }
626
750
  }
627
- async sidNoteOff(voice) {
751
+ async sidNoteOff(voice, pinnedFacade) {
628
752
  if (voice < 1 || voice > 3) {
629
753
  return { success: false, details: { message: "Voice must be 1..3" } };
630
754
  }
631
755
  const ctrlAddr = 0xd400 + (voice - 1) * 7 + 4;
632
756
  try {
633
- const facade = await this.facadePromise;
757
+ const facade = pinnedFacade ?? await this.facadePromise;
634
758
  await facade.writeMemory(ctrlAddr, Buffer.from([0x00]));
635
759
  return { success: true };
636
760
  }
@@ -650,26 +774,26 @@ export class C64Client {
650
774
  const facade = await this.facadePromise;
651
775
  return facade.info();
652
776
  }
653
- async pause() {
777
+ async pause(pinnedFacade) {
654
778
  try {
655
- if (await this.shouldUseC64uMockBypass()) {
779
+ if (!pinnedFacade && await this.shouldUseC64uMockBypass()) {
656
780
  const res = await this.api.v1.machinePauseUpdate(":pause");
657
781
  return { success: true, details: res.data };
658
782
  }
659
- const facade = await this.facadePromise;
783
+ const facade = pinnedFacade ?? await this.facadePromise;
660
784
  return await facade.pause();
661
785
  }
662
786
  catch (error) {
663
787
  return { success: false, details: this.normaliseError(error) };
664
788
  }
665
789
  }
666
- async resume() {
790
+ async resume(pinnedFacade) {
667
791
  try {
668
- if (await this.shouldUseC64uMockBypass()) {
792
+ if (!pinnedFacade && await this.shouldUseC64uMockBypass()) {
669
793
  const res = await this.api.v1.machineResumeUpdate(":resume");
670
794
  return { success: true, details: res.data };
671
795
  }
672
- const facade = await this.facadePromise;
796
+ const facade = pinnedFacade ?? await this.facadePromise;
673
797
  return await facade.resume();
674
798
  }
675
799
  catch (error) {
@@ -685,6 +809,95 @@ export class C64Client {
685
809
  return { success: false, details: this.normaliseError(error) };
686
810
  }
687
811
  }
812
+ /**
813
+ * Start from a clean machine state. C64U/U64 firmware exposes this only in
814
+ * the Tool Menu; U2-family firmware uses its equivalent REST reboot and
815
+ * VICE is restarted when it is managed by c64bridge.
816
+ */
817
+ async powerCycle() {
818
+ let menuOpened = false;
819
+ let cleanup;
820
+ let pinnedFacade;
821
+ try {
822
+ const facade = await this.facadePromise;
823
+ pinnedFacade = facade;
824
+ if (facade.type === "u2") {
825
+ const result = await facade.reboot();
826
+ return { ...result, details: { strategy: "reboot", result: result.details } };
827
+ }
828
+ if (facade.type === "vice") {
829
+ const result = await facade.powerCycle();
830
+ return { ...result, details: { strategy: "fresh_start", result: result.details } };
831
+ }
832
+ const endpoints = await this.getNativeEndpointCapabilities();
833
+ if (endpoints.machineInput !== "available") {
834
+ return {
835
+ success: false,
836
+ details: {
837
+ code: "native_input_unavailable",
838
+ message: "C64U/U64 Power Cycle requires machine:input and machine:menu_screen, which this firmware does not currently expose.",
839
+ fallback: "Use c64_system reboot if a firmware reboot is acceptable, or power cycle from the device itself.",
840
+ },
841
+ };
842
+ }
843
+ const menuResult = await facade.menuButton();
844
+ if (!menuResult.success) {
845
+ throw new Error("Unable to open the Ultimate menu for power cycling");
846
+ }
847
+ menuOpened = true;
848
+ const initial = await this.readVerifiedMenuScreen(facade, "open Ultimate menu");
849
+ await this.requirePowerCycleInput(facade, ["f1"]);
850
+ await this.readVerifiedMenuScreen(facade, "open Tool Menu", "TOOL MENU", initial);
851
+ await this.requirePowerCycleInput(facade, ["return"]);
852
+ let current = await this.readVerifiedMenuScreen(facade, "enter Tool Menu", "POWER CYCLE");
853
+ for (let index = 0; index < 4; index += 1) {
854
+ await this.requirePowerCycleInput(facade, ["cursor_up_down"]);
855
+ current = await this.readVerifiedMenuScreen(facade, `move to Power Cycle (${index + 1}/4)`, "POWER CYCLE", current);
856
+ }
857
+ if (!selectedMenuRowIncludes(current, "POWER CYCLE")) {
858
+ throw new Error("Unable to verify that POWER CYCLE is the selected Tool Menu item; final RETURN was not sent.");
859
+ }
860
+ await this.requirePowerCycleInput(facade, ["return"]);
861
+ return { success: true, details: { strategy: "tool_menu", verifiedMenu: "Tool Menu", selectedItem: "Power Cycle" } };
862
+ }
863
+ catch (error) {
864
+ if (menuOpened) {
865
+ try {
866
+ cleanup = await pinnedFacade?.menuButton() ?? { success: false, details: "No facade available for menu cleanup" };
867
+ }
868
+ catch (cleanupError) {
869
+ cleanup = { success: false, details: this.normaliseError(cleanupError) };
870
+ }
871
+ }
872
+ if (isMissingEndpoint(error)) {
873
+ return {
874
+ success: false,
875
+ details: {
876
+ code: "native_menu_screen_unavailable",
877
+ message: "C64U/U64 Power Cycle could not verify Tool Menu navigation because machine:menu_screen is unavailable.",
878
+ fallback: "Use c64_system reboot if a firmware reboot is acceptable, or power cycle from the device itself.",
879
+ },
880
+ };
881
+ }
882
+ return { success: false, details: { error: this.normaliseError(error), cleanup: cleanup ?? { success: false, details: "menu cleanup was not attempted" } } };
883
+ }
884
+ }
885
+ async requirePowerCycleInput(facade, inputs) {
886
+ await facade.sendInputEvents({ events: [{ kind: "keyboard", inputs, transition: "tap" }] });
887
+ }
888
+ async readVerifiedMenuScreen(facade, phase, requiredText, previous) {
889
+ const matrix = await facade.readMenuScreen();
890
+ if (matrix.length === 0) {
891
+ throw new Error(`Unable to ${phase}: machine:menu_screen returned an empty matrix`);
892
+ }
893
+ if (previous && Buffer.compare(Buffer.from(previous), Buffer.from(matrix)) === 0) {
894
+ throw new Error(`Unable to ${phase}: machine:menu_screen did not change after menu input`);
895
+ }
896
+ if (requiredText && !menuMatrixIncludes(matrix, requiredText)) {
897
+ throw new Error(`Unable to ${phase}: machine:menu_screen did not contain '${requiredText}'`);
898
+ }
899
+ return matrix;
900
+ }
688
901
  async menuButton() {
689
902
  try {
690
903
  if (await this.shouldUseC64uMockBypass()) {
@@ -698,6 +911,18 @@ export class C64Client {
698
911
  return { success: false, details: this.normaliseError(error) };
699
912
  }
700
913
  }
914
+ async readMenuScreen() {
915
+ const facade = await this.facadePromise;
916
+ return facade.readMenuScreen();
917
+ }
918
+ async getInputState() {
919
+ const facade = await this.facadePromise;
920
+ return facade.getInputState();
921
+ }
922
+ async sendInputEvents(batch) {
923
+ const facade = await this.facadePromise;
924
+ return facade.sendInputEvents(batch);
925
+ }
701
926
  async debugregRead() {
702
927
  try {
703
928
  if (await this.shouldUseC64uMockBypass()) {
@@ -1466,10 +1691,10 @@ export class C64Client {
1466
1691
  * raw binary bytes or JSON with a base64 payload.
1467
1692
  * Public to allow advanced polling and monitoring use cases.
1468
1693
  */
1469
- async readMemoryRaw(address, length) {
1694
+ async readMemoryRaw(address, length, pinnedFacade) {
1470
1695
  validateMemoryRange(address, length);
1471
1696
  try {
1472
- const facade = await this.facadePromise;
1697
+ const facade = pinnedFacade ?? await this.facadePromise;
1473
1698
  return await facade.readMemory(address, length);
1474
1699
  }
1475
1700
  catch (facadeError) {
@@ -1498,11 +1723,11 @@ export class C64Client {
1498
1723
  * Public so tools that need to drive cross-platform machine state (e.g.
1499
1724
  * keyboard-buffer injection) can avoid backend-specific paths.
1500
1725
  */
1501
- async writeMemoryRaw(address, bytes) {
1726
+ async writeMemoryRaw(address, bytes, pinnedFacade) {
1502
1727
  if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
1503
1728
  throw new Error("Address must be within 0x0000 - 0xFFFF");
1504
1729
  }
1505
- const facade = await this.facadePromise;
1730
+ const facade = pinnedFacade ?? await this.facadePromise;
1506
1731
  const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
1507
1732
  validateMemoryRange(address, buf.length);
1508
1733
  await facade.writeMemory(address, buf);
@@ -1526,32 +1751,39 @@ export class C64Client {
1526
1751
  ? bytes
1527
1752
  : Buffer.from(bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes));
1528
1753
  if (data.length === 0) {
1529
- return;
1754
+ return { delivered: 0 };
1530
1755
  }
1531
1756
  const chunkSize = Math.max(1, Math.min(10, options?.chunkSize ?? 10));
1532
1757
  const pollMs = Math.max(1, options?.drainPollMs ?? 8);
1533
1758
  const totalTimeoutMs = Math.max(50, options?.drainTimeoutMs ?? 2000);
1534
- const facade = await this.facadePromise;
1759
+ const facade = options?.pinnedFacade ?? await this.facadePromise;
1535
1760
  const KEYD = 0x0277; // KERNAL keyboard buffer base
1536
1761
  const NDX = 0x00C6; // pending byte count
1762
+ let delivered = 0;
1537
1763
  for (let offset = 0; offset < data.length; offset += chunkSize) {
1538
1764
  const slice = data.subarray(offset, Math.min(data.length, offset + chunkSize));
1539
1765
  // Write the bytes into the queue first so KERNAL never reads a stale
1540
1766
  // length+old-byte combination.
1541
1767
  await facade.writeMemory(KEYD, Buffer.from(slice));
1542
1768
  await facade.writeMemory(NDX, Buffer.from([slice.length]));
1543
- // Drain: wait for KERNAL to consume the queue. If the machine is paused
1544
- // or otherwise not running, give up after the timeout and continue —
1545
- // higher layers can decide whether that is a failure for their context.
1769
+ // Drain before another write. Overwriting a non-empty KERNAL queue loses
1770
+ // user input, so a timeout is an explicit partial-delivery failure.
1546
1771
  const drainStart = Date.now();
1772
+ let drained = false;
1547
1773
  while (Date.now() - drainStart < totalTimeoutMs) {
1548
1774
  const ndx = await facade.readMemory(NDX, 1);
1549
1775
  if ((ndx[0] ?? 0) === 0) {
1776
+ drained = true;
1550
1777
  break;
1551
1778
  }
1552
1779
  await new Promise((resolve) => setTimeout(resolve, pollMs));
1553
1780
  }
1781
+ delivered += slice.length;
1782
+ if (!drained) {
1783
+ throw new KeyboardQueueDrainTimeoutError(delivered, data.length, totalTimeoutMs);
1784
+ }
1554
1785
  }
1786
+ return { delivered };
1555
1787
  }
1556
1788
  async viceExitMonitor() {
1557
1789
  await this.withViceMonitor((client) => client.exitMonitor());
@@ -1563,6 +1795,9 @@ export class C64Client {
1563
1795
  const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
1564
1796
  await this.withViceMonitor((client) => client.memSet(address, buf));
1565
1797
  }
1798
+ async viceJoyportSet(port, activeLowMask) {
1799
+ await this.withViceMonitor((client) => client.joyportSet(port, activeLowMask));
1800
+ }
1566
1801
  async viceMemGet(address, length) {
1567
1802
  const end = (address + length - 1) & 0xffff;
1568
1803
  return this.withViceMonitor((client) => client.memGet(address, end));
@@ -1761,7 +1996,7 @@ export class C64Client {
1761
1996
  }
1762
1997
  hzToSidFrequency(hz, system = "PAL") {
1763
1998
  const phi2 = system === "PAL" ? 985248 : 1022727;
1764
- const value = Math.round((hz * 65536) / phi2);
1999
+ const value = Math.round((hz * 16777216) / phi2);
1765
2000
  // Clamp to 16-bit
1766
2001
  return Math.max(0, Math.min(0xffff, value));
1767
2002
  }
@@ -1942,7 +2177,7 @@ function buildSingleSpriteProgram(opts) {
1942
2177
  const source = lines.join("\n");
1943
2178
  return assemblyToPrg(source, { fileName: "sprite_gen.asm", loadAddress: 0x0801 });
1944
2179
  }
1945
- function buildPetsciiScreenBasic(opts) {
2180
+ export function buildPetsciiScreenBasic(opts) {
1946
2181
  const border = toByte(opts.borderColor ?? 6); // default blue-ish
1947
2182
  const bg = toByte(opts.backgroundColor ?? 0); // default black
1948
2183
  // Clear screen, set colours, print text starting at 1,1
@@ -1951,7 +2186,7 @@ function buildPetsciiScreenBasic(opts) {
1951
2186
  const program = [
1952
2187
  `10 POKE 53280,${border}:POKE 53281,${bg}:PRINT CHR$(147)`,
1953
2188
  `20 PRINT "${sanitized}"`,
1954
- `30 GETA$:IFA$<>""THENEND:REM wait for key then end`,
2189
+ `30 GETA$:IFA$=""THEN30:END:REM wait for key then end`,
1955
2190
  ].join("\n");
1956
2191
  return program;
1957
2192
  }
@@ -1977,11 +2212,11 @@ export function buildPrinterBasicProgram(opts) {
1977
2212
  ln += 10;
1978
2213
  continue;
1979
2214
  }
1980
- const chunks = chunkString(logical, 60);
2215
+ const chunks = chunkString(logical, 45);
1981
2216
  for (let i = 0; i < chunks.length; i += 1) {
1982
- const chunk = escapeBasicQuotes(chunks[i]);
2217
+ const chunk = basicStringExpression(chunks[i]);
1983
2218
  const tail = i < chunks.length - 1 ? ";" : ""; // avoid CR between chunks within the same logical line
1984
- lines.push(`${ln} PRINT#1,"${chunk}"${tail}`);
2219
+ lines.push(`${ln} PRINT#1,${chunk}${tail}`);
1985
2220
  ln += 10;
1986
2221
  }
1987
2222
  }
@@ -2005,9 +2240,10 @@ function chunkString(input, maxLen) {
2005
2240
  }
2006
2241
  return parts;
2007
2242
  }
2008
- function escapeBasicQuotes(input) {
2009
- // In Commodore BASIC, embed a double quote by doubling it
2010
- return input.replace(/"/g, '""');
2243
+ function basicStringExpression(input) {
2244
+ // BASIC V2 has no doubled-quote escaping. Splice literal quote characters
2245
+ // through CHR$(34), including leading/trailing/consecutive quote cases.
2246
+ return input.split('"').map((part) => `"${part}"`).join(";CHR$(34);");
2011
2247
  }
2012
2248
  export function buildCommodoreBitmapBasicProgram(opts) {
2013
2249
  const repeats = Math.max(1, Math.floor(opts.repeats ?? 1));
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
+ import os from "node:os";
3
4
  import { fileURLToPath } from "url";
4
5
  const DEFAULT_HOST = "c64u";
5
6
  const DEFAULT_PORT = 80;
@@ -8,16 +9,16 @@ const DEFAULT_CONFIG = {
8
9
  baseUrl: buildBaseUrl(DEFAULT_HOST, DEFAULT_PORT),
9
10
  c64_port: DEFAULT_PORT,
10
11
  vicePrewarm: false,
12
+ backendConfig: {},
11
13
  };
12
- let cachedConfig = null;
13
14
  export function loadConfig() {
14
- if (cachedConfig) {
15
- return cachedConfig;
16
- }
17
15
  const configPath = process.env.C64BRIDGE_CONFIG;
18
- const repoConfigPath = join(dirname(fileURLToPath(import.meta.url)), "..", ".c64bridge.json");
19
- const homeConfigPath = process.env.HOME ? `${process.env.HOME}/.c64bridge.json` : null;
20
- const candidatePaths = [configPath, repoConfigPath, homeConfigPath];
16
+ const cwdConfigPath = join(process.cwd(), ".c64bridge.json");
17
+ const homeConfigPath = join(os.homedir(), ".c64bridge.json");
18
+ // Legacy package-adjacent config is deliberately last: it is useful for
19
+ // source checkouts but must never override a project or user config.
20
+ const legacyPackageConfigPath = join(dirname(fileURLToPath(import.meta.url)), "..", ".c64bridge.json");
21
+ const candidatePaths = [configPath, cwdConfigPath, homeConfigPath, legacyPackageConfigPath];
21
22
  let rawConfig;
22
23
  for (const candidatePath of candidatePaths) {
23
24
  if (!candidatePath) {
@@ -29,21 +30,27 @@ export function loadConfig() {
29
30
  }
30
31
  catch (error) {
31
32
  if (error.code !== "ENOENT") {
32
- throw error;
33
+ // Deliberately omit file contents/credentials. Configuration mistakes
34
+ // should not prevent the MCP server from exposing a recoverable path.
35
+ console.error(`[c64bridge] ignoring configuration '${candidatePath}': ${error instanceof Error ? error.message : String(error)}`);
33
36
  }
34
37
  }
35
38
  }
36
39
  rawConfig ?? (rawConfig = {});
37
- // New schema: prefer c64u.{host,port,baseUrl}; keep legacy fields as fallback
40
+ // New schema: choose the configured Ultimate hardware profile; retain
41
+ // c64u and legacy fields as the default when C64_MODE is not set to u2.
38
42
  const c64u = rawConfig?.c64u;
43
+ const u2 = rawConfig?.u2;
44
+ const useU2 = process.env.C64_MODE?.trim().toLowerCase() === "u2";
45
+ const hardware = useU2 ? (u2 ?? c64u) : (c64u ?? u2);
39
46
  const vice = rawConfig?.vice;
40
- const parsedC64uHost = parseEndpoint(configuredString(c64u?.host));
41
- const parsedC64uHostname = parseEndpoint(configuredString(c64u?.hostname));
47
+ const parsedC64uHost = parseEndpoint(configuredString(hardware?.host));
48
+ const parsedC64uHostname = parseEndpoint(configuredString(hardware?.hostname));
42
49
  const parsedLegacyHost = parseEndpoint(configuredString(rawConfig?.c64_host));
43
50
  const parsedLegacyIp = parseEndpoint(configuredString(rawConfig?.c64_ip));
44
- const parsedEnvHost = parseEndpoint(configuredString(process.env.C64U_HOST));
51
+ const parsedEnvHost = parseEndpoint(configuredString(useU2 ? process.env.U2_HOST ?? process.env.C64U_HOST : process.env.C64U_HOST));
45
52
  const parsedBaseOverrides = [
46
- parseEndpoint(normaliseBaseUrl(c64u?.baseUrl)),
53
+ parseEndpoint(normaliseBaseUrl(hardware?.baseUrl)),
47
54
  parseEndpoint(normaliseBaseUrl(rawConfig?.baseUrl)),
48
55
  ];
49
56
  const hostCandidates = [
@@ -55,9 +62,9 @@ export function loadConfig() {
55
62
  ...parsedBaseOverrides.map((entry) => entry.hostname),
56
63
  ];
57
64
  const portCandidates = [
58
- configuredPort(process.env.C64U_PORT),
65
+ configuredPort(useU2 ? process.env.U2_PORT ?? process.env.C64U_PORT : process.env.C64U_PORT),
59
66
  parsedEnvHost.port,
60
- configuredPort(c64u?.port),
67
+ configuredPort(hardware?.port),
61
68
  parsedC64uHost.port,
62
69
  parsedC64uHostname.port,
63
70
  configuredPort(rawConfig?.c64_port),
@@ -74,10 +81,14 @@ export function loadConfig() {
74
81
  c64_host: hostWithPort,
75
82
  baseUrl,
76
83
  c64_port: port,
77
- networkPassword: firstDefined(configuredString(process.env.C64U_PASSWORD), configuredString(c64u?.networkPassword), configuredString(rawConfig?.networkPassword)),
84
+ networkPassword: firstDefined(configuredString(useU2 ? process.env.U2_PASSWORD ?? process.env.C64U_PASSWORD : process.env.C64U_PASSWORD), configuredString(hardware?.networkPassword), configuredString(rawConfig?.networkPassword)),
78
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
+ },
79
91
  };
80
- cachedConfig = config;
81
92
  return config;
82
93
  }
83
94
  function configuredString(value) {
@@ -162,5 +173,6 @@ function formatHost(host) {
162
173
  return host;
163
174
  }
164
175
  export function __resetConfigCacheForTests() {
165
- cachedConfig = null;
176
+ // Kept as a no-op compatibility hook: config is intentionally reloaded for
177
+ // each construction so startup and facade creation cannot disagree.
166
178
  }