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.
Files changed (58) hide show
  1. package/README.md +12 -12
  2. package/data/embeddings_basic.json +1 -1
  3. package/data/embeddings_hardware.json +1 -1
  4. package/data/embeddings_mixed.json +1 -1
  5. package/data/embeddings_other.json +1 -1
  6. package/data/graphics/character-set.csv +64 -64
  7. package/dist/.tsbuildinfo +1 -1
  8. package/dist/c64Client.js +68 -61
  9. package/dist/config.js +75 -38
  10. package/dist/device.js +154 -120
  11. package/dist/knowledge.js +2 -1
  12. package/dist/petscii.js +18 -15
  13. package/dist/petsciiArt.js +40 -14
  14. package/dist/rag/indexer.js +2 -1
  15. package/dist/sidwave.js +1 -1
  16. package/dist/sidwaveCompiler.js +3 -9
  17. package/dist/tools/audio.js +34 -8
  18. package/dist/tools/debug.js +9 -9
  19. package/dist/tools/errors.js +12 -1
  20. package/dist/tools/hex.js +9 -0
  21. package/dist/tools/input.js +13 -7
  22. package/dist/tools/memory.js +5 -4
  23. package/dist/tools/meta/artifacts.js +8 -3
  24. package/dist/tools/meta/audio.js +25 -28
  25. package/dist/tools/meta/background.js +6 -1
  26. package/dist/tools/meta/config.js +12 -4
  27. package/dist/tools/meta/configInventory.js +4 -1
  28. package/dist/tools/meta/filesystem.js +37 -10
  29. package/dist/tools/meta/graphics.js +10 -15
  30. package/dist/tools/meta/memory.js +12 -18
  31. package/dist/tools/meta/program.js +44 -16
  32. package/dist/tools/pollValidator.js +30 -2
  33. package/dist/tools/programRunners.js +5 -4
  34. package/dist/tools/registry/batch.js +4 -2
  35. package/dist/tools/registry/config.js +1 -1
  36. package/dist/tools/registry/disk.js +5 -0
  37. package/dist/tools/registry/extract.js +3 -0
  38. package/dist/tools/registry/graphics.js +1 -0
  39. package/dist/tools/registry/program.js +1 -1
  40. package/dist/tools/registry/sound.js +3 -3
  41. package/dist/tools/registry/system.js +3 -3
  42. package/dist/tools/storage.js +2 -2
  43. package/dist/tools/translation/assembler.js +150 -47
  44. package/dist/tools/translation/basicTokenizer.js +39 -9
  45. package/dist/tools/types.js +11 -4
  46. package/dist/vicBitmap.js +16 -0
  47. package/dist/vice/mockServer.js +7 -3
  48. package/dist/vice/viceClient.js +16 -6
  49. package/mcp/schemas/c64_config.schema.json +2 -2
  50. package/mcp/schemas/c64_program.schema.json +10 -18
  51. package/mcp/schemas/c64_sound.schema.json +8 -6
  52. package/mcp/schemas/c64_system.schema.json +9 -7
  53. package/mcp/server.json +1 -1
  54. package/mcp/tools.json +58 -43
  55. package/mcp.json +1 -1
  56. package/package.json +1 -1
  57. package/scripts/mockC64Server.mjs +98 -19
  58. 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
- if (token.kind === "punct" && (token.value === "+" || token.value === "-" || token.value === "!" || token.value === "~")) {
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
- const existing = this.get(label);
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
- this.pc = 0;
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
- if (this.lowestWritten === MAX_ADDRESS || this.highestWritten < this.loadAddress) {
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 = Math.min(this.lowestWritten, this.loadAddress);
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.ensureLabel(statement.target, value);
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
- if (available.has(AddressingMode.ZeroPageX) && value !== undefined && fitsByte(value)) {
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
- else if (register === "Y") {
1051
- if (available.has(AddressingMode.ZeroPageY) && value !== undefined && fitsByte(value)) {
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
- else if (register === "S") {
1059
- if (available.has(AddressingMode.ZeroPageS)) {
1060
- return AddressingMode.ZeroPageS;
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
- else {
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
- const lineBuffers = [];
18
- let currentAddress = startAddress;
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 > 65535) {
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
- const tokenised = tokenize(content);
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
- const encoded = encodeStringWithNames(segment);
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", 0x83],
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", 0x99],
167
+ ["PRINT", PRINT_TOKEN],
138
168
  ["CONT", 0x9a],
139
169
  ["LIST", 0x9b],
140
170
  ["CLR", 0x9c],
@@ -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
- const handler = handlers[opKey];
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?.[operation];
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?.[operation];
196
+ const mappedName = ownValue(tool.operationToolNames, operation);
196
197
  if (mappedName) {
197
198
  return mappedName;
198
199
  }
199
- return tool.operationPlatforms?.[operation] ? operation : toolName;
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;
@@ -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, 0);
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
- return Buffer.concat([header, pixelLength, pixels]);
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) {
@@ -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
- const len = frame.readUInt16LE(12);
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
- const pixelLength = body.readUInt32LE(17);
462
- const pixelStart = 4 + infoLength;
463
- if (pixelStart + pixelLength > body.length) {
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.from(body.subarray(pixelStart, pixelStart + pixelLength));
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": {