c64bridge 1.0.3 → 1.0.5
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 +12 -12
- 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/dist/.tsbuildinfo +1 -1
- package/dist/c64Client.js +68 -61
- package/dist/config.js +75 -38
- package/dist/device.js +154 -120
- package/dist/knowledge.js +2 -1
- package/dist/petscii.js +18 -15
- package/dist/petsciiArt.js +40 -14
- package/dist/rag/indexer.js +2 -1
- package/dist/sidwave.js +1 -1
- package/dist/sidwaveCompiler.js +3 -9
- package/dist/tools/audio.js +34 -8
- package/dist/tools/debug.js +9 -9
- package/dist/tools/errors.js +12 -1
- package/dist/tools/hex.js +9 -0
- package/dist/tools/input.js +13 -7
- package/dist/tools/memory.js +5 -4
- package/dist/tools/meta/artifacts.js +8 -3
- package/dist/tools/meta/audio.js +25 -28
- package/dist/tools/meta/background.js +6 -1
- package/dist/tools/meta/config.js +12 -4
- package/dist/tools/meta/configInventory.js +4 -1
- package/dist/tools/meta/filesystem.js +37 -10
- package/dist/tools/meta/graphics.js +10 -15
- package/dist/tools/meta/memory.js +12 -18
- package/dist/tools/meta/program.js +44 -16
- package/dist/tools/pollValidator.js +30 -2
- package/dist/tools/programRunners.js +5 -4
- package/dist/tools/registry/batch.js +4 -2
- package/dist/tools/registry/config.js +1 -1
- package/dist/tools/registry/disk.js +5 -0
- package/dist/tools/registry/extract.js +3 -0
- package/dist/tools/registry/graphics.js +1 -0
- package/dist/tools/registry/program.js +1 -1
- package/dist/tools/registry/sound.js +3 -3
- package/dist/tools/registry/system.js +3 -3
- package/dist/tools/storage.js +2 -2
- package/dist/tools/translation/assembler.js +150 -47
- package/dist/tools/translation/basicTokenizer.js +39 -9
- package/dist/tools/types.js +11 -4
- package/dist/vicBitmap.js +16 -0
- package/dist/vice/mockServer.js +7 -3
- package/dist/vice/viceClient.js +16 -6
- package/mcp/schemas/c64_config.schema.json +2 -2
- package/mcp/schemas/c64_program.schema.json +10 -18
- package/mcp/schemas/c64_sound.schema.json +8 -6
- package/mcp/schemas/c64_system.schema.json +9 -7
- package/mcp/server.json +1 -1
- package/mcp/tools.json +58 -43
- package/mcp.json +1 -1
- package/package.json +1 -1
- package/scripts/mockC64Server.mjs +98 -19
- package/server.json +2 -2
|
@@ -31,6 +31,41 @@ var AddressingMode;
|
|
|
31
31
|
AddressingMode["Relative"] = "relative";
|
|
32
32
|
})(AddressingMode || (AddressingMode = {}));
|
|
33
33
|
const DEFAULT_LOAD_ADDRESS = 0x0801;
|
|
34
|
+
const BASIC_START = 0x0801;
|
|
35
|
+
// "10 SYS0nnnn": link $080C, line 10, SYS token, five digits, end of line, end
|
|
36
|
+
// of program. The digits are the first instruction's address; a fixed width
|
|
37
|
+
// keeps the stub, and so every address after it, the same in both passes.
|
|
38
|
+
function basicSysStub(target) {
|
|
39
|
+
const digits = String(target & 0xffff).padStart(5, "0");
|
|
40
|
+
return [0x0c, 0x08, 0x0a, 0x00, 0x9e, ...[...digits].map((digit) => digit.charCodeAt(0)), 0x00, 0x00, 0x00];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Address to SYS to start a PRG: the target of a leading "SYS <n>" BASIC line
|
|
44
|
+
* when the PRG loads at $0801, otherwise the load address.
|
|
45
|
+
*/
|
|
46
|
+
export function prgEntryAddress(prg) {
|
|
47
|
+
const loadAddress = (prg[0] ?? 0) | ((prg[1] ?? 0) << 8);
|
|
48
|
+
if (loadAddress !== BASIC_START || prg.length < 8) {
|
|
49
|
+
return loadAddress;
|
|
50
|
+
}
|
|
51
|
+
// Skip the link pointer and line number, then expect the SYS token and digits.
|
|
52
|
+
let index = 6;
|
|
53
|
+
while (prg[index] === 0x20)
|
|
54
|
+
index += 1;
|
|
55
|
+
if (prg[index] !== 0x9e) {
|
|
56
|
+
return loadAddress;
|
|
57
|
+
}
|
|
58
|
+
index += 1;
|
|
59
|
+
while (prg[index] === 0x20 || prg[index] === 0x28)
|
|
60
|
+
index += 1;
|
|
61
|
+
let digits = "";
|
|
62
|
+
while (index < prg.length && prg[index] >= 0x30 && prg[index] <= 0x39) {
|
|
63
|
+
digits += String.fromCharCode(prg[index]);
|
|
64
|
+
index += 1;
|
|
65
|
+
}
|
|
66
|
+
const target = Number.parseInt(digits, 10);
|
|
67
|
+
return digits.length > 0 && target <= 0xffff ? target : loadAddress;
|
|
68
|
+
}
|
|
34
69
|
const MAX_ADDRESS = 0xffff;
|
|
35
70
|
const SINGLE_CHAR_TOKENS = new Set([",", ":", "#", "(", ")", "+", "-", "*", "/", "%", "&", "|", "^", "<", ">", "=", "!", "~"]);
|
|
36
71
|
const MULTI_CHAR_TOKENS = new Set([
|
|
@@ -280,6 +315,13 @@ function tokenize(line) {
|
|
|
280
315
|
}
|
|
281
316
|
if (isDigit(char) || char === "$") {
|
|
282
317
|
const { value, nextIndex } = parseNumberToken(line, index);
|
|
318
|
+
const digits = line.slice(index, nextIndex);
|
|
319
|
+
const afterPercent = tokens[tokens.length - 1]?.kind === "punct" && tokens[tokens.length - 1]?.value === "%" && line[index - 1] === "%";
|
|
320
|
+
if (afterPercent && /^[01]+$/.test(digits)) {
|
|
321
|
+
tokens.push({ kind: "number", value, binaryValue: parseInt(digits, 2) });
|
|
322
|
+
index = nextIndex;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
283
325
|
tokens.push({ kind: "number", value });
|
|
284
326
|
index = nextIndex;
|
|
285
327
|
continue;
|
|
@@ -466,10 +508,23 @@ function parseUnaryExpression(stream, terminators) {
|
|
|
466
508
|
if (!token) {
|
|
467
509
|
throw new Error("Unexpected end of expression");
|
|
468
510
|
}
|
|
469
|
-
|
|
511
|
+
// In operand position, "<" and ">" are byte selectors and "*" is the current
|
|
512
|
+
// PC; parseBinaryExpression consumes them as binary operators elsewhere.
|
|
513
|
+
if (token.kind === "punct" &&
|
|
514
|
+
(token.value === "+" || token.value === "-" || token.value === "!" || token.value === "~" || token.value === "<" || token.value === ">")) {
|
|
470
515
|
const operand = parseUnaryExpression(stream, terminators);
|
|
471
516
|
return { type: "unary", op: token.value, operand };
|
|
472
517
|
}
|
|
518
|
+
if (token.kind === "punct" && token.value === "*") {
|
|
519
|
+
return { type: "symbol", name: "*" };
|
|
520
|
+
}
|
|
521
|
+
if (token.kind === "punct" && token.value === "%") {
|
|
522
|
+
const digits = stream.peek();
|
|
523
|
+
if (digits?.kind === "number" && digits.binaryValue !== undefined) {
|
|
524
|
+
stream.consume();
|
|
525
|
+
return { type: "number", value: digits.binaryValue };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
473
528
|
if (token.kind === "punct" && token.value === "(") {
|
|
474
529
|
const expr = parseExpression(stream, new Set([")"]));
|
|
475
530
|
const closing = stream.consume();
|
|
@@ -591,17 +646,16 @@ class SymbolTable {
|
|
|
591
646
|
}
|
|
592
647
|
}
|
|
593
648
|
ensureLabel(label, expected) {
|
|
594
|
-
|
|
595
|
-
if (existing === undefined) {
|
|
596
|
-
throw new Error(`Symbol "${label}" was not defined in the first pass`);
|
|
597
|
-
}
|
|
598
|
-
if (existing !== expected) {
|
|
599
|
-
throw new Error(`Symbol "${label}" changed value between passes (expected ${formatHex(existing)}, got ${formatHex(expected)})`);
|
|
600
|
-
}
|
|
649
|
+
this.ensureUnchanged(label, expected);
|
|
601
650
|
if (!label.startsWith(".")) {
|
|
602
651
|
this.currentScope = label;
|
|
603
652
|
}
|
|
604
653
|
}
|
|
654
|
+
// Constant assignments never open a local-label scope (as in ACME and
|
|
655
|
+
// 64tass), matching setAssignment in the first pass.
|
|
656
|
+
ensureAssignment(label, expected) {
|
|
657
|
+
this.ensureUnchanged(label, expected);
|
|
658
|
+
}
|
|
605
659
|
setAssignment(label, value) {
|
|
606
660
|
if (label.startsWith(".")) {
|
|
607
661
|
if (!this.currentScope) {
|
|
@@ -628,6 +682,15 @@ class SymbolTable {
|
|
|
628
682
|
allGlobals() {
|
|
629
683
|
return this.globals;
|
|
630
684
|
}
|
|
685
|
+
ensureUnchanged(label, expected) {
|
|
686
|
+
const existing = this.get(label);
|
|
687
|
+
if (existing === undefined) {
|
|
688
|
+
throw new Error(`Symbol "${label}" was not defined in the first pass`);
|
|
689
|
+
}
|
|
690
|
+
if (existing !== expected) {
|
|
691
|
+
throw new Error(`Symbol "${label}" changed value between passes (expected ${formatHex(existing)}, got ${formatHex(expected)})`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
631
694
|
ensureLocalScope(scope) {
|
|
632
695
|
if (!this.locals.has(scope)) {
|
|
633
696
|
this.locals.set(scope, new Map());
|
|
@@ -638,21 +701,33 @@ class SymbolTable {
|
|
|
638
701
|
class Assembler {
|
|
639
702
|
constructor(lines, defaultLoadAddress) {
|
|
640
703
|
this.lines = lines;
|
|
704
|
+
this.defaultLoadAddress = defaultLoadAddress;
|
|
641
705
|
this.symbolTable = new SymbolTable();
|
|
642
706
|
this.memory = new Uint8Array(0x10000);
|
|
643
707
|
this.written = new Uint8Array(0x10000);
|
|
644
708
|
this.pc = 0;
|
|
645
709
|
this.lowestWritten = MAX_ADDRESS;
|
|
646
710
|
this.highestWritten = 0;
|
|
647
|
-
this.loadAddress = defaultLoadAddress;
|
|
648
711
|
this.symbolTable.reset();
|
|
649
712
|
}
|
|
650
713
|
runPass(pass) {
|
|
651
|
-
|
|
714
|
+
// Code before the first org directive is assembled at the load address.
|
|
715
|
+
this.pc = this.defaultLoadAddress & 0xffff;
|
|
652
716
|
if (pass === 0) {
|
|
653
717
|
this.symbolTable.reset();
|
|
654
718
|
this.lowestWritten = MAX_ADDRESS;
|
|
655
719
|
this.highestWritten = 0;
|
|
720
|
+
this.firstInstructionAddress = undefined;
|
|
721
|
+
}
|
|
722
|
+
// Loading a PRG at $0801 makes BASIC relink the bytes there as program
|
|
723
|
+
// lines, which corrupts raw machine code. Code that starts at $0801
|
|
724
|
+
// without its own org therefore gets a "10 SYS" stub that jumps to the
|
|
725
|
+
// first instruction, even when data comes before it.
|
|
726
|
+
if (this.pc === BASIC_START && this.emitsBeforeFirstOrg()) {
|
|
727
|
+
// Pass 0 does not know the first instruction's address yet; pass 1 uses the address pass 0 recorded.
|
|
728
|
+
for (const byte of basicSysStub(pass === 1 ? this.firstInstructionAddress ?? 0 : 0)) {
|
|
729
|
+
this.writeByte(byte, this.lines[0].location);
|
|
730
|
+
}
|
|
656
731
|
}
|
|
657
732
|
for (const line of this.lines) {
|
|
658
733
|
try {
|
|
@@ -667,11 +742,31 @@ class Assembler {
|
|
|
667
742
|
}
|
|
668
743
|
}
|
|
669
744
|
}
|
|
745
|
+
emitsBeforeFirstOrg() {
|
|
746
|
+
for (const line of this.lines) {
|
|
747
|
+
const statement = line.statement;
|
|
748
|
+
if (!statement)
|
|
749
|
+
continue;
|
|
750
|
+
if (statement.kind === "instruction")
|
|
751
|
+
return true;
|
|
752
|
+
if (statement.kind === "assignment" && statement.isLocationCounter)
|
|
753
|
+
return false;
|
|
754
|
+
if (statement.kind === "directive") {
|
|
755
|
+
if (statement.name === "org")
|
|
756
|
+
return false;
|
|
757
|
+
if (statement.name !== "include")
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
670
763
|
buildPrg() {
|
|
671
|
-
|
|
764
|
+
// The PRG spans the bytes actually emitted, so org blocks that only define
|
|
765
|
+
// labels neither move the load address nor add padding.
|
|
766
|
+
if (this.lowestWritten > this.highestWritten) {
|
|
672
767
|
return Buffer.alloc(0);
|
|
673
768
|
}
|
|
674
|
-
const start =
|
|
769
|
+
const start = this.lowestWritten;
|
|
675
770
|
const end = this.highestWritten;
|
|
676
771
|
const body = this.memory.subarray(start, end + 1);
|
|
677
772
|
const header = Buffer.alloc(2);
|
|
@@ -714,15 +809,12 @@ class Assembler {
|
|
|
714
809
|
}
|
|
715
810
|
if (statement.isLocationCounter) {
|
|
716
811
|
this.pc = value & 0xffff;
|
|
717
|
-
if (pass === 0) {
|
|
718
|
-
this.loadAddress = this.pc;
|
|
719
|
-
}
|
|
720
812
|
}
|
|
721
813
|
else if (pass === 0) {
|
|
722
814
|
this.symbolTable.setAssignment(statement.target, value);
|
|
723
815
|
}
|
|
724
816
|
else {
|
|
725
|
-
this.symbolTable.
|
|
817
|
+
this.symbolTable.ensureAssignment(statement.target, value);
|
|
726
818
|
}
|
|
727
819
|
}
|
|
728
820
|
handleDirective(statement, pass, location) {
|
|
@@ -736,9 +828,6 @@ class Assembler {
|
|
|
736
828
|
throw new AssemblyError("org directive requires a defined value", location);
|
|
737
829
|
}
|
|
738
830
|
this.pc = value & 0xffff;
|
|
739
|
-
if (pass === 0) {
|
|
740
|
-
this.loadAddress = this.pc;
|
|
741
|
-
}
|
|
742
831
|
break;
|
|
743
832
|
}
|
|
744
833
|
case "byte": {
|
|
@@ -831,6 +920,7 @@ class Assembler {
|
|
|
831
920
|
const info = determineInstruction(statement, pass, this.evaluate.bind(this), this.pc, location);
|
|
832
921
|
statement.resolvedMode = info.mode;
|
|
833
922
|
if (pass === 0) {
|
|
923
|
+
this.firstInstructionAddress ?? (this.firstInstructionAddress = this.pc);
|
|
834
924
|
this.pc += info.size;
|
|
835
925
|
return;
|
|
836
926
|
}
|
|
@@ -889,6 +979,10 @@ class Assembler {
|
|
|
889
979
|
return value ? 0 : 1;
|
|
890
980
|
case "~":
|
|
891
981
|
return ~value;
|
|
982
|
+
case "<":
|
|
983
|
+
return value & 0xff;
|
|
984
|
+
case ">":
|
|
985
|
+
return (value >> 8) & 0xff;
|
|
892
986
|
default:
|
|
893
987
|
throw new AssemblyError("Unknown unary operator", location);
|
|
894
988
|
}
|
|
@@ -961,6 +1055,7 @@ class Assembler {
|
|
|
961
1055
|
function determineInstruction(statement, pass, evaluator, locationCounter, location) {
|
|
962
1056
|
if (statement.resolvedMode && pass === 1) {
|
|
963
1057
|
const value = statement.operand.kind === "none" ? undefined : resolveOperandValue(statement.operand, evaluator, pass, location);
|
|
1058
|
+
assertZeroPageOperand(statement.mnemonic, statement.resolvedMode, value, location);
|
|
964
1059
|
const size = instructionSize(statement.resolvedMode);
|
|
965
1060
|
return {
|
|
966
1061
|
mode: statement.resolvedMode,
|
|
@@ -1040,43 +1135,51 @@ function chooseAddressingMode(mnemonic, operand, value, pass, location) {
|
|
|
1040
1135
|
return AddressingMode.Relative;
|
|
1041
1136
|
}
|
|
1042
1137
|
if (register === "X") {
|
|
1043
|
-
|
|
1044
|
-
return AddressingMode.ZeroPageX;
|
|
1045
|
-
}
|
|
1046
|
-
if (available.has(AddressingMode.AbsoluteX)) {
|
|
1047
|
-
return AddressingMode.AbsoluteX;
|
|
1048
|
-
}
|
|
1138
|
+
return chooseZeroPageOrAbsolute(mnemonic, available, AddressingMode.ZeroPageX, AddressingMode.AbsoluteX, value, location);
|
|
1049
1139
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
return AddressingMode.ZeroPageY;
|
|
1053
|
-
}
|
|
1054
|
-
if (available.has(AddressingMode.AbsoluteY)) {
|
|
1055
|
-
return AddressingMode.AbsoluteY;
|
|
1056
|
-
}
|
|
1140
|
+
if (register === "Y") {
|
|
1141
|
+
return chooseZeroPageOrAbsolute(mnemonic, available, AddressingMode.ZeroPageY, AddressingMode.AbsoluteY, value, location);
|
|
1057
1142
|
}
|
|
1058
|
-
|
|
1059
|
-
if (available.has(AddressingMode.ZeroPageS)) {
|
|
1060
|
-
|
|
1143
|
+
if (register === "S") {
|
|
1144
|
+
if (!available.has(AddressingMode.ZeroPageS)) {
|
|
1145
|
+
throw new AssemblyError(`Instruction ${mnemonic} does not support expr,S addressing`, location);
|
|
1061
1146
|
}
|
|
1147
|
+
return AddressingMode.ZeroPageS;
|
|
1062
1148
|
}
|
|
1063
|
-
|
|
1064
|
-
if (available.has(AddressingMode.ZeroPage) && value !== undefined && fitsByte(value)) {
|
|
1065
|
-
return AddressingMode.ZeroPage;
|
|
1066
|
-
}
|
|
1067
|
-
if (available.has(AddressingMode.Absolute)) {
|
|
1068
|
-
return AddressingMode.Absolute;
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
if (available.has(AddressingMode.Absolute)) {
|
|
1072
|
-
return AddressingMode.Absolute;
|
|
1073
|
-
}
|
|
1074
|
-
throw new AssemblyError(`Instruction ${mnemonic} cannot resolve addressing mode`, location);
|
|
1149
|
+
return chooseZeroPageOrAbsolute(mnemonic, available, AddressingMode.ZeroPage, AddressingMode.Absolute, value, location);
|
|
1075
1150
|
}
|
|
1076
1151
|
default:
|
|
1077
1152
|
throw new AssemblyError("Unsupported operand kind", location);
|
|
1078
1153
|
}
|
|
1079
1154
|
}
|
|
1155
|
+
function chooseZeroPageOrAbsolute(mnemonic, available, zeroPageMode, absoluteMode, value, location) {
|
|
1156
|
+
const hasZeroPage = available.has(zeroPageMode);
|
|
1157
|
+
const hasAbsolute = available.has(absoluteMode);
|
|
1158
|
+
if (hasZeroPage && value !== undefined && fitsByte(value)) {
|
|
1159
|
+
return zeroPageMode;
|
|
1160
|
+
}
|
|
1161
|
+
if (hasAbsolute) {
|
|
1162
|
+
return absoluteMode;
|
|
1163
|
+
}
|
|
1164
|
+
// Without an absolute form (e.g. STX zp,Y), a forward reference must be
|
|
1165
|
+
// sized as zero page; the second pass rejects values outside zero page.
|
|
1166
|
+
if (hasZeroPage && value === undefined) {
|
|
1167
|
+
return zeroPageMode;
|
|
1168
|
+
}
|
|
1169
|
+
throw new AssemblyError(`Instruction ${mnemonic} does not support ${absoluteMode} addressing`, location);
|
|
1170
|
+
}
|
|
1171
|
+
function assertZeroPageOperand(mnemonic, mode, value, location) {
|
|
1172
|
+
const absoluteMode = ZERO_PAGE_TO_ABSOLUTE_MODE.get(mode);
|
|
1173
|
+
if (absoluteMode === undefined || value === undefined || fitsByte(value)) {
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
throw new AssemblyError(`Instruction ${mnemonic} does not support ${absoluteMode} addressing (operand ${formatHex(value)} is outside zero page)`, location);
|
|
1177
|
+
}
|
|
1178
|
+
const ZERO_PAGE_TO_ABSOLUTE_MODE = new Map([
|
|
1179
|
+
[AddressingMode.ZeroPage, AddressingMode.Absolute],
|
|
1180
|
+
[AddressingMode.ZeroPageX, AddressingMode.AbsoluteX],
|
|
1181
|
+
[AddressingMode.ZeroPageY, AddressingMode.AbsoluteY],
|
|
1182
|
+
]);
|
|
1080
1183
|
function instructionSize(mode) {
|
|
1081
1184
|
switch (mode) {
|
|
1082
1185
|
case AddressingMode.Implied:
|
|
@@ -9,13 +9,18 @@ import { Buffer } from "node:buffer";
|
|
|
9
9
|
import { asciiToPetscii, encodeStringWithNames } from "../../petscii.js";
|
|
10
10
|
const DEFAULT_START_ADDRESS = 0x0801;
|
|
11
11
|
const REM_TOKEN = 0x8f;
|
|
12
|
+
const DATA_TOKEN = 0x83;
|
|
13
|
+
const PRINT_TOKEN = 0x99;
|
|
14
|
+
// Highest line number the BASIC V2 line parser (LINGET) accepts; larger values give ?SYNTAX ERROR.
|
|
15
|
+
const MAX_LINE_NUMBER = 63999;
|
|
12
16
|
const TOKEN_TABLE = buildTokenTable();
|
|
13
17
|
export function basicToPrg(source, options) {
|
|
14
18
|
const startAddress = options?.startAddress ?? DEFAULT_START_ADDRESS;
|
|
15
19
|
const normalizedSource = normalize(source);
|
|
16
20
|
const lines = normalizedSource.split("\n");
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
// Like typing lines into the C64, lines are kept in ascending order, a later
|
|
22
|
+
// duplicate replaces the earlier line, and a bare line number deletes it.
|
|
23
|
+
const programLines = new Map();
|
|
19
24
|
for (const rawLine of lines) {
|
|
20
25
|
const trimmedRight = rawLine.replace(/\s+$/u, "");
|
|
21
26
|
if (trimmedRight === "") {
|
|
@@ -26,11 +31,20 @@ export function basicToPrg(source, options) {
|
|
|
26
31
|
throw new Error(`Expected line number at: "${rawLine}"`);
|
|
27
32
|
}
|
|
28
33
|
const lineNumber = Number.parseInt(match[1], 10);
|
|
29
|
-
if (!Number.isInteger(lineNumber) || lineNumber < 0 || lineNumber >
|
|
30
|
-
throw new Error(`Line number out of range: ${match[1]}`);
|
|
34
|
+
if (!Number.isInteger(lineNumber) || lineNumber < 0 || lineNumber > MAX_LINE_NUMBER) {
|
|
35
|
+
throw new Error(`Line number out of range: ${match[1]} (C64 BASIC accepts 0-${MAX_LINE_NUMBER})`);
|
|
31
36
|
}
|
|
32
37
|
const content = match[2] ?? "";
|
|
33
|
-
|
|
38
|
+
if (content === "") {
|
|
39
|
+
programLines.delete(lineNumber);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
programLines.set(lineNumber, tokenize(content));
|
|
43
|
+
}
|
|
44
|
+
const lineBuffers = [];
|
|
45
|
+
let currentAddress = startAddress;
|
|
46
|
+
for (const lineNumber of [...programLines.keys()].sort((a, b) => a - b)) {
|
|
47
|
+
const tokenised = programLines.get(lineNumber);
|
|
34
48
|
const lineLength = 2 + 2 + tokenised.length + 1;
|
|
35
49
|
const nextAddress = currentAddress + lineLength;
|
|
36
50
|
const lineBuffer = Buffer.alloc(lineLength);
|
|
@@ -56,8 +70,16 @@ function tokenize(content) {
|
|
|
56
70
|
let index = 0;
|
|
57
71
|
let inString = false;
|
|
58
72
|
let inRemark = false;
|
|
73
|
+
// As in the ROM CRUNCH routine, DATA items are copied literally until the
|
|
74
|
+
// next ':' outside quotes, and '?' outside strings, REM, and DATA becomes PRINT.
|
|
75
|
+
let inData = false;
|
|
59
76
|
while (index < content.length) {
|
|
60
|
-
if (!inString && !inRemark) {
|
|
77
|
+
if (!inString && !inRemark && !inData) {
|
|
78
|
+
if (upper[index] === "?") {
|
|
79
|
+
bytes.push(PRINT_TOKEN);
|
|
80
|
+
index += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
61
83
|
const token = matchToken(upper, index);
|
|
62
84
|
if (token) {
|
|
63
85
|
bytes.push(token.byte);
|
|
@@ -65,6 +87,9 @@ function tokenize(content) {
|
|
|
65
87
|
if (token.byte === REM_TOKEN) {
|
|
66
88
|
inRemark = true;
|
|
67
89
|
}
|
|
90
|
+
else if (token.byte === DATA_TOKEN) {
|
|
91
|
+
inData = true;
|
|
92
|
+
}
|
|
68
93
|
continue;
|
|
69
94
|
}
|
|
70
95
|
}
|
|
@@ -82,7 +107,9 @@ function tokenize(content) {
|
|
|
82
107
|
end += 1;
|
|
83
108
|
}
|
|
84
109
|
const segment = content.slice(start, end);
|
|
85
|
-
|
|
110
|
+
// Lowercase letters would print as graphics characters in the default
|
|
111
|
+
// character set, so string text is stored as the PETSCII letters.
|
|
112
|
+
const encoded = encodeStringWithNames(segment, { foldLowercase: true });
|
|
86
113
|
for (const b of encoded)
|
|
87
114
|
bytes.push(b);
|
|
88
115
|
index = end; // leave closing quote to be processed next iteration
|
|
@@ -91,6 +118,9 @@ function tokenize(content) {
|
|
|
91
118
|
}
|
|
92
119
|
const plainChar = (!inString && !inRemark ? upper[index] : char) ?? char;
|
|
93
120
|
bytes.push(asciiToPetscii(plainChar));
|
|
121
|
+
if (inData && !inString && plainChar === ":") {
|
|
122
|
+
inData = false;
|
|
123
|
+
}
|
|
94
124
|
index += 1;
|
|
95
125
|
}
|
|
96
126
|
if (bytes.length === 0) {
|
|
@@ -112,7 +142,7 @@ function buildTokenTable() {
|
|
|
112
142
|
["END", 0x80],
|
|
113
143
|
["FOR", 0x81],
|
|
114
144
|
["NEXT", 0x82],
|
|
115
|
-
["DATA",
|
|
145
|
+
["DATA", DATA_TOKEN],
|
|
116
146
|
["INPUT#", 0x84],
|
|
117
147
|
["INPUT", 0x85],
|
|
118
148
|
["DIM", 0x86],
|
|
@@ -134,7 +164,7 @@ function buildTokenTable() {
|
|
|
134
164
|
["DEF", 0x96],
|
|
135
165
|
["POKE", 0x97],
|
|
136
166
|
["PRINT#", 0x98],
|
|
137
|
-
["PRINT",
|
|
167
|
+
["PRINT", PRINT_TOKEN],
|
|
138
168
|
["CONT", 0x9a],
|
|
139
169
|
["LIST", 0x9b],
|
|
140
170
|
["CLR", 0x9c],
|
package/dist/tools/types.js
CHANGED
|
@@ -104,7 +104,8 @@ export function createOperationDispatcher(toolName, handlers) {
|
|
|
104
104
|
throw new ToolValidationError(`${toolName} is a grouped tool and requires an ${OPERATION_DISCRIMINATOR} string to select an operation`, { path: `$.${OPERATION_DISCRIMINATOR}`, details: { allowed } });
|
|
105
105
|
}
|
|
106
106
|
const opKey = opValue;
|
|
107
|
-
|
|
107
|
+
// Own properties only: "constructor", "toString", etc. must not resolve to Object.prototype members.
|
|
108
|
+
const handler = hasOwnKey(handlers, opKey) ? handlers[opKey] : undefined;
|
|
108
109
|
if (!handler) {
|
|
109
110
|
throw new ToolValidationError(`${toolName} does not support ${OPERATION_DISCRIMINATOR} "${opValue}"`, { path: `$.${OPERATION_DISCRIMINATOR}`, details: { allowed } });
|
|
110
111
|
}
|
|
@@ -184,7 +185,7 @@ function resolveSupportedPlatforms(tool, args, defaultPlatforms) {
|
|
|
184
185
|
if (!operation) {
|
|
185
186
|
return basePlatforms;
|
|
186
187
|
}
|
|
187
|
-
const operationPlatforms = tool.operationPlatforms
|
|
188
|
+
const operationPlatforms = ownValue(tool.operationPlatforms, operation);
|
|
188
189
|
return operationPlatforms && operationPlatforms.length > 0 ? operationPlatforms : basePlatforms;
|
|
189
190
|
}
|
|
190
191
|
function resolveUnsupportedToolName(toolName, tool, args) {
|
|
@@ -192,11 +193,17 @@ function resolveUnsupportedToolName(toolName, tool, args) {
|
|
|
192
193
|
if (!operation) {
|
|
193
194
|
return toolName;
|
|
194
195
|
}
|
|
195
|
-
const mappedName = tool.operationToolNames
|
|
196
|
+
const mappedName = ownValue(tool.operationToolNames, operation);
|
|
196
197
|
if (mappedName) {
|
|
197
198
|
return mappedName;
|
|
198
199
|
}
|
|
199
|
-
return tool.operationPlatforms
|
|
200
|
+
return ownValue(tool.operationPlatforms, operation) ? operation : toolName;
|
|
201
|
+
}
|
|
202
|
+
function hasOwnKey(record, key) {
|
|
203
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
204
|
+
}
|
|
205
|
+
function ownValue(record, key) {
|
|
206
|
+
return record && hasOwnKey(record, key) ? record[key] : undefined;
|
|
200
207
|
}
|
|
201
208
|
function getSelectedOperation(args) {
|
|
202
209
|
if (typeof args !== "object" || args === null) {
|
package/dist/vicBitmap.js
CHANGED
|
@@ -4,6 +4,8 @@ const BITMAP_BYTE_LENGTH = 8000;
|
|
|
4
4
|
const SCREEN_RAM_BYTE_LENGTH = 1000;
|
|
5
5
|
const COLOR_RAM_BYTE_LENGTH = 1000;
|
|
6
6
|
const COLOR_RAM_ADDRESS = 0xD800;
|
|
7
|
+
const CHAR_ROM_SHADOW_OFFSET = 0x1000;
|
|
8
|
+
const CHAR_ROM_SHADOW_LENGTH = 0x1000;
|
|
7
9
|
const VIC_PALETTE = [
|
|
8
10
|
[0x00, 0x00, 0x00],
|
|
9
11
|
[0xFF, 0xFF, 0xFF],
|
|
@@ -83,6 +85,17 @@ export function resolveVicBitmapMemoryLayout(bitmapAddress = 0x2000, screenAddre
|
|
|
83
85
|
if (rangesOverlap(bitmapAddress, BITMAP_BYTE_LENGTH, screenAddress, SCREEN_RAM_BYTE_LENGTH)) {
|
|
84
86
|
throw new Error("Bitmap RAM and screen RAM must not overlap");
|
|
85
87
|
}
|
|
88
|
+
// In banks 0 and 2 the VIC reads the character ROM, not RAM, at bank offsets $1000-$1FFF.
|
|
89
|
+
if (bank === 0 || bank === 2) {
|
|
90
|
+
const shadowStart = bankBase + CHAR_ROM_SHADOW_OFFSET;
|
|
91
|
+
const shadowRange = `$${formatHex(shadowStart)}-$${formatHex(shadowStart + CHAR_ROM_SHADOW_LENGTH - 1)}`;
|
|
92
|
+
if (rangesOverlap(bitmapOffset, BITMAP_BYTE_LENGTH, CHAR_ROM_SHADOW_OFFSET, CHAR_ROM_SHADOW_LENGTH)) {
|
|
93
|
+
throw new Error(`Bitmap RAM must not overlap the character ROM shadow at ${shadowRange} in VIC bank ${bank}`);
|
|
94
|
+
}
|
|
95
|
+
if (rangesOverlap(screenOffset, SCREEN_RAM_BYTE_LENGTH, CHAR_ROM_SHADOW_OFFSET, CHAR_ROM_SHADOW_LENGTH)) {
|
|
96
|
+
throw new Error(`Screen RAM must not overlap the character ROM shadow at ${shadowRange} in VIC bank ${bank}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
86
99
|
const screenNibble = ((screenOffset / 0x0400) & 0x0F) << 4;
|
|
87
100
|
const bitmapBit = bitmapOffset === 0x2000 ? 0x08 : 0x00;
|
|
88
101
|
return {
|
|
@@ -310,6 +323,9 @@ function validateAddress(address, length, label) {
|
|
|
310
323
|
throw new Error(`${label} range must fit within 64 KB address space`);
|
|
311
324
|
}
|
|
312
325
|
}
|
|
326
|
+
function formatHex(value) {
|
|
327
|
+
return value.toString(16).toUpperCase().padStart(4, "0");
|
|
328
|
+
}
|
|
313
329
|
function rangesOverlap(leftStart, leftLength, rightStart, rightLength) {
|
|
314
330
|
const leftEnd = leftStart + leftLength - 1;
|
|
315
331
|
const rightEnd = rightStart + rightLength - 1;
|
package/dist/vice/mockServer.js
CHANGED
|
@@ -187,7 +187,7 @@ export class ViceMockServer {
|
|
|
187
187
|
const end = body.readUInt16LE(3);
|
|
188
188
|
const length = Math.max(0, end - start + 1);
|
|
189
189
|
const payload = Buffer.alloc(2 + length);
|
|
190
|
-
payload.writeUInt16LE(length, 0);
|
|
190
|
+
payload.writeUInt16LE(length & 0xffff, 0); // Like VICE: a full 64 KB read reports ML = 0.
|
|
191
191
|
for (let i = 0; i < length; i += 1) {
|
|
192
192
|
payload[2 + i] = this.memory[(start + i) & 0xffff];
|
|
193
193
|
}
|
|
@@ -359,7 +359,9 @@ export class ViceMockServer {
|
|
|
359
359
|
return payload;
|
|
360
360
|
}
|
|
361
361
|
encodeDisplay() {
|
|
362
|
-
const pixels = Buffer.alloc(64
|
|
362
|
+
const pixels = Buffer.alloc(64);
|
|
363
|
+
for (let i = 0; i < pixels.length; i += 1)
|
|
364
|
+
pixels[i] = i & 0x0f;
|
|
363
365
|
const header = Buffer.alloc(17);
|
|
364
366
|
const pixelLength = Buffer.alloc(4);
|
|
365
367
|
header.writeUInt32LE(13, 0);
|
|
@@ -371,7 +373,9 @@ export class ViceMockServer {
|
|
|
371
373
|
header.writeUInt16LE(200, 14);
|
|
372
374
|
header[16] = 8;
|
|
373
375
|
pixelLength.writeUInt32LE(pixels.length, 0);
|
|
374
|
-
|
|
376
|
+
// VICE 3.9/3.10 send a body of 4 + FL + BL bytes, which drops the last 4 pixel bytes.
|
|
377
|
+
const full = Buffer.concat([header, pixelLength, pixels]);
|
|
378
|
+
return full.subarray(0, 4 + 13 + pixels.length);
|
|
375
379
|
}
|
|
376
380
|
applyRegisterWrites(body) {
|
|
377
381
|
if (body.length < 3) {
|
package/dist/vice/viceClient.js
CHANGED
|
@@ -177,7 +177,9 @@ export class ViceClient {
|
|
|
177
177
|
body[5] = 0; // comp space
|
|
178
178
|
body.writeUInt16LE(0, 6); // bank
|
|
179
179
|
const frame = await this.send(0x01, body);
|
|
180
|
-
|
|
180
|
+
// ML is 16 bits wide, so VICE reports a full $0000-$FFFF read as 0; the body length is authoritative.
|
|
181
|
+
const declared = frame.readUInt16LE(12);
|
|
182
|
+
const len = Math.min(frame.length - 14, declared === 0 ? 0x10000 : declared);
|
|
181
183
|
return frame.subarray(14, 14 + len);
|
|
182
184
|
}
|
|
183
185
|
async memSet(start, payload) {
|
|
@@ -200,7 +202,7 @@ export class ViceClient {
|
|
|
200
202
|
/** Set the VICE joyport's active-low control byte (BM command 0xA2). */
|
|
201
203
|
async joyportSet(port, activeLowMask) {
|
|
202
204
|
const body = Buffer.alloc(4);
|
|
203
|
-
body.writeUInt16LE(port, 0);
|
|
205
|
+
body.writeUInt16LE(port - 1, 0); // VICE joyports are 0-based (JOYPORT_1 = 0, JOYPORT_2 = 1).
|
|
204
206
|
body.writeUInt16LE(activeLowMask & 0xff, 2);
|
|
205
207
|
await this.send(0xA2, body);
|
|
206
208
|
}
|
|
@@ -458,12 +460,20 @@ export class ViceClient {
|
|
|
458
460
|
const innerWidth = body.readUInt16LE(12);
|
|
459
461
|
const innerHeight = body.readUInt16LE(14);
|
|
460
462
|
const bitsPerPixel = body[16] ?? 8;
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
if (
|
|
463
|
+
// FL counts only the DW..BP fields; the 4-byte BL field sits between them and the pixels.
|
|
464
|
+
const pixelLengthOffset = 4 + infoLength;
|
|
465
|
+
if (body.length < pixelLengthOffset + 4) {
|
|
466
|
+
throw new Error("Display response length mismatch");
|
|
467
|
+
}
|
|
468
|
+
const pixelLength = body.readUInt32LE(pixelLengthOffset);
|
|
469
|
+
const pixelStart = pixelLengthOffset + 4;
|
|
470
|
+
const received = body.length - pixelStart;
|
|
471
|
+
// VICE 3.9/3.10 size the body as 4 + FL + BL, omitting the last 4 pixel bytes; zero-fill only that gap.
|
|
472
|
+
if (received < pixelLength - 4) {
|
|
464
473
|
throw new Error("Display pixel buffer truncated");
|
|
465
474
|
}
|
|
466
|
-
const pixels = Buffer.
|
|
475
|
+
const pixels = Buffer.alloc(pixelLength);
|
|
476
|
+
body.copy(pixels, 0, pixelStart, pixelStart + Math.min(received, pixelLength));
|
|
467
477
|
return {
|
|
468
478
|
debugWidth,
|
|
469
479
|
debugHeight,
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"boolean",
|
|
54
54
|
"null"
|
|
55
55
|
],
|
|
56
|
-
"description": "When true, save the restored configuration to flash immediately.",
|
|
56
|
+
"description": "When true, save the restored configuration to flash immediately (C64U/U64 and U2 only).",
|
|
57
57
|
"default": false
|
|
58
58
|
},
|
|
59
59
|
"root": {
|
|
@@ -452,7 +452,7 @@
|
|
|
452
452
|
"boolean",
|
|
453
453
|
"null"
|
|
454
454
|
],
|
|
455
|
-
"description": "When true, save the restored configuration to flash immediately.",
|
|
455
|
+
"description": "When true, save the restored configuration to flash immediately (C64U/U64 and U2 only).",
|
|
456
456
|
"default": false
|
|
457
457
|
},
|
|
458
458
|
"op": {
|