c64bridge 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +151 -37
- package/dist/config.js +18 -10
- package/dist/device.js +123 -87
- package/dist/knowledge.js +1 -1
- package/dist/loggingHttpClient.js +15 -3
- package/dist/mcp-server.js +56 -15
- package/dist/rag/indexer.js +11 -8
- package/dist/rag/init.js +11 -8
- package/dist/sidwaveCompiler.js +2 -2
- package/dist/tools/audio.js +26 -5
- package/dist/tools/input.js +9 -14
- package/dist/tools/memory.js +20 -5
- package/dist/tools/meta/artifacts.js +20 -8
- package/dist/tools/meta/background.js +11 -1
- package/dist/tools/pollValidator.js +19 -51
- package/dist/tools/printer.js +1 -0
- package/dist/tools/registry/platform.js +2 -0
- package/dist/tools/registry/printer.js +1 -0
- package/dist/vice/mockServer.js +33 -0
- package/dist/vice/process.js +11 -1
- package/dist/vice/viceClient.js +35 -6
- package/doc/reviews/hardening/01-fable/prompt-handover.md +125 -0
- package/doc/reviews/hardening/01-fable/prompt.md +98 -0
- package/doc/reviews/hardening/01-fable/review-prompt.md +468 -0
- package/doc/reviews/hardening/01-fable/review.md +1234 -0
- package/mcp/schemas/c64_input.schema.json +5 -5
- package/mcp/server.json +1 -1
- package/mcp/tools.json +7 -6
- package/mcp.json +1 -1
- package/package.json +5 -5
- package/scripts/mockC64Server.mjs +131 -2
- package/server.json +2 -2
package/dist/c64Client.js
CHANGED
|
@@ -18,6 +18,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
|
+
}
|
|
21
32
|
function menuMatrixIncludes(matrix, expected) {
|
|
22
33
|
// Firmware supplies the character matrix followed by its colour matrix. The
|
|
23
34
|
// documented wire format is raw bytes, so only inspect the first 40×25
|
|
@@ -27,6 +38,38 @@ function menuMatrixIncludes(matrix, expected) {
|
|
|
27
38
|
return [screenCodesToAscii(characters), petsciiToAscii(characters)]
|
|
28
39
|
.some((text) => text.replace(/\s+/g, " ").toUpperCase().includes(normalizedExpected));
|
|
29
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
|
+
}
|
|
30
73
|
function isMissingEndpoint(error) {
|
|
31
74
|
return axios.isAxiosError(error) && error.response?.status === 404;
|
|
32
75
|
}
|
|
@@ -101,6 +144,15 @@ export class C64Client {
|
|
|
101
144
|
async getBackendType() {
|
|
102
145
|
return this.getActiveBackendType();
|
|
103
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
|
+
}
|
|
104
156
|
async getActiveBackendType() {
|
|
105
157
|
await this.initPromise;
|
|
106
158
|
return this.activeType;
|
|
@@ -124,6 +176,13 @@ export class C64Client {
|
|
|
124
176
|
.then(() => "available")
|
|
125
177
|
.catch((error) => isMissingEndpoint(error) ? "unavailable" : "unknown");
|
|
126
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
|
+
});
|
|
127
186
|
}
|
|
128
187
|
return { machineInput: await probe, machineMenuScreen: "unknown" };
|
|
129
188
|
}
|
|
@@ -399,9 +458,37 @@ export class C64Client {
|
|
|
399
458
|
};
|
|
400
459
|
}
|
|
401
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
|
+
*/
|
|
402
470
|
async uploadAndRunAsm(program) {
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
}
|
|
405
492
|
}
|
|
406
493
|
async runPrg(prg) {
|
|
407
494
|
try {
|
|
@@ -517,13 +604,13 @@ export class C64Client {
|
|
|
517
604
|
return { success: false, details: this.normaliseError(error) };
|
|
518
605
|
}
|
|
519
606
|
}
|
|
520
|
-
async readMemory(addressInput, lengthInput) {
|
|
607
|
+
async readMemory(addressInput, lengthInput, pinnedFacade) {
|
|
521
608
|
try {
|
|
522
609
|
const resolved = resolveAddressSymbol(addressInput);
|
|
523
610
|
const address = resolved ?? this.parseNumeric(addressInput);
|
|
524
611
|
const length = this.parseNumeric(lengthInput);
|
|
525
612
|
validateMemoryRange(address, length);
|
|
526
|
-
const rawBytes = await this.readMemoryRaw(address, length);
|
|
613
|
+
const rawBytes = await this.readMemoryRaw(address, length, pinnedFacade);
|
|
527
614
|
const bytes = rawBytes.slice(0, length);
|
|
528
615
|
return {
|
|
529
616
|
success: true,
|
|
@@ -541,7 +628,7 @@ export class C64Client {
|
|
|
541
628
|
};
|
|
542
629
|
}
|
|
543
630
|
}
|
|
544
|
-
async writeMemory(addressInput, bytesInput) {
|
|
631
|
+
async writeMemory(addressInput, bytesInput, pinnedFacade) {
|
|
545
632
|
try {
|
|
546
633
|
const resolved = resolveAddressSymbol(addressInput);
|
|
547
634
|
const address = resolved ?? this.parseNumeric(addressInput);
|
|
@@ -550,7 +637,7 @@ export class C64Client {
|
|
|
550
637
|
// Prefer PUT with hex data for up to 128 bytes; fall back to POST binary for larger writes
|
|
551
638
|
const addrStr = this.formatAddress(address);
|
|
552
639
|
try {
|
|
553
|
-
const facade = await this.facadePromise;
|
|
640
|
+
const facade = pinnedFacade ?? await this.facadePromise;
|
|
554
641
|
await facade.writeMemory(address, dataBuffer);
|
|
555
642
|
return {
|
|
556
643
|
success: true,
|
|
@@ -594,10 +681,10 @@ export class C64Client {
|
|
|
594
681
|
}
|
|
595
682
|
}
|
|
596
683
|
// --- SID/Music helpers ---
|
|
597
|
-
async sidSetVolume(volume) {
|
|
684
|
+
async sidSetVolume(volume, pinnedFacade) {
|
|
598
685
|
const clamped = Math.max(0, Math.min(15, Math.floor(volume)));
|
|
599
686
|
const byte = Buffer.from([clamped]);
|
|
600
|
-
return this.writeMemory("$D418", this.bytesToHex(byte));
|
|
687
|
+
return this.writeMemory("$D418", this.bytesToHex(byte), pinnedFacade);
|
|
601
688
|
}
|
|
602
689
|
async sidReset(hard = false) {
|
|
603
690
|
try {
|
|
@@ -620,7 +707,7 @@ export class C64Client {
|
|
|
620
707
|
return { success: false, details: this.normaliseError(error) };
|
|
621
708
|
}
|
|
622
709
|
}
|
|
623
|
-
async sidNoteOn(options) {
|
|
710
|
+
async sidNoteOn(options, pinnedFacade) {
|
|
624
711
|
const voice = options.voice ?? 1;
|
|
625
712
|
if (voice < 1 || voice > 3) {
|
|
626
713
|
return { success: false, details: { message: "Voice must be 1..3" } };
|
|
@@ -653,7 +740,7 @@ export class C64Client {
|
|
|
653
740
|
const base = 0xd400 + (voice - 1) * 7;
|
|
654
741
|
const bytes = Buffer.from([freqLo, freqHi, pwLo, pwHi, ctrl, ad, sr]);
|
|
655
742
|
try {
|
|
656
|
-
const facade = await this.facadePromise;
|
|
743
|
+
const facade = pinnedFacade ?? await this.facadePromise;
|
|
657
744
|
await facade.writeMemory(base, bytes);
|
|
658
745
|
return { success: true };
|
|
659
746
|
}
|
|
@@ -661,13 +748,13 @@ export class C64Client {
|
|
|
661
748
|
return { success: false, details: this.normaliseError(error) };
|
|
662
749
|
}
|
|
663
750
|
}
|
|
664
|
-
async sidNoteOff(voice) {
|
|
751
|
+
async sidNoteOff(voice, pinnedFacade) {
|
|
665
752
|
if (voice < 1 || voice > 3) {
|
|
666
753
|
return { success: false, details: { message: "Voice must be 1..3" } };
|
|
667
754
|
}
|
|
668
755
|
const ctrlAddr = 0xd400 + (voice - 1) * 7 + 4;
|
|
669
756
|
try {
|
|
670
|
-
const facade = await this.facadePromise;
|
|
757
|
+
const facade = pinnedFacade ?? await this.facadePromise;
|
|
671
758
|
await facade.writeMemory(ctrlAddr, Buffer.from([0x00]));
|
|
672
759
|
return { success: true };
|
|
673
760
|
}
|
|
@@ -687,26 +774,26 @@ export class C64Client {
|
|
|
687
774
|
const facade = await this.facadePromise;
|
|
688
775
|
return facade.info();
|
|
689
776
|
}
|
|
690
|
-
async pause() {
|
|
777
|
+
async pause(pinnedFacade) {
|
|
691
778
|
try {
|
|
692
|
-
if (await this.shouldUseC64uMockBypass()) {
|
|
779
|
+
if (!pinnedFacade && await this.shouldUseC64uMockBypass()) {
|
|
693
780
|
const res = await this.api.v1.machinePauseUpdate(":pause");
|
|
694
781
|
return { success: true, details: res.data };
|
|
695
782
|
}
|
|
696
|
-
const facade = await this.facadePromise;
|
|
783
|
+
const facade = pinnedFacade ?? await this.facadePromise;
|
|
697
784
|
return await facade.pause();
|
|
698
785
|
}
|
|
699
786
|
catch (error) {
|
|
700
787
|
return { success: false, details: this.normaliseError(error) };
|
|
701
788
|
}
|
|
702
789
|
}
|
|
703
|
-
async resume() {
|
|
790
|
+
async resume(pinnedFacade) {
|
|
704
791
|
try {
|
|
705
|
-
if (await this.shouldUseC64uMockBypass()) {
|
|
792
|
+
if (!pinnedFacade && await this.shouldUseC64uMockBypass()) {
|
|
706
793
|
const res = await this.api.v1.machineResumeUpdate(":resume");
|
|
707
794
|
return { success: true, details: res.data };
|
|
708
795
|
}
|
|
709
|
-
const facade = await this.facadePromise;
|
|
796
|
+
const facade = pinnedFacade ?? await this.facadePromise;
|
|
710
797
|
return await facade.resume();
|
|
711
798
|
}
|
|
712
799
|
catch (error) {
|
|
@@ -728,8 +815,12 @@ export class C64Client {
|
|
|
728
815
|
* VICE is restarted when it is managed by c64bridge.
|
|
729
816
|
*/
|
|
730
817
|
async powerCycle() {
|
|
818
|
+
let menuOpened = false;
|
|
819
|
+
let cleanup;
|
|
820
|
+
let pinnedFacade;
|
|
731
821
|
try {
|
|
732
822
|
const facade = await this.facadePromise;
|
|
823
|
+
pinnedFacade = facade;
|
|
733
824
|
if (facade.type === "u2") {
|
|
734
825
|
const result = await facade.reboot();
|
|
735
826
|
return { ...result, details: { strategy: "reboot", result: result.details } };
|
|
@@ -753,6 +844,7 @@ export class C64Client {
|
|
|
753
844
|
if (!menuResult.success) {
|
|
754
845
|
throw new Error("Unable to open the Ultimate menu for power cycling");
|
|
755
846
|
}
|
|
847
|
+
menuOpened = true;
|
|
756
848
|
const initial = await this.readVerifiedMenuScreen(facade, "open Ultimate menu");
|
|
757
849
|
await this.requirePowerCycleInput(facade, ["f1"]);
|
|
758
850
|
await this.readVerifiedMenuScreen(facade, "open Tool Menu", "TOOL MENU", initial);
|
|
@@ -762,10 +854,21 @@ export class C64Client {
|
|
|
762
854
|
await this.requirePowerCycleInput(facade, ["cursor_up_down"]);
|
|
763
855
|
current = await this.readVerifiedMenuScreen(facade, `move to Power Cycle (${index + 1}/4)`, "POWER CYCLE", current);
|
|
764
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
|
+
}
|
|
765
860
|
await this.requirePowerCycleInput(facade, ["return"]);
|
|
766
861
|
return { success: true, details: { strategy: "tool_menu", verifiedMenu: "Tool Menu", selectedItem: "Power Cycle" } };
|
|
767
862
|
}
|
|
768
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
|
+
}
|
|
769
872
|
if (isMissingEndpoint(error)) {
|
|
770
873
|
return {
|
|
771
874
|
success: false,
|
|
@@ -776,7 +879,7 @@ export class C64Client {
|
|
|
776
879
|
},
|
|
777
880
|
};
|
|
778
881
|
}
|
|
779
|
-
return { success: false, details: this.normaliseError(error) };
|
|
882
|
+
return { success: false, details: { error: this.normaliseError(error), cleanup: cleanup ?? { success: false, details: "menu cleanup was not attempted" } } };
|
|
780
883
|
}
|
|
781
884
|
}
|
|
782
885
|
async requirePowerCycleInput(facade, inputs) {
|
|
@@ -1588,10 +1691,10 @@ export class C64Client {
|
|
|
1588
1691
|
* raw binary bytes or JSON with a base64 payload.
|
|
1589
1692
|
* Public to allow advanced polling and monitoring use cases.
|
|
1590
1693
|
*/
|
|
1591
|
-
async readMemoryRaw(address, length) {
|
|
1694
|
+
async readMemoryRaw(address, length, pinnedFacade) {
|
|
1592
1695
|
validateMemoryRange(address, length);
|
|
1593
1696
|
try {
|
|
1594
|
-
const facade = await this.facadePromise;
|
|
1697
|
+
const facade = pinnedFacade ?? await this.facadePromise;
|
|
1595
1698
|
return await facade.readMemory(address, length);
|
|
1596
1699
|
}
|
|
1597
1700
|
catch (facadeError) {
|
|
@@ -1620,11 +1723,11 @@ export class C64Client {
|
|
|
1620
1723
|
* Public so tools that need to drive cross-platform machine state (e.g.
|
|
1621
1724
|
* keyboard-buffer injection) can avoid backend-specific paths.
|
|
1622
1725
|
*/
|
|
1623
|
-
async writeMemoryRaw(address, bytes) {
|
|
1726
|
+
async writeMemoryRaw(address, bytes, pinnedFacade) {
|
|
1624
1727
|
if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
|
|
1625
1728
|
throw new Error("Address must be within 0x0000 - 0xFFFF");
|
|
1626
1729
|
}
|
|
1627
|
-
const facade = await this.facadePromise;
|
|
1730
|
+
const facade = pinnedFacade ?? await this.facadePromise;
|
|
1628
1731
|
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
1629
1732
|
validateMemoryRange(address, buf.length);
|
|
1630
1733
|
await facade.writeMemory(address, buf);
|
|
@@ -1648,32 +1751,39 @@ export class C64Client {
|
|
|
1648
1751
|
? bytes
|
|
1649
1752
|
: Buffer.from(bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes));
|
|
1650
1753
|
if (data.length === 0) {
|
|
1651
|
-
return;
|
|
1754
|
+
return { delivered: 0 };
|
|
1652
1755
|
}
|
|
1653
1756
|
const chunkSize = Math.max(1, Math.min(10, options?.chunkSize ?? 10));
|
|
1654
1757
|
const pollMs = Math.max(1, options?.drainPollMs ?? 8);
|
|
1655
1758
|
const totalTimeoutMs = Math.max(50, options?.drainTimeoutMs ?? 2000);
|
|
1656
|
-
const facade = await this.facadePromise;
|
|
1759
|
+
const facade = options?.pinnedFacade ?? await this.facadePromise;
|
|
1657
1760
|
const KEYD = 0x0277; // KERNAL keyboard buffer base
|
|
1658
1761
|
const NDX = 0x00C6; // pending byte count
|
|
1762
|
+
let delivered = 0;
|
|
1659
1763
|
for (let offset = 0; offset < data.length; offset += chunkSize) {
|
|
1660
1764
|
const slice = data.subarray(offset, Math.min(data.length, offset + chunkSize));
|
|
1661
1765
|
// Write the bytes into the queue first so KERNAL never reads a stale
|
|
1662
1766
|
// length+old-byte combination.
|
|
1663
1767
|
await facade.writeMemory(KEYD, Buffer.from(slice));
|
|
1664
1768
|
await facade.writeMemory(NDX, Buffer.from([slice.length]));
|
|
1665
|
-
// Drain
|
|
1666
|
-
//
|
|
1667
|
-
// 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.
|
|
1668
1771
|
const drainStart = Date.now();
|
|
1772
|
+
let drained = false;
|
|
1669
1773
|
while (Date.now() - drainStart < totalTimeoutMs) {
|
|
1670
1774
|
const ndx = await facade.readMemory(NDX, 1);
|
|
1671
1775
|
if ((ndx[0] ?? 0) === 0) {
|
|
1776
|
+
drained = true;
|
|
1672
1777
|
break;
|
|
1673
1778
|
}
|
|
1674
1779
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
1675
1780
|
}
|
|
1781
|
+
delivered += slice.length;
|
|
1782
|
+
if (!drained) {
|
|
1783
|
+
throw new KeyboardQueueDrainTimeoutError(delivered, data.length, totalTimeoutMs);
|
|
1784
|
+
}
|
|
1676
1785
|
}
|
|
1786
|
+
return { delivered };
|
|
1677
1787
|
}
|
|
1678
1788
|
async viceExitMonitor() {
|
|
1679
1789
|
await this.withViceMonitor((client) => client.exitMonitor());
|
|
@@ -1685,6 +1795,9 @@ export class C64Client {
|
|
|
1685
1795
|
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
1686
1796
|
await this.withViceMonitor((client) => client.memSet(address, buf));
|
|
1687
1797
|
}
|
|
1798
|
+
async viceJoyportSet(port, activeLowMask) {
|
|
1799
|
+
await this.withViceMonitor((client) => client.joyportSet(port, activeLowMask));
|
|
1800
|
+
}
|
|
1688
1801
|
async viceMemGet(address, length) {
|
|
1689
1802
|
const end = (address + length - 1) & 0xffff;
|
|
1690
1803
|
return this.withViceMonitor((client) => client.memGet(address, end));
|
|
@@ -1883,7 +1996,7 @@ export class C64Client {
|
|
|
1883
1996
|
}
|
|
1884
1997
|
hzToSidFrequency(hz, system = "PAL") {
|
|
1885
1998
|
const phi2 = system === "PAL" ? 985248 : 1022727;
|
|
1886
|
-
const value = Math.round((hz *
|
|
1999
|
+
const value = Math.round((hz * 16777216) / phi2);
|
|
1887
2000
|
// Clamp to 16-bit
|
|
1888
2001
|
return Math.max(0, Math.min(0xffff, value));
|
|
1889
2002
|
}
|
|
@@ -2064,7 +2177,7 @@ function buildSingleSpriteProgram(opts) {
|
|
|
2064
2177
|
const source = lines.join("\n");
|
|
2065
2178
|
return assemblyToPrg(source, { fileName: "sprite_gen.asm", loadAddress: 0x0801 });
|
|
2066
2179
|
}
|
|
2067
|
-
function buildPetsciiScreenBasic(opts) {
|
|
2180
|
+
export function buildPetsciiScreenBasic(opts) {
|
|
2068
2181
|
const border = toByte(opts.borderColor ?? 6); // default blue-ish
|
|
2069
2182
|
const bg = toByte(opts.backgroundColor ?? 0); // default black
|
|
2070
2183
|
// Clear screen, set colours, print text starting at 1,1
|
|
@@ -2073,7 +2186,7 @@ function buildPetsciiScreenBasic(opts) {
|
|
|
2073
2186
|
const program = [
|
|
2074
2187
|
`10 POKE 53280,${border}:POKE 53281,${bg}:PRINT CHR$(147)`,
|
|
2075
2188
|
`20 PRINT "${sanitized}"`,
|
|
2076
|
-
`30 GETA$:IFA
|
|
2189
|
+
`30 GETA$:IFA$=""THEN30:END:REM wait for key then end`,
|
|
2077
2190
|
].join("\n");
|
|
2078
2191
|
return program;
|
|
2079
2192
|
}
|
|
@@ -2099,11 +2212,11 @@ export function buildPrinterBasicProgram(opts) {
|
|
|
2099
2212
|
ln += 10;
|
|
2100
2213
|
continue;
|
|
2101
2214
|
}
|
|
2102
|
-
const chunks = chunkString(logical,
|
|
2215
|
+
const chunks = chunkString(logical, 45);
|
|
2103
2216
|
for (let i = 0; i < chunks.length; i += 1) {
|
|
2104
|
-
const chunk =
|
|
2217
|
+
const chunk = basicStringExpression(chunks[i]);
|
|
2105
2218
|
const tail = i < chunks.length - 1 ? ";" : ""; // avoid CR between chunks within the same logical line
|
|
2106
|
-
lines.push(`${ln} PRINT#1
|
|
2219
|
+
lines.push(`${ln} PRINT#1,${chunk}${tail}`);
|
|
2107
2220
|
ln += 10;
|
|
2108
2221
|
}
|
|
2109
2222
|
}
|
|
@@ -2127,9 +2240,10 @@ function chunkString(input, maxLen) {
|
|
|
2127
2240
|
}
|
|
2128
2241
|
return parts;
|
|
2129
2242
|
}
|
|
2130
|
-
function
|
|
2131
|
-
//
|
|
2132
|
-
|
|
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);");
|
|
2133
2247
|
}
|
|
2134
2248
|
export function buildCommodoreBitmapBasicProgram(opts) {
|
|
2135
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
|
|
19
|
-
const homeConfigPath =
|
|
20
|
-
|
|
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,7 +30,9 @@ export function loadConfig() {
|
|
|
29
30
|
}
|
|
30
31
|
catch (error) {
|
|
31
32
|
if (error.code !== "ENOENT") {
|
|
32
|
-
|
|
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
|
}
|
|
@@ -80,8 +83,12 @@ export function loadConfig() {
|
|
|
80
83
|
c64_port: port,
|
|
81
84
|
networkPassword: firstDefined(configuredString(useU2 ? process.env.U2_PASSWORD ?? process.env.C64U_PASSWORD : process.env.C64U_PASSWORD), configuredString(hardware?.networkPassword), configuredString(rawConfig?.networkPassword)),
|
|
82
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
|
+
},
|
|
83
91
|
};
|
|
84
|
-
cachedConfig = config;
|
|
85
92
|
return config;
|
|
86
93
|
}
|
|
87
94
|
function configuredString(value) {
|
|
@@ -166,5 +173,6 @@ function formatHost(host) {
|
|
|
166
173
|
return host;
|
|
167
174
|
}
|
|
168
175
|
export function __resetConfigCacheForTests() {
|
|
169
|
-
|
|
176
|
+
// Kept as a no-op compatibility hook: config is intentionally reloaded for
|
|
177
|
+
// each construction so startup and facade creation cannot disagree.
|
|
170
178
|
}
|