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