c64bridge 1.0.6 → 1.1.0

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 (136) hide show
  1. package/.github/skills/assembly-program/SKILL.md +37 -0
  2. package/.github/skills/basic-program/SKILL.md +40 -0
  3. package/.github/skills/cross-platform-demo/SKILL.md +32 -0
  4. package/.github/skills/drive-manager/SKILL.md +33 -0
  5. package/.github/skills/graphics-demo/SKILL.md +30 -0
  6. package/.github/skills/hello-world/SKILL.md +36 -0
  7. package/.github/skills/memory-debug/SKILL.md +32 -0
  8. package/.github/skills/pr-converge/SKILL.md +33 -0
  9. package/.github/skills/printer-job/SKILL.md +30 -0
  10. package/.github/skills/sid-music/SKILL.md +35 -0
  11. package/.github/skills/software-launch/SKILL.md +31 -0
  12. package/.github/skills/stream-control/SKILL.md +29 -0
  13. package/.github/skills/system-control/SKILL.md +32 -0
  14. package/AGENTS.md +28 -7
  15. package/README.md +70 -15
  16. package/data/assembly/assembly-spec.md +2 -0
  17. package/dist/c64Client.js +238 -60
  18. package/dist/chargen.js +0 -4
  19. package/dist/config.js +53 -28
  20. package/dist/device.js +92 -37
  21. package/dist/diagnostics.js +66 -5
  22. package/dist/hostPaths.js +128 -0
  23. package/dist/logger.js +3 -2
  24. package/dist/loggingHttpClient.js +71 -2
  25. package/dist/mcp-server.js +416 -291
  26. package/dist/numericArg.js +55 -0
  27. package/dist/petscii.js +13 -6
  28. package/dist/platform.js +12 -3
  29. package/dist/prompts/registry.js +6 -1
  30. package/dist/redact.js +166 -0
  31. package/dist/tools/access.js +66 -0
  32. package/dist/tools/debug.js +6 -0
  33. package/dist/tools/developer.js +77 -36
  34. package/dist/tools/errors.js +54 -3
  35. package/dist/tools/input.js +25 -8
  36. package/dist/tools/machineControl.js +113 -21
  37. package/dist/tools/memory.js +135 -30
  38. package/dist/tools/meta/artifacts.js +5 -5
  39. package/dist/tools/meta/background.js +216 -54
  40. package/dist/tools/meta/compilation.js +6 -5
  41. package/dist/tools/meta/config.js +20 -10
  42. package/dist/tools/meta/configInventory.js +25 -2
  43. package/dist/tools/meta/diagnostics.js +2 -1
  44. package/dist/tools/meta/filesystem.js +16 -14
  45. package/dist/tools/meta/graphics.js +48 -20
  46. package/dist/tools/meta/memory.js +45 -19
  47. package/dist/tools/meta/program.js +10 -7
  48. package/dist/tools/meta/screen.js +116 -25
  49. package/dist/tools/pauseGuard.js +23 -0
  50. package/dist/tools/pollValidator.js +53 -8
  51. package/dist/tools/programRunners.js +62 -18
  52. package/dist/tools/registry/batch.js +17 -11
  53. package/dist/tools/registry/config.js +9 -2
  54. package/dist/tools/registry/disk.js +12 -2
  55. package/dist/tools/registry/drive.js +3 -0
  56. package/dist/tools/registry/extract.js +3 -0
  57. package/dist/tools/registry/graphics.js +59 -6
  58. package/dist/tools/registry/index.js +17 -1
  59. package/dist/tools/registry/memory.js +11 -1
  60. package/dist/tools/registry/platform.js +4 -1
  61. package/dist/tools/registry/printer.js +2 -1
  62. package/dist/tools/registry/program.js +5 -1
  63. package/dist/tools/registry/rag.js +1 -0
  64. package/dist/tools/registry/sound.js +12 -1
  65. package/dist/tools/registry/stream.js +2 -1
  66. package/dist/tools/registry/system.js +8 -1
  67. package/dist/tools/schema.js +7 -7
  68. package/dist/tools/streaming.js +34 -4
  69. package/dist/tools/translation/assembler.js +247 -24
  70. package/dist/tools/translation/basicDetokenizer.js +80 -0
  71. package/dist/tools/translation/basicTokenizer.js +8 -3
  72. package/dist/tools/translation/disassembler.js +14 -0
  73. package/dist/tools/types.js +258 -7
  74. package/dist/tools/vice.js +5 -16
  75. package/dist/vicBitmap.js +1 -1
  76. package/dist/vice/mockServer.js +3 -0
  77. package/dist/vice/process.js +19 -0
  78. package/dist/vice/resourcePolicy.js +36 -0
  79. package/dist/vice/viceClient.js +22 -5
  80. package/doc/vice/support-matrix.md +17 -11
  81. package/mcp/schemas/c64_batch.schema.json +2 -1
  82. package/mcp/schemas/c64_config.schema.json +33 -466
  83. package/mcp/schemas/c64_debug.schema.json +3 -602
  84. package/mcp/schemas/c64_disk.schema.json +17 -344
  85. package/mcp/schemas/c64_drive.schema.json +2 -157
  86. package/mcp/schemas/c64_extract.schema.json +10 -364
  87. package/mcp/schemas/c64_graphics.schema.json +19 -426
  88. package/mcp/schemas/c64_input.schema.json +5 -318
  89. package/mcp/schemas/c64_memory.schema.json +23 -491
  90. package/mcp/schemas/c64_printer.schema.json +3 -258
  91. package/mcp/schemas/c64_program.schema.json +16 -491
  92. package/mcp/schemas/c64_rag.schema.json +1 -81
  93. package/mcp/schemas/c64_select_backend.schema.json +2 -36
  94. package/mcp/schemas/c64_sound.schema.json +20 -822
  95. package/mcp/schemas/c64_stream.schema.json +4 -75
  96. package/mcp/schemas/c64_system.schema.json +28 -348
  97. package/mcp/schemas/c64_vice.schema.json +2 -71
  98. package/mcp/server.json +1 -1
  99. package/mcp/tools.json +1990 -6900
  100. package/mcp.json +21 -1
  101. package/package.json +19 -8
  102. package/scripts/mockC64Server.mjs +44 -0
  103. package/scripts/run-coverage.mjs +27 -1
  104. package/scripts/smoke-run.sh +3 -2
  105. package/scripts/start.mjs +23 -1
  106. package/server.json +53 -3
  107. package/dist/.tsbuildinfo +0 -1
  108. package/doc/agent-state-spec.md +0 -243
  109. package/doc/plans/bun/baseline-metrics.md +0 -16
  110. package/doc/plans/bun/migration-checklist.md +0 -55
  111. package/doc/plans/bun/migration.md +0 -83
  112. package/doc/plans/improve-knowledge/notes.md +0 -43
  113. package/doc/plans/improve-knowledge/plan.md +0 -121
  114. package/doc/plans/improve-knowledge/rollout.md +0 -110
  115. package/doc/plans/meta-tools/README.md +0 -110
  116. package/doc/plans/meta-tools/analysis.md +0 -272
  117. package/doc/plans/meta-tools/catalog.md +0 -435
  118. package/doc/plans/meta-tools/notes.md +0 -192
  119. package/doc/plans/meta-tools/rollout.md +0 -368
  120. package/doc/plans/prompts/README.md +0 -96
  121. package/doc/plans/prompts/user-prompt-ideas.md +0 -147
  122. package/doc/plans/tool-consolidation/notes.md +0 -28
  123. package/doc/plans/tool-consolidation/plan.md +0 -169
  124. package/doc/plans/tool-consolidation/rollout.md +0 -104
  125. package/doc/plans/vice-backend-switch/plan.md +0 -486
  126. package/doc/plans/vice-backend-switch-2/plan.md +0 -48
  127. package/doc/plans/vice-support/notes.md +0 -63
  128. package/doc/plans/vice-support/plan.md +0 -88
  129. package/doc/plans/vice-support/rollout.md +0 -99
  130. package/doc/reviews/audit.md +0 -143
  131. package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
  132. package/doc/reviews/hardening/01-fable/prompt.md +0 -98
  133. package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
  134. package/doc/reviews/hardening/01-fable/review.md +0 -1234
  135. package/doc/reviews/vice-support-review.md +0 -185
  136. package/scripts/compare-rest/rest-compare-2025-11-01T00-25-21-830Z.json +0 -2513
