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.
- package/.github/skills/assembly-program/SKILL.md +37 -0
- package/.github/skills/basic-program/SKILL.md +40 -0
- package/.github/skills/cross-platform-demo/SKILL.md +32 -0
- package/.github/skills/drive-manager/SKILL.md +33 -0
- package/.github/skills/graphics-demo/SKILL.md +30 -0
- package/.github/skills/hello-world/SKILL.md +36 -0
- package/.github/skills/memory-debug/SKILL.md +32 -0
- package/.github/skills/pr-converge/SKILL.md +33 -0
- package/.github/skills/printer-job/SKILL.md +30 -0
- package/.github/skills/sid-music/SKILL.md +35 -0
- package/.github/skills/software-launch/SKILL.md +31 -0
- package/.github/skills/stream-control/SKILL.md +29 -0
- package/.github/skills/system-control/SKILL.md +32 -0
- package/AGENTS.md +28 -7
- package/README.md +70 -15
- package/data/assembly/assembly-spec.md +2 -0
- package/dist/c64Client.js +238 -60
- package/dist/chargen.js +0 -4
- package/dist/config.js +53 -28
- package/dist/device.js +92 -37
- package/dist/diagnostics.js +66 -5
- package/dist/hostPaths.js +128 -0
- package/dist/logger.js +3 -2
- package/dist/loggingHttpClient.js +71 -2
- package/dist/mcp-server.js +416 -291
- package/dist/numericArg.js +55 -0
- package/dist/petscii.js +13 -6
- package/dist/platform.js +12 -3
- package/dist/prompts/registry.js +6 -1
- package/dist/redact.js +166 -0
- package/dist/tools/access.js +66 -0
- package/dist/tools/debug.js +6 -0
- package/dist/tools/developer.js +77 -36
- package/dist/tools/errors.js +54 -3
- package/dist/tools/input.js +25 -8
- package/dist/tools/machineControl.js +113 -21
- package/dist/tools/memory.js +135 -30
- package/dist/tools/meta/artifacts.js +5 -5
- package/dist/tools/meta/background.js +216 -54
- package/dist/tools/meta/compilation.js +6 -5
- package/dist/tools/meta/config.js +20 -10
- package/dist/tools/meta/configInventory.js +25 -2
- package/dist/tools/meta/diagnostics.js +2 -1
- package/dist/tools/meta/filesystem.js +16 -14
- package/dist/tools/meta/graphics.js +48 -20
- package/dist/tools/meta/memory.js +45 -19
- package/dist/tools/meta/program.js +10 -7
- package/dist/tools/meta/screen.js +116 -25
- package/dist/tools/pauseGuard.js +23 -0
- package/dist/tools/pollValidator.js +53 -8
- package/dist/tools/programRunners.js +62 -18
- package/dist/tools/registry/batch.js +17 -11
- package/dist/tools/registry/config.js +9 -2
- package/dist/tools/registry/disk.js +12 -2
- package/dist/tools/registry/drive.js +3 -0
- package/dist/tools/registry/extract.js +3 -0
- package/dist/tools/registry/graphics.js +59 -6
- package/dist/tools/registry/index.js +17 -1
- package/dist/tools/registry/memory.js +11 -1
- package/dist/tools/registry/platform.js +4 -1
- package/dist/tools/registry/printer.js +2 -1
- package/dist/tools/registry/program.js +5 -1
- package/dist/tools/registry/rag.js +1 -0
- package/dist/tools/registry/sound.js +12 -1
- package/dist/tools/registry/stream.js +2 -1
- package/dist/tools/registry/system.js +8 -1
- package/dist/tools/schema.js +7 -7
- package/dist/tools/streaming.js +34 -4
- package/dist/tools/translation/assembler.js +247 -24
- package/dist/tools/translation/basicDetokenizer.js +80 -0
- package/dist/tools/translation/basicTokenizer.js +8 -3
- package/dist/tools/translation/disassembler.js +14 -0
- package/dist/tools/types.js +258 -7
- package/dist/tools/vice.js +5 -16
- package/dist/vicBitmap.js +1 -1
- package/dist/vice/mockServer.js +3 -0
- package/dist/vice/process.js +19 -0
- package/dist/vice/resourcePolicy.js +36 -0
- package/dist/vice/viceClient.js +22 -5
- package/doc/vice/support-matrix.md +17 -11
- package/mcp/schemas/c64_batch.schema.json +2 -1
- package/mcp/schemas/c64_config.schema.json +33 -466
- package/mcp/schemas/c64_debug.schema.json +3 -602
- package/mcp/schemas/c64_disk.schema.json +17 -344
- package/mcp/schemas/c64_drive.schema.json +2 -157
- package/mcp/schemas/c64_extract.schema.json +10 -364
- package/mcp/schemas/c64_graphics.schema.json +19 -426
- package/mcp/schemas/c64_input.schema.json +5 -318
- package/mcp/schemas/c64_memory.schema.json +23 -491
- package/mcp/schemas/c64_printer.schema.json +3 -258
- package/mcp/schemas/c64_program.schema.json +16 -491
- package/mcp/schemas/c64_rag.schema.json +1 -81
- package/mcp/schemas/c64_select_backend.schema.json +2 -36
- package/mcp/schemas/c64_sound.schema.json +20 -822
- package/mcp/schemas/c64_stream.schema.json +4 -75
- package/mcp/schemas/c64_system.schema.json +28 -348
- package/mcp/schemas/c64_vice.schema.json +2 -71
- package/mcp/server.json +1 -1
- package/mcp/tools.json +1990 -6900
- package/mcp.json +21 -1
- package/package.json +19 -8
- package/scripts/mockC64Server.mjs +44 -0
- package/scripts/run-coverage.mjs +27 -1
- package/scripts/smoke-run.sh +3 -2
- package/scripts/start.mjs +23 -1
- package/server.json +53 -3
- package/dist/.tsbuildinfo +0 -1
- package/doc/agent-state-spec.md +0 -243
- package/doc/plans/bun/baseline-metrics.md +0 -16
- package/doc/plans/bun/migration-checklist.md +0 -55
- package/doc/plans/bun/migration.md +0 -83
- package/doc/plans/improve-knowledge/notes.md +0 -43
- package/doc/plans/improve-knowledge/plan.md +0 -121
- package/doc/plans/improve-knowledge/rollout.md +0 -110
- package/doc/plans/meta-tools/README.md +0 -110
- package/doc/plans/meta-tools/analysis.md +0 -272
- package/doc/plans/meta-tools/catalog.md +0 -435
- package/doc/plans/meta-tools/notes.md +0 -192
- package/doc/plans/meta-tools/rollout.md +0 -368
- package/doc/plans/prompts/README.md +0 -96
- package/doc/plans/prompts/user-prompt-ideas.md +0 -147
- package/doc/plans/tool-consolidation/notes.md +0 -28
- package/doc/plans/tool-consolidation/plan.md +0 -169
- package/doc/plans/tool-consolidation/rollout.md +0 -104
- package/doc/plans/vice-backend-switch/plan.md +0 -486
- package/doc/plans/vice-backend-switch-2/plan.md +0 -48
- package/doc/plans/vice-support/notes.md +0 -63
- package/doc/plans/vice-support/plan.md +0 -88
- package/doc/plans/vice-support/rollout.md +0 -99
- package/doc/reviews/audit.md +0 -143
- package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
- package/doc/reviews/hardening/01-fable/prompt.md +0 -98
- package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
- package/doc/reviews/hardening/01-fable/review.md +0 -1234
- package/doc/reviews/vice-support-review.md +0 -185
- 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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
940
|
-
this.pc
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|