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.
- package/AGENTS.md +19 -3
- package/README.md +190 -148
- package/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +277 -41
- package/dist/config.js +30 -18
- package/dist/device.js +191 -125
- package/dist/knowledge.js +1 -1
- package/dist/loggingHttpClient.js +15 -3
- package/dist/mcp-server.js +65 -16
- package/dist/platform.js +13 -2
- 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 +27 -6
- package/dist/tools/developer.js +6 -6
- package/dist/tools/graphics.js +4 -4
- package/dist/tools/input.js +136 -24
- package/dist/tools/machineControl.js +85 -6
- package/dist/tools/memory.js +21 -6
- package/dist/tools/meta/artifacts.js +20 -8
- package/dist/tools/meta/background.js +11 -1
- package/dist/tools/meta/index.js +1 -1
- package/dist/tools/pollValidator.js +19 -51
- package/dist/tools/printer.js +1 -0
- package/dist/tools/programRunners.js +5 -5
- package/dist/tools/rag.js +1 -1
- package/dist/tools/registry/batch.js +1 -1
- package/dist/tools/registry/config.js +5 -5
- package/dist/tools/registry/disk.js +2 -2
- package/dist/tools/registry/drive.js +2 -2
- package/dist/tools/registry/graphics.js +2 -2
- package/dist/tools/registry/memory.js +2 -2
- package/dist/tools/registry/platform.js +9 -5
- package/dist/tools/registry/printer.js +1 -0
- package/dist/tools/registry/program.js +5 -5
- package/dist/tools/registry/rag.js +1 -1
- package/dist/tools/registry/sound.js +4 -4
- package/dist/tools/registry/system.js +13 -3
- package/dist/tools/storage.js +1 -1
- package/dist/vice/mockServer.js +33 -0
- package/dist/vice/process.js +11 -1
- package/dist/vice/viceClient.js +35 -6
- package/doc/c64u/c64-openapi.yaml +1130 -774
- 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/doc/u2/u2-openapi.yaml +1192 -0
- package/generated/c64/index.js +142 -9
- package/generated/openapi/c64u/index.js +1117 -0
- package/generated/openapi/u2/index.js +929 -0
- package/mcp/schemas/c64_graphics.schema.json +3 -3
- package/mcp/schemas/c64_input.schema.json +249 -8
- package/mcp/schemas/c64_memory.schema.json +3 -3
- package/mcp/schemas/c64_program.schema.json +3 -3
- package/mcp/schemas/c64_select_backend.schema.json +2 -0
- package/mcp/schemas/c64_system.schema.json +41 -1
- package/mcp/server.json +1 -1
- package/mcp/tools.json +386 -44
- package/mcp.json +14 -2
- package/package.json +4 -2
- package/scripts/mockC64Server.mjs +171 -2
- package/scripts/prepare-release.mjs +13 -2
- package/scripts/update-readme.ts +18 -30
- 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,
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
1544
|
-
//
|
|
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 *
|
|
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
|
|
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,
|
|
2215
|
+
const chunks = chunkString(logical, 45);
|
|
1981
2216
|
for (let i = 0; i < chunks.length; i += 1) {
|
|
1982
|
-
const chunk =
|
|
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
|
|
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
|
|
2009
|
-
//
|
|
2010
|
-
|
|
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
|
|
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,21 +30,27 @@ 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
|
}
|
|
36
39
|
rawConfig ?? (rawConfig = {});
|
|
37
|
-
// New schema:
|
|
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(
|
|
41
|
-
const parsedC64uHostname = parseEndpoint(configuredString(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|