@@ -109,6 +109,11 @@ const DIRECTIVE_ALIASES = {
109
109
  "RES": "reserve",
110
110
  ".DS": "reserve",
111
111
  "DS": "reserve",
112
+ ".EQU": "equ",
113
+ ".FILL": "fill",
114
+ ".ALIGN": "align",
115
+ ".TEXT": "text",
116
+ ".ASCII": "text",
112
117
  ".INCLUDE": "include",
113
118
  "INCLUDE": "include",
114
119
  };
@@ -151,17 +156,28 @@ class Parser {
151
156
  for (let index = 0; index < lines.length; index += 1) {
152
157
  const originalLine = lines[index] ?? "";
153
158
  const location = { file: fileName, line: index + 1, text: originalLine };
154
- const tokens = tokenize(originalLine);
155
- if (tokens.length === 0) {
156
- continue;
159
+ let labels = [];
160
+ let statement;
161
+ try {
162
+ const tokens = tokenize(originalLine);
163
+ if (tokens.length === 0) {
164
+ continue;
165
+ }
166
+ const stream = new TokenStream(tokens);
167
+ labels = [];
168
+ while (stream.peek()?.kind === "symbol" && stream.peek(1)?.kind === "punct" && stream.peek(1).value === ":") {
169
+ labels.push(stream.consumeSymbol());
170
+ stream.consumePunct(":");
171
+ }
172
+ statement = this.parseStatement(stream, location, includeStack, statements);
157
173
  }
158
- const stream = new TokenStream(tokens);
159
- const labels = [];
160
- while (stream.peek()?.kind === "symbol" && stream.peek(1)?.kind === "punct" && stream.peek(1).value === ":") {
161
- labels.push(stream.consumeSymbol());
162
- stream.consumePunct(":");
174
+ catch (error) {
175
+ // Name the source line for syntax errors as well.
176
+ if (error instanceof AssemblyError) {
177
+ throw error;
178
+ }
179
+ throw new AssemblyError(error instanceof Error ? error.message : String(error), location);
163
180
  }
164
- const statement = this.parseStatement(stream, location, includeStack, statements);
165
181
  statements.push({
166
182
  labels,
167
183
  statement,
@@ -200,6 +216,15 @@ class Parser {
200
216
  if (first.kind !== "symbol") {
201
217
  throw new AssemblyError("Expected an opcode, directive, or assignment", location);
202
218
  }
219
+ // "name .equ expr", the same as "name = expr".
220
+ const second = stream.peek(1);
221
+ if (second?.kind === "symbol" && second.value.toUpperCase() === ".EQU") {
222
+ const target = stream.consumeSymbol();
223
+ stream.consumeSymbol();
224
+ const expr = parseExpression(stream, new Set());
225
+ stream.expectEnd();
226
+ return { kind: "assignment", target, expr, isLocationCounter: false };
227
+ }
203
228
  const upper = stream.consumeSymbol().toUpperCase();
204
229
  if (upper in DIRECTIVE_ALIASES) {
205
230
  const directive = DIRECTIVE_ALIASES[upper];
@@ -228,7 +253,26 @@ class Parser {
228
253
  this.parseInto(statements, normalizeSource(includeContent), includeName, [...includeStack, includePath]);
229
254
  return undefined;
230
255
  }
256
+ case "equ": {
257
+ // ".equ name, expr", the same as "name = expr".
258
+ const target = stream.consumeSymbol(".equ expects a symbol name");
259
+ stream.consumePunct(",", ".equ expects a comma between the name and the value");
260
+ const expr = parseExpression(stream, new Set());
261
+ stream.expectEnd();
262
+ return { kind: "assignment", target, expr, isLocationCounter: false };
263
+ }
264
+ case "fill":
265
+ case "align": {
266
+ const args = [{ kind: "expr", expr: parseExpression(stream, new Set([","])) }];
267
+ if (stream.peek()?.kind === "punct" && stream.peek().value === ",") {
268
+ stream.consumePunct(",");
269
+ args.push({ kind: "expr", expr: parseExpression(stream, new Set()) });
270
+ }
271
+ stream.expectEnd();
272
+ return { kind: "directive", name: directive, args };
273
+ }
231
274
  case "byte":
275
+ case "text":
232
276
  case "word": {
233
277
  const args = [];
234
278
  while (!stream.isAtEnd()) {
@@ -638,13 +682,34 @@ class SymbolTable {
638
682
  constructor() {
639
683
  this.globals = new Map();
640
684
  this.locals = new Map();
685
+ this.definitions = new Map();
641
686
  }
642
687
  reset() {
643
688
  this.globals = new Map();
644
689
  this.locals = new Map();
690
+ this.definitions.clear();
645
691
  this.currentScope = undefined;
646
692
  }
647
- setLabel(label, value) {
693
+ /**
694
+ * Records where a name was first defined and rejects a second definition.
695
+ * Redefining a constant with the same value is allowed.
696
+ */
697
+ recordDefinition(label, value, isLabel, location) {
698
+ if (!location)
699
+ return;
700
+ const key = label.startsWith(".") ? `${this.currentScope ?? ""}\u0000${label}` : label;
701
+ const previous = this.definitions.get(key);
702
+ if (previous && (previous.isLabel || isLabel || previous.value !== value)) {
703
+ const kind = isLabel || previous.isLabel ? "label" : "symbol";
704
+ throw new Error(`Duplicate ${kind} "${label}" (first defined at ${previous.location.file}:${previous.location.line})`);
705
+ }
706
+ this.definitions.set(key, { location, value, isLabel });
707
+ }
708
+ setLabel(label, value, location) {
709
+ if (label.startsWith(".") && !this.currentScope) {
710
+ throw new Error(`Local label "${label}" defined before any global label`);
711
+ }
712
+ this.recordDefinition(label, value, true, location);
648
713
  if (label.startsWith(".")) {
649
714
  if (!this.currentScope) {
650
715
  throw new Error(`Local label "${label}" defined before any global label`);
@@ -668,7 +733,11 @@ class SymbolTable {
668
733
  ensureAssignment(label, expected) {
669
734
  this.ensureUnchanged(label, expected);
670
735
  }
671
- setAssignment(label, value) {
736
+ setAssignment(label, value, location) {
737
+ if (label.startsWith(".") && !this.currentScope) {
738
+ throw new Error(`Local constant "${label}" defined before any global label`);
739
+ }
740
+ this.recordDefinition(label, value, false, location);
672
741
  if (label.startsWith(".")) {
673
742
  if (!this.currentScope) {
674
743
  throw new Error(`Local constant "${label}" defined before any global label`);
@@ -814,7 +883,7 @@ class Assembler {
814
883
  processLine(line, pass) {
815
884
  for (const label of line.labels) {
816
885
  if (pass === 0) {
817
- this.symbolTable.setLabel(label, this.pc);
886
+ this.symbolTable.setLabel(label, this.pc, line.location);
818
887
  }
819
888
  else {
820
889
  this.symbolTable.ensureLabel(label, this.pc);
@@ -840,13 +909,13 @@ class Assembler {
840
909
  handleAssignment(statement, pass, location) {
841
910
  const value = this.evaluate(statement.expr, pass, location);
842
911
  if (value === undefined) {
843
- throw new AssemblyError("Assignment depends on an undefined symbol", location);
912
+ throw this.undefinedError(statement.expr, location, "Assignment depends on an undefined symbol");
844
913
  }
845
914
  if (statement.isLocationCounter) {
846
915
  this.pc = value & 0xffff;
847
916
  }
848
917
  else if (pass === 0) {
849
- this.symbolTable.setAssignment(statement.target, value);
918
+ this.symbolTable.setAssignment(statement.target, value, location);
850
919
  }
851
920
  else {
852
921
  this.symbolTable.ensureAssignment(statement.target, value);
@@ -860,17 +929,23 @@ class Assembler {
860
929
  }
861
930
  const value = this.evaluate(statement.args[0]?.expr, pass, location);
862
931
  if (value === undefined) {
863
- throw new AssemblyError("org directive requires a defined value", location);
932
+ throw this.undefinedError(statement.args[0].expr, location, "org directive requires a defined value");
864
933
  }
865
934
  this.pc = value & 0xffff;
866
935
  break;
867
936
  }
868
- case "byte": {
937
+ case "byte":
938
+ case "text": {
939
+ const isText = statement.name === "text";
869
940
  for (const arg of statement.args) {
870
941
  if (arg.kind === "string") {
871
942
  if (pass === 1) {
872
943
  for (const char of arg.value) {
873
- this.writeByte(char.charCodeAt(0), location);
944
+ const code = isText ? asciiToPetscii(char, location) : char.charCodeAt(0);
945
+ if (code > 0xff) {
946
+ throw new AssemblyError(`byte directive character '${char}' (code ${code}) does not fit in one byte`, location);
947
+ }
948
+ this.writeByte(code, location);
874
949
  }
875
950
  }
876
951
  else {
@@ -881,7 +956,10 @@ class Assembler {
881
956
  const value = this.evaluate(arg.expr, pass, location);
882
957
  if (pass === 1) {
883
958
  if (value === undefined) {
884
- throw new AssemblyError("byte directive contains undefined expression", location);
959
+ throw this.undefinedError(arg.expr, location, "byte directive contains undefined expression");
960
+ }
961
+ if (value < -128 || value > 255) {
962
+ throw new AssemblyError(`byte directive value ${value} is outside -128..255 and would be truncated`, location);
885
963
  }
886
964
  this.writeByte(value, location);
887
965
  }
@@ -908,11 +986,14 @@ class Assembler {
908
986
  const value = this.evaluate(arg.expr, pass, location);
909
987
  if (value === undefined) {
910
988
  if (pass === 1) {
911
- throw new AssemblyError("word directive contains undefined expression", location);
989
+ throw this.undefinedError(arg.expr, location, "word directive contains undefined expression");
912
990
  }
913
991
  this.pc += 2;
914
992
  }
915
993
  else if (pass === 1) {
994
+ if (value < -32768 || value > 65535) {
995
+ throw new AssemblyError(`word directive value ${value} is outside -32768..65535 and would be truncated`, location);
996
+ }
916
997
  this.writeWord(value, location);
917
998
  }
918
999
  else {
@@ -922,22 +1003,62 @@ class Assembler {
922
1003
  }
923
1004
  break;
924
1005
  }
1006
+ case "fill": {
1007
+ const count = this.evaluateDefined(statement.args[0], pass, location, "fill directive");
1008
+ if (!Number.isInteger(count) || count < 0 || count > 0x10000) {
1009
+ throw new AssemblyError(`fill directive requires a count of 0..65536, got ${count}`, location);
1010
+ }
1011
+ const value = statement.args[1] ? this.evaluateDefined(statement.args[1], pass, location, "fill directive") : 0;
1012
+ if (pass === 1) {
1013
+ this.checkByteValue(value, "fill directive", location);
1014
+ for (let index = 0; index < count; index += 1)
1015
+ this.writeByte(value, location);
1016
+ }
1017
+ else {
1018
+ this.pc += count;
1019
+ }
1020
+ break;
1021
+ }
1022
+ case "align": {
1023
+ const boundary = this.evaluateDefined(statement.args[0], pass, location, "align directive");
1024
+ if (!Number.isInteger(boundary) || boundary < 1 || boundary > 0x10000) {
1025
+ throw new AssemblyError(`align directive requires a boundary of 1..65536, got ${boundary}`, location);
1026
+ }
1027
+ const value = statement.args[1] ? this.evaluateDefined(statement.args[1], pass, location, "align directive") : 0;
1028
+ const padding = (boundary - (this.pc % boundary)) % boundary;
1029
+ if (pass === 1) {
1030
+ this.checkByteValue(value, "align directive", location);
1031
+ for (let index = 0; index < padding; index += 1)
1032
+ this.writeByte(value, location);
1033
+ }
1034
+ else {
1035
+ this.pc += padding;
1036
+ }
1037
+ break;
1038
+ }
1039
+ case "equ": {
1040
+ // Parsed as an assignment; never reaches the directive handler.
1041
+ break;
1042
+ }
925
1043
  case "reserve": {
926
1044
  if (statement.args.length !== 1 || statement.args[0]?.kind !== "expr") {
927
1045
  throw new AssemblyError("reserve directive expects a single expression", location);
928
1046
  }
929
1047
  const amount = this.evaluate(statement.args[0]?.expr, pass, location);
930
1048
  if (amount === undefined) {
931
- throw new AssemblyError("reserve directive requires a defined value", location);
1049
+ throw this.undefinedError(statement.args[0].expr, location, "reserve directive requires a defined value");
932
1050
  }
933
1051
  if (amount < 0) {
934
1052
  throw new AssemblyError("reserve directive requires a non-negative size", location);
935
1053
  }
936
1054
  // Reserved space advances the PC without emitting; buildPrg includes it
937
1055
  // (zero-filled) only where it adjoins emitted bytes.
1056
+ if (this.pc + amount > MAX_ADDRESS + 1) {
1057
+ throw new AssemblyError(`reserve directive of ${amount} bytes at ${formatHex(this.pc)} passes $FFFF`, location);
1058
+ }
938
1059
  if (amount > 0)
939
- this.reserved.push([this.pc, Math.min(0xffff, this.pc + amount - 1)]);
940
- this.pc = (this.pc + amount) & 0xffff;
1060
+ this.reserved.push([this.pc, this.pc + amount - 1]);
1061
+ this.pc += amount;
941
1062
  break;
942
1063
  }
943
1064
  case "include": {
@@ -957,7 +1078,8 @@ class Assembler {
957
1078
  return;
958
1079
  }
959
1080
  if (modeExpectsValue(info.mode) && info.value === undefined) {
960
- throw new AssemblyError("Undefined expression", location);
1081
+ const operand = statement.operand;
1082
+ throw this.undefinedError("expr" in operand ? operand.expr : undefined, location, "Undefined expression");
961
1083
  }
962
1084
  const opcode = lookupOpcode(statement.mnemonic, info.mode, location);
963
1085
  this.writeByte(opcode, location);
@@ -989,6 +1111,46 @@ class Assembler {
989
1111
  throw new AssemblyError(`Unhandled addressing mode ${info.mode}`, location);
990
1112
  }
991
1113
  }
1114
+ evaluateDefined(arg, pass, location, context) {
1115
+ if (arg.kind !== "expr") {
1116
+ throw new AssemblyError(`${context} expects a numeric expression`, location);
1117
+ }
1118
+ const value = this.evaluate(arg.expr, pass, location);
1119
+ if (value === undefined) {
1120
+ throw this.undefinedError(arg.expr, location, `${context} requires a defined value`);
1121
+ }
1122
+ return value;
1123
+ }
1124
+ checkByteValue(value, context, location) {
1125
+ if (value < -128 || value > 255) {
1126
+ throw new AssemblyError(`${context} value ${value} is outside -128..255 and would be truncated`, location);
1127
+ }
1128
+ }
1129
+ undefinedSymbols(expr) {
1130
+ if (!expr)
1131
+ return [];
1132
+ switch (expr.type) {
1133
+ case "number":
1134
+ return [];
1135
+ case "symbol":
1136
+ return expr.name !== "*" && this.symbolTable.get(expr.name) === undefined ? [expr.name] : [];
1137
+ case "unary":
1138
+ return this.undefinedSymbols(expr.operand);
1139
+ case "binary":
1140
+ return [...new Set([...this.undefinedSymbols(expr.left), ...this.undefinedSymbols(expr.right)])];
1141
+ default:
1142
+ return [];
1143
+ }
1144
+ }
1145
+ /** Names the undefined symbols of an expression; falls back to the generic message. */
1146
+ undefinedError(expr, location, fallback) {
1147
+ const names = this.undefinedSymbols(expr);
1148
+ if (names.length === 0) {
1149
+ return new AssemblyError(fallback, location);
1150
+ }
1151
+ const quoted = names.map((name) => `"${name}"`).join(", ");
1152
+ return new AssemblyError(`Undefined symbol${names.length > 1 ? "s" : ""} ${quoted}`, location);
1153
+ }
992
1154
  evaluate(expr, pass, location) {
993
1155
  switch (expr.type) {
994
1156
  case "number":
@@ -1071,12 +1233,17 @@ class Assembler {
1071
1233
  }
1072
1234
  }
1073
1235
  writeByte(value, location) {
1236
+ if (this.pc > MAX_ADDRESS) {
1237
+ throw new AssemblyError(`Output passes $FFFF: a byte would be written at ${formatHex(this.pc)}, outside the 64 KB address space`, location);
1238
+ }
1074
1239
  const masked = value & 0xff;
1075
1240
  this.memory[this.pc] = masked;
1076
1241
  this.written[this.pc] = 1;
1077
1242
  this.lowestWritten = Math.min(this.lowestWritten, this.pc);
1078
1243
  this.highestWritten = Math.max(this.highestWritten, this.pc);
1079
- this.pc = (this.pc + 1) & 0xffff;
1244
+ // The counter may reach $10000 so that a following write is detected; a
1245
+ // wrapped counter would silently overwrite the start of the image.
1246
+ this.pc += 1;
1080
1247
  }
1081
1248
  writeWord(value, location) {
1082
1249
  const masked = value & 0xffff;
@@ -1084,10 +1251,28 @@ class Assembler {
1084
1251
  this.writeByte((masked >> 8) & 0xff, location);
1085
1252
  }
1086
1253
  }
1254
+ /**
1255
+ * ASCII to PETSCII for .text/.ascii in the default upper-case/graphics
1256
+ * character set: a-z and A-Z both become the PETSCII upper-case codes
1257
+ * $41-$5A, newline becomes RETURN ($0D), every other ASCII character is
1258
+ * unchanged. Characters above $7F have no mapping.
1259
+ */
1260
+ function asciiToPetscii(char, location) {
1261
+ const code = char.charCodeAt(0);
1262
+ if (code > 0x7f) {
1263
+ throw new AssemblyError(`text directive character '${char}' (code ${code}) is not ASCII and has no PETSCII mapping`, location);
1264
+ }
1265
+ if (code >= 0x61 && code <= 0x7a)
1266
+ return code - 0x20;
1267
+ if (code === 0x0a)
1268
+ return 0x0d;
1269
+ return code;
1270
+ }
1087
1271
  function determineInstruction(statement, pass, evaluator, locationCounter, location) {
1088
1272
  if (statement.resolvedMode && pass === 1) {
1089
1273
  const value = statement.operand.kind === "none" ? undefined : resolveOperandValue(statement.operand, evaluator, pass, location);
1090
1274
  assertZeroPageOperand(statement.mnemonic, statement.resolvedMode, value, location);
1275
+ assertOperandRange(statement.mnemonic, statement.resolvedMode, value, location);
1091
1276
  const size = instructionSize(statement.resolvedMode);
1092
1277
  return {
1093
1278
  mode: statement.resolvedMode,
@@ -1207,6 +1392,44 @@ function assertZeroPageOperand(mnemonic, mode, value, location) {
1207
1392
  }
1208
1393
  throw new AssemblyError(`Instruction ${mnemonic} does not support ${absoluteMode} addressing (operand ${formatHex(value)} is outside zero page)`, location);
1209
1394
  }
1395
+ /**
1396
+ * Rejects operands that the encoding would truncate. Runs in the final pass
1397
+ * only, where every symbol has a value; in the first pass a forward reference
1398
+ * is still undefined and is sized as absolute.
1399
+ */
1400
+ function assertOperandRange(mnemonic, mode, value, location) {
1401
+ if (value === undefined)
1402
+ return;
1403
+ switch (mode) {
1404
+ case AddressingMode.Immediate:
1405
+ if (value < -128 || value > 255) {
1406
+ throw new AssemblyError(`Immediate operand ${value} of ${mnemonic} is outside -128..255 and would be truncated to one byte`, location);
1407
+ }
1408
+ return;
1409
+ case AddressingMode.IndirectX:
1410
+ case AddressingMode.IndirectY:
1411
+ if (value < 0 || value > 0xff) {
1412
+ const form = mode === AddressingMode.IndirectX ? "(zp,X)" : "(zp),Y";
1413
+ throw new AssemblyError(`Instruction ${mnemonic} ${form} requires a zero-page operand ($00-$FF), got ${value < 0 ? value : formatHex(value)}`, location);
1414
+ }
1415
+ return;
1416
+ case AddressingMode.ZeroPageS:
1417
+ if (value < 0 || value > 0xff) {
1418
+ throw new AssemblyError(`Stack-relative operand ${value} of ${mnemonic} is outside 0..255`, location);
1419
+ }
1420
+ return;
1421
+ case AddressingMode.Absolute:
1422
+ case AddressingMode.AbsoluteX:
1423
+ case AddressingMode.AbsoluteY:
1424
+ case AddressingMode.Indirect:
1425
+ if (value < 0 || value > 0xffff) {
1426
+ throw new AssemblyError(`Address operand ${value} of ${mnemonic} is outside $0000-$FFFF`, location);
1427
+ }
1428
+ return;
1429
+ default:
1430
+ return;
1431
+ }
1432
+ }
1210
1433
  const ZERO_PAGE_TO_ABSOLUTE_MODE = new Map([
1211
1434
  [AddressingMode.ZeroPage, AddressingMode.Absolute],
1212
1435
  [AddressingMode.ZeroPageX, AddressingMode.AbsoluteX],
@@ -0,0 +1,80 @@
1
+ /*
2
+ C64 Bridge - An MCP Server for the Commodore 64 Ultimate
3
+ Copyright (C) 2025 Christian Gleissner
4
+
5
+ Licensed under the GNU General Public License v2.0 or later.
6
+ See <https://www.gnu.org/licenses/> for details.
7
+ */
8
+ import { petsciiToAscii } from "../../petscii.js";
9
+ import { basicTokenText } from "./basicTokenizer.js";
10
+ import { ToolExecutionError } from "../errors.js";
11
+ /** Largest program image accepted, matching the 64 KiB address space. */
12
+ export const MAX_BASIC_IMAGE_BYTES = 0x10000;
13
+ function hex16(value) {
14
+ return `$${value.toString(16).toUpperCase().padStart(4, "0")}`;
15
+ }
16
+ function plainChar(byte) {
17
+ if (byte === 0x20 || byte === 0xa0)
18
+ return " ";
19
+ const ch = petsciiToAscii(Uint8Array.of(byte));
20
+ return ch.length === 1 && ch > " " && ch <= "~" ? ch : `{$${byte.toString(16).toUpperCase().padStart(2, "0")}}`;
21
+ }
22
+ /** Expands tokens outside quotes like LIST; quoted text is never detokenised. */
23
+ export function detokenizeLine(bytes) {
24
+ let out = "";
25
+ let inString = false;
26
+ for (const byte of bytes) {
27
+ if (byte === 0x22) {
28
+ inString = !inString;
29
+ out += '"';
30
+ continue;
31
+ }
32
+ if (!inString && byte >= 0x80) {
33
+ out += basicTokenText(byte) ?? plainChar(byte);
34
+ continue;
35
+ }
36
+ out += plainChar(byte);
37
+ }
38
+ return out;
39
+ }
40
+ /**
41
+ * Walks the link pointers of a tokenised BASIC program held in `image`, which
42
+ * starts at memory address `base`. Throws when a link does not move forward or
43
+ * leaves the image, so a corrupt program cannot loop.
44
+ */
45
+ export function listBasicProgram(image, base) {
46
+ if (image.length > MAX_BASIC_IMAGE_BYTES) {
47
+ throw new ToolExecutionError("BASIC program image is larger than 64 KiB", { details: { length: image.length } });
48
+ }
49
+ const lines = [];
50
+ let offset = 0;
51
+ while (offset + 2 <= image.length) {
52
+ const link = image[offset] | (image[offset + 1] << 8);
53
+ if (link === 0)
54
+ return lines;
55
+ const linkOffset = link - base;
56
+ const address = hex16(base + offset);
57
+ if (linkOffset <= offset + 4) {
58
+ throw new ToolExecutionError(`Corrupt BASIC link at ${address}: link ${hex16(link)} does not point past the line`, {
59
+ details: { address, link },
60
+ });
61
+ }
62
+ if (linkOffset > image.length) {
63
+ throw new ToolExecutionError(`Corrupt BASIC link at ${address}: link ${hex16(link)} is outside the program area`, {
64
+ details: { address, link },
65
+ });
66
+ }
67
+ const number = image[offset + 2] | (image[offset + 3] << 8);
68
+ let end = offset + 4;
69
+ while (end < linkOffset && image[end] !== 0)
70
+ end += 1;
71
+ if (end >= linkOffset) {
72
+ throw new ToolExecutionError(`Corrupt BASIC line ${number} at ${address}: no end-of-line byte before the next link`, {
73
+ details: { address, number },
74
+ });
75
+ }
76
+ lines.push({ number, text: detokenizeLine(image.subarray(offset + 4, end)) });
77
+ offset = linkOffset;
78
+ }
79
+ return lines;
80
+ }
@@ -7,6 +7,7 @@ See <https://www.gnu.org/licenses/> for details.
7
7
  */
8
8
  import { Buffer } from "node:buffer";
9
9
  import { asciiToPetscii, encodeStringWithNames } from "../../petscii.js";
10
+ import { ToolValidationError } from "../errors.js";
10
11
  const DEFAULT_START_ADDRESS = 0x0801;
11
12
  const REM_TOKEN = 0x8f;
12
13
  const DATA_TOKEN = 0x83;
@@ -14,6 +15,10 @@ const PRINT_TOKEN = 0x99;
14
15
  // Highest line number the BASIC V2 line parser (LINGET) accepts; larger values give ?SYNTAX ERROR.
15
16
  const MAX_LINE_NUMBER = 63999;
16
17
  const TOKEN_TABLE = buildTokenTable();
18
+ /** Keyword text for a BASIC V2 token byte (0x80-0xCB), or undefined. */
19
+ export function basicTokenText(byte) {
20
+ return TOKEN_TABLE.find((entry) => entry.byte === byte)?.text;
21
+ }
17
22
  export function basicToPrg(source, options) {
18
23
  const startAddress = options?.startAddress ?? DEFAULT_START_ADDRESS;
19
24
  const normalizedSource = normalize(source);
@@ -21,18 +26,18 @@ export function basicToPrg(source, options) {
21
26
  // Like typing lines into the C64, lines are kept in ascending order, a later
22
27
  // duplicate replaces the earlier line, and a bare line number deletes it.
23
28
  const programLines = new Map();
24
- for (const rawLine of lines) {
29
+ for (const [lineIndex, rawLine] of lines.entries()) {
25
30
  const trimmedRight = rawLine.replace(/\s+$/u, "");
26
31
  if (trimmedRight === "") {
27
32
  continue;
28
33
  }
29
34
  const match = /^\s*(\d+)\s*(.*)$/u.exec(trimmedRight);
30
35
  if (!match) {
31
- throw new Error(`Expected line number at: "${rawLine}"`);
36
+ throw new ToolValidationError(`Expected a line number at the start of line ${lineIndex + 1} of the program: "${rawLine}"`, { path: "$.program" });
32
37
  }
33
38
  const lineNumber = Number.parseInt(match[1], 10);
34
39
  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})`);
40
+ throw new ToolValidationError(`Line number out of range: ${match[1]} on line ${lineIndex + 1} of the program (C64 BASIC accepts 0-${MAX_LINE_NUMBER})`, { path: "$.program" });
36
41
  }
37
42
  const content = match[2] ?? "";
38
43
  if (content === "") {
@@ -102,6 +102,20 @@ export function disassemble(bytes, baseAddress, countOrOptions, symbols) {
102
102
  continue;
103
103
  }
104
104
  const size = modeSize(op.mode);
105
+ if (offset + size > bytes.length) {
106
+ // The input ends inside this instruction: its operand bytes are unknown,
107
+ // so show the bytes that exist as data instead of inventing an operand.
108
+ const remaining = Array.from(bytes.subarray(offset));
109
+ lines.push({
110
+ address: instrAddr,
111
+ bytes: remaining,
112
+ label,
113
+ mnemonic: ".byte",
114
+ operand: remaining.map((value) => `$${hex2(value)}`).join(","),
115
+ undocumented: false,
116
+ });
117
+ break;
118
+ }
105
119
  const instrBytes = [];
106
120
  for (let i = 0; i < size && offset + i < bytes.length; i += 1) {
107
121
  instrBytes.push(bytes[offset + i] ?? 0);