@henols/c64-re-tools 1.1.0 → 2.0.0-rc.11

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 (162) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -37
  3. package/distribution/package.ts +21 -0
  4. package/distribution/plugin.ts +122 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +76 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +110 -0
  8. package/skills/c64-assembler/scripts/compare.ts +84 -0
  9. package/skills/c64-basic/SKILL.md +66 -0
  10. package/skills/c64-basic/scripts/basic.ts +46 -0
  11. package/skills/c64-disk/SKILL.md +93 -0
  12. package/skills/c64-disk/scripts/containers.ts +124 -0
  13. package/skills/c64-disk/scripts/disk.ts +120 -0
  14. package/skills/c64-emulator/SKILL.md +134 -0
  15. package/skills/c64-knowledge/SKILL.md +192 -0
  16. package/skills/c64-knowledge/scripts/knowledge.ts +613 -0
  17. package/skills/c64-memory-map/SKILL.md +79 -0
  18. package/skills/c64-memory-map/scripts/memmap.ts +92 -0
  19. package/skills/c64-provenance/SKILL.md +110 -0
  20. package/skills/c64-provenance/scripts/compare.ts +152 -0
  21. package/skills/c64-provenance/scripts/provenance.ts +334 -0
  22. package/skills/c64-provenance/scripts/runtime.ts +191 -0
  23. package/skills/c64-provenance/scripts/separate.ts +140 -0
  24. package/skills/c64-reverse-engineering/SKILL.md +144 -0
  25. package/skills/c64-source/SKILL.md +133 -0
  26. package/skills/c64-source/scripts/generate.ts +503 -0
  27. package/skills/c64-source/scripts/loader.ts +589 -0
  28. package/skills/c64-source/scripts/opcodes.ts +61 -0
  29. package/skills/c64-source/scripts/source.ts +673 -0
  30. package/skills/c64-static-analysis/SKILL.md +120 -0
  31. package/skills/c64-static-analysis/scripts/analyze.ts +293 -0
  32. package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
  33. package/skills/c64-static-analysis/scripts/findings.ts +75 -0
  34. package/skills/c64-static-analysis/scripts/idioms.ts +149 -0
  35. package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
  36. package/skills/c64-static-analysis/scripts/pointers.ts +108 -0
  37. package/skills/c64-testing/SKILL.md +103 -0
  38. package/skills/c64-testing/references/scenario-format.md +116 -0
  39. package/skills/c64-testing/scripts/checklist.ts +51 -0
  40. package/skills/c64-testing/scripts/run.ts +189 -0
  41. package/skills/c64-testing/scripts/scenario.ts +493 -0
  42. package/skills/c64-testing/scripts/test.ts +65 -0
  43. package/skills/c64-unpacker/SKILL.md +135 -0
  44. package/skills/c64-unpacker/scripts/evidence.ts +207 -0
  45. package/skills/c64-unpacker/scripts/loads.ts +209 -0
  46. package/skills/c64-unpacker/scripts/machine.ts +80 -0
  47. package/skills/c64-unpacker/scripts/stages.ts +130 -0
  48. package/skills/c64-unpacker/scripts/unpack.ts +683 -0
  49. package/src/c64.ts +411 -0
  50. package/src/cli/ap-sdk.ts +44 -0
  51. package/src/cli/cleanup.ts +194 -0
  52. package/src/cli/main.ts +125 -0
  53. package/src/cli/status.ts +61 -0
  54. package/src/host/main.ts +134 -0
  55. package/src/host/server.ts +392 -0
  56. package/src/host/tools/c1541.ts +284 -0
  57. package/src/host/tools/index.ts +35 -0
  58. package/src/host/tools/petcat.ts +77 -0
  59. package/src/host/tools/status.ts +47 -0
  60. package/src/host/tools/version.ts +41 -0
  61. package/src/host/vice/adapter.ts +804 -0
  62. package/src/host/vice/binary-monitor.ts +389 -0
  63. package/src/host/vice/chips.ts +118 -0
  64. package/src/host/vice/connect.ts +34 -0
  65. package/src/host/vice/process.ts +413 -0
  66. package/src/host/vice/screen.ts +184 -0
  67. package/src/host/vice/session.ts +1426 -0
  68. package/src/host/vice/text-monitor.ts +223 -0
  69. package/src/host-client/autostart.ts +55 -0
  70. package/src/host-client/connect.ts +241 -0
  71. package/src/host-client/coverage.ts +63 -0
  72. package/src/host-client/image.ts +63 -0
  73. package/src/host-client/steps.ts +170 -0
  74. package/src/host-client/tools.ts +89 -0
  75. package/src/host-client/transfer.ts +126 -0
  76. package/src/host-client/vice-session.ts +316 -0
  77. package/src/knowledge/database.ts +132 -0
  78. package/src/knowledge/expression.ts +209 -0
  79. package/src/knowledge/history.ts +169 -0
  80. package/src/knowledge/import.ts +392 -0
  81. package/src/knowledge/read.ts +392 -0
  82. package/src/knowledge/schema.ts +250 -0
  83. package/src/knowledge/write.ts +837 -0
  84. package/src/mcp/main.ts +134 -0
  85. package/src/mcp/output.ts +32 -0
  86. package/src/mcp/server.ts +316 -0
  87. package/src/mcp/tools/debug.ts +327 -0
  88. package/src/mcp/tools/execution.ts +117 -0
  89. package/src/mcp/tools/input.ts +72 -0
  90. package/src/mcp/tools/machine.ts +100 -0
  91. package/src/mcp/tools/media.ts +129 -0
  92. package/src/mcp/tools/memory.ts +250 -0
  93. package/src/mcp/tools/video.ts +271 -0
  94. package/src/native/acme.ts +116 -0
  95. package/src/native/discover.ts +179 -0
  96. package/src/native/dxa.ts +166 -0
  97. package/src/native/ghidra/analyze.ts +86 -0
  98. package/src/native/ghidra/index.ts +173 -0
  99. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  100. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  101. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  102. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  103. package/src/native/ghidra/scripts/C64Export.java +274 -0
  104. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  105. package/src/native/local.ts +34 -0
  106. package/src/native/processes.ts +350 -0
  107. package/src/native/run.ts +172 -0
  108. package/src/native/staging.ts +83 -0
  109. package/src/native/status.ts +113 -0
  110. package/src/native/types.ts +257 -0
  111. package/src/native/watchdog.ts +78 -0
  112. package/src/package.ts +21 -0
  113. package/src/platform.ts +441 -0
  114. package/src/project.ts +39 -0
  115. package/src/protocol/framing.ts +132 -0
  116. package/src/protocol/messages.ts +259 -0
  117. package/src/protocol/tools.ts +266 -0
  118. package/src/protocol/vice.ts +1348 -0
  119. package/src/script.ts +60 -0
  120. package/src/time.ts +15 -0
  121. package/src/trace.ts +194 -0
  122. package/THIRD-PARTY-NOTICES.md +0 -26
  123. package/bin/cli.mjs +0 -283
  124. package/skills/acme-build/SKILL.md +0 -260
  125. package/skills/acme-build/scripts/acme.mjs +0 -341
  126. package/skills/acme-build/template.a +0 -53
  127. package/skills/c64-disk-access/SKILL.md +0 -159
  128. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  129. package/skills/c64-memory-mapping/SKILL.md +0 -631
  130. package/skills/c64-memory-mapping/memmap.json +0 -8800
  131. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  132. package/skills/c64-petcat/SKILL.md +0 -87
  133. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  134. package/skills/c64-program-recon/SKILL.md +0 -706
  135. package/skills/c64-program-recon/references/control-flow.md +0 -180
  136. package/skills/c64-program-recon/references/graphics.md +0 -79
  137. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  138. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  139. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  140. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  141. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  142. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  143. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  144. package/skills/c64-provenance-diff/SKILL.md +0 -292
  145. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  146. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  147. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  148. package/skills/c64-ram-capture/SKILL.md +0 -474
  149. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  150. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  151. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  152. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  153. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  154. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  155. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  156. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  157. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  158. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  159. package/skills/c64-ram-capture/transients/README.md +0 -137
  160. package/skills/routine-queue-walker/SKILL.md +0 -394
  161. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  162. package/skills/vice-wedge-triage/SKILL.md +0 -269
package/src/c64.ts ADDED
@@ -0,0 +1,411 @@
1
+ const ADDRESS_PATTERN = /^\$([0-9a-fA-F]{4})$/;
2
+
3
+ /** Returns the value when it is a C64 address (0-$ffff). Throws RangeError otherwise. */
4
+ function checkAddress(value: number): number {
5
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
6
+ throw new RangeError(`C64 address out of range: ${value}`);
7
+ }
8
+ return value;
9
+ }
10
+
11
+ export function parseC64Address(value: string): number {
12
+ const match = ADDRESS_PATTERN.exec(value);
13
+ if (match === null) {
14
+ throw new TypeError(`Invalid C64 address: ${value}`);
15
+ }
16
+ return checkAddress(Number.parseInt(match[1]!, 16));
17
+ }
18
+
19
+ export function formatC64Address(value: number): string {
20
+ return `$${checkAddress(value).toString(16).padStart(4, "0")}`;
21
+ }
22
+
23
+ /** A byte as $xx. Throws RangeError outside 0-255. */
24
+ export function formatC64Byte(value: number): string {
25
+ if (!Number.isInteger(value) || value < 0 || value > 0xff) throw new RangeError(`C64 byte out of range: ${value}`);
26
+ return `$${value.toString(16).padStart(2, "0")}`;
27
+ }
28
+
29
+ /**
30
+ * The memory a PRG loads into: the 2-byte load address, then the body.
31
+ * Throws RangeError for a file shorter than 3 bytes or one that runs past $ffff.
32
+ */
33
+ export function prgRange(prg: Uint8Array): { start: number; end: number; body: Uint8Array } {
34
+ if (prg.length < 3) throw new RangeError("A PRG has a 2-byte load address and at least one byte.");
35
+ const start = prg[0]! | (prg[1]! << 8);
36
+ if (start + prg.length - 2 > 0x10000) throw new RangeError("The PRG runs past $ffff.");
37
+ return { start, end: start + prg.length - 3, body: prg.subarray(2) };
38
+ }
39
+
40
+ /** How 6502 code refers to an address. A pointer is code that builds the address from immediates (D65). */
41
+ export const REFERENCE_KINDS = ["call", "jump", "read", "write", "reference", "pointer"] as const;
42
+ export type ReferenceKind = (typeof REFERENCE_KINDS)[number];
43
+
44
+ /** A symbol or label name: assembler label syntax, at most 64 characters; a leading dot marks a local label. */
45
+ export const SYMBOL_NAME = /^\.?[A-Za-z_][A-Za-z0-9_]{0,63}$/;
46
+
47
+ /** The VIC-II's 16 KiB bank base, from CIA 2 port A bits 0-1 (inverted); undriven lines read high. */
48
+ export function vicBankBase(cia2PortA: number, cia2DdrA = 0xff): number {
49
+ const lines = (cia2PortA | ~cia2DdrA) & 0x03;
50
+ return (3 - lines) * 0x4000;
51
+ }
52
+
53
+ /** The offsets inside the VIC-II bank that $d018 selects. */
54
+ export function d018Offsets(d018: number): { screen: number; characters: number; bitmap: number } {
55
+ return { screen: ((d018 >> 4) & 0x0f) * 0x400, characters: ((d018 >> 1) & 0x07) * 0x800, bitmap: (d018 & 0x08) !== 0 ? 0x2000 : 0 };
56
+ }
57
+
58
+ /**
59
+ * Converts text to the PETSCII bytes the C64 keyboard types after a reset
60
+ * (upper case/graphics mode). Letters of either case type the unshifted key,
61
+ * which shows as an upper-case letter. "\n" and "\r\n" type RETURN.
62
+ * Throws RangeError naming the first character that has no key.
63
+ */
64
+ export function textToPetscii(text: string): Uint8Array {
65
+ const bytes: number[] = [];
66
+ const normalized = text.replace(/\r\n/g, "\n");
67
+ for (const character of normalized) {
68
+ const code = character.codePointAt(0)!;
69
+ if (character === "\n" || character === "\r") bytes.push(0x0d);
70
+ else if (code >= 0x61 && code <= 0x7a) bytes.push(code - 0x20);
71
+ else if (code >= 0x20 && code <= 0x5b) bytes.push(code);
72
+ else if (character === "]") bytes.push(0x5d);
73
+ else if (character === "£") bytes.push(0x5c);
74
+ else if (character === "↑" || character === "^") bytes.push(0x5e);
75
+ else if (character === "←") bytes.push(0x5f);
76
+ else throw new RangeError(`There is no C64 key for ${JSON.stringify(character)}`);
77
+ }
78
+ return Uint8Array.from(bytes);
79
+ }
80
+
81
+ /**
82
+ * Shows a CBM file or disk name (PETSCII bytes) as text, the way a directory
83
+ * listing shows it in upper case/graphics mode. Bytes $20-$5f are their
84
+ * characters ($5c is £, $5e is ↑, $5f is ←); every other byte is written as
85
+ * {$xx}, so the text converts back to the same bytes.
86
+ */
87
+ export function petsciiNameToText(bytes: Uint8Array): string {
88
+ let text = "";
89
+ for (const byte of bytes) {
90
+ if (byte === 0x5c) text += "£";
91
+ else if (byte === 0x5e) text += "↑";
92
+ else if (byte === 0x5f) text += "←";
93
+ else if (byte >= 0x20 && byte <= 0x5d) text += String.fromCharCode(byte);
94
+ else text += `{$${byte.toString(16).padStart(2, "0")}}`;
95
+ }
96
+ return text;
97
+ }
98
+
99
+ /**
100
+ * The reverse of petsciiNameToText. Letters of either case are the unshifted
101
+ * letters ($41-$5a). Throws RangeError for text that is no name.
102
+ */
103
+ export function textToPetsciiName(text: string): Uint8Array {
104
+ const bytes: number[] = [];
105
+ const escape = /^\{\$([0-9a-fA-F]{2})\}/;
106
+ for (let index = 0; index < text.length; ) {
107
+ const escaped = escape.exec(text.slice(index));
108
+ if (escaped !== null) {
109
+ bytes.push(Number.parseInt(escaped[1]!, 16));
110
+ index += escaped[0].length;
111
+ continue;
112
+ }
113
+ const character = text[index]!;
114
+ const code = character.charCodeAt(0);
115
+ if (code >= 0x61 && code <= 0x7a) bytes.push(code - 0x20);
116
+ else if (character === "£") bytes.push(0x5c);
117
+ else if (character === "↑") bytes.push(0x5e);
118
+ else if (character === "←") bytes.push(0x5f);
119
+ else if (code >= 0x20 && code <= 0x5d) bytes.push(code);
120
+ else throw new RangeError(`${JSON.stringify(character)} is not a name character. Write other bytes as {$xx}.`);
121
+ index++;
122
+ }
123
+ return Uint8Array.from(bytes);
124
+ }
125
+
126
+ // C64 BASIC V2 program text, as LOAD puts it in memory.
127
+
128
+ /** BASIC refuses higher line numbers when a line is entered. */
129
+ const MAX_LINE_NUMBER = 63999;
130
+
131
+ const TOKEN = { data: 0x83, rem: 0x8f, sys: 0x9e, usr: 0xb7, plus: 0xaa, minus: 0xab, times: 0xac, divide: 0xad, pi: 0xff } as const;
132
+ const QUOTE = 0x22;
133
+ const COLON = 0x3a;
134
+
135
+ export interface TokenizedLine {
136
+ number: number;
137
+ /** The line's bytes after the line number, without the terminating zero. */
138
+ tokens: Uint8Array;
139
+ }
140
+
141
+ /**
142
+ * A machine-code handoff in BASIC program text: SYS with a constant address, a
143
+ * SYS whose argument needs run-time values (with the argument as text), or a USR.
144
+ */
145
+ export type BasicHandoff =
146
+ | { kind: "sys"; line: number; address: number }
147
+ | { kind: "sys"; line: number; computed: true; expression: string }
148
+ | { kind: "usr"; line: number; computed: true };
149
+
150
+ /**
151
+ * `basicEnd` is the first address that holds no BASIC; `textBytes` is the
152
+ * number of file bytes (load address included) before the end of the BASIC text.
153
+ */
154
+ export type TokenizedProgram = { loadAddress: number; basicEnd: number; textBytes: number; lines: TokenizedLine[] } | { reason: string };
155
+
156
+ /** BASIC text cannot start below the screen: there are the zero page, the stack and the system pages. */
157
+ const MIN_BASIC_LOAD = 0x0400;
158
+
159
+ /**
160
+ * Splits a PRG into BASIC lines the way BASIC relinks a loaded program: each
161
+ * line ends at a zero byte, and a link whose high byte is zero ends the
162
+ * program. A real end marker is two zero bytes; when only the high byte is
163
+ * zero, the "marker" is the first machine code after the BASIC text (SYS
164
+ * lines that jump right behind themselves). The stored links are not read:
165
+ * BASIC relinks a loaded program, and a crack can store odd links to hide
166
+ * lines from LIST. A load address below $0400 is a memory dump (F-12), and so
167
+ * is a file whose bytes run past $ffff: a 64 KiB dump from $0000 always does
168
+ * (b2/01, R3-02).
169
+ */
170
+ export function parseTokenized(program: Uint8Array): TokenizedProgram {
171
+ if (program.length < 4) return { reason: "The file is too short for a BASIC program." };
172
+ const loadAddress = program[0]! | (program[1]! << 8);
173
+ if (loadAddress < MIN_BASIC_LOAD) {
174
+ return { reason: `The load address ${formatC64Address(loadAddress)} is below $0400, in the zero page, the stack or the system pages. The file is a memory dump or machine code, not a BASIC program.` };
175
+ }
176
+ if (loadAddress + program.length - 2 > 0x10000) {
177
+ return { reason: `The file holds ${program.length - 2} bytes from ${formatC64Address(loadAddress)}, which runs past $ffff. The file is a memory dump, not a BASIC program.` };
178
+ }
179
+ const lines: TokenizedLine[] = [];
180
+ let at = 2;
181
+ const ended = (basicEnd: number): TokenizedProgram => ({ loadAddress, basicEnd, textBytes: at, lines });
182
+ // File offset k (from 2 on) loads at loadAddress + k - 2.
183
+ for (;;) {
184
+ // A file that ends right after a line leaves the end marker to the zeros after it in memory;
185
+ // basicEnd is the address after that marker, as when the file holds it.
186
+ if (at === program.length && lines.length > 0) return ended(loadAddress + at);
187
+ if (at + 1 >= program.length) return { reason: "The BASIC program has no end marker." };
188
+ if (program[at + 1] === 0) {
189
+ // An end marker before any line, with more bytes after it, is how machine code at a non-BASIC address often starts.
190
+ if (lines.length === 0 && at + 2 < program.length) return { reason: "The file holds no BASIC line before other bytes, so it is no BASIC program." };
191
+ const marker = program[at] === 0;
192
+ return ended(loadAddress + (marker ? at : at - 2));
193
+ }
194
+ if (at + 4 > program.length) return { reason: "The last BASIC line is cut off." };
195
+ const number = program[at + 2]! | (program[at + 3]! << 8);
196
+ if (number > MAX_LINE_NUMBER) return { reason: `Line number ${number} is above ${MAX_LINE_NUMBER}, so the bytes are no BASIC program.` };
197
+ const end = program.indexOf(0, at + 4);
198
+ if (end < 0) return { reason: `BASIC line ${number} has no end.` };
199
+ lines.push({ number, tokens: program.subarray(at + 4, end) });
200
+ at = end + 1;
201
+ }
202
+ }
203
+
204
+ /** BASIC V2 keywords for the tokens $80 to $cb, in token order. */
205
+ const KEYWORDS = [
206
+ "END", "FOR", "NEXT", "DATA", "INPUT#", "INPUT", "DIM", "READ", "LET", "GOTO", "RUN", "IF", "RESTORE", "GOSUB", "RETURN", "REM",
207
+ "STOP", "ON", "WAIT", "LOAD", "SAVE", "VERIFY", "DEF", "POKE", "PRINT#", "PRINT", "CONT", "LIST", "CLR", "CMD", "SYS", "OPEN",
208
+ "CLOSE", "GET", "NEW", "TAB(", "TO", "FN", "SPC(", "THEN", "NOT", "STEP", "+", "-", "*", "/", "^", "AND",
209
+ "OR", ">", "=", "<", "SGN", "INT", "ABS", "USR", "FRE", "POS", "SQR", "RND", "LOG", "EXP", "COS", "SIN",
210
+ "TAN", "ATN", "PEEK", "LEN", "STR$", "VAL", "ASC", "CHR$", "LEFT$", "RIGHT$", "MID$", "GO",
211
+ ] as const;
212
+
213
+ /**
214
+ * One BASIC line as text: keywords for tokens, text in quotes and after REM
215
+ * as it is, and {$xx} for bytes that are no character. For evidence, not a listing.
216
+ */
217
+ export function basicLineText(line: TokenizedLine): string {
218
+ return `${line.number} ${basicText(line.tokens)}`;
219
+ }
220
+
221
+ /** Tokenized BASIC bytes as text, as for basicLineText; pi is π. */
222
+ function basicText(tokens: Uint8Array): string {
223
+ let text = "";
224
+ let literal = false;
225
+ for (const byte of tokens) {
226
+ if (byte === QUOTE) literal = !literal;
227
+ if (!literal && byte >= 0x80 && byte < 0x80 + KEYWORDS.length) {
228
+ text += KEYWORDS[byte - 0x80];
229
+ if (byte === TOKEN.rem) literal = true;
230
+ } else if (!literal && byte === TOKEN.pi) {
231
+ text += "π";
232
+ } else {
233
+ text += petsciiNameToText(Uint8Array.of(byte));
234
+ }
235
+ }
236
+ return text;
237
+ }
238
+
239
+ /**
240
+ * Evaluates a SYS argument that holds only numbers, π, + - * / and brackets.
241
+ * Returns undefined for anything that needs run-time values.
242
+ */
243
+ export function constantExpression(bytes: Uint8Array): number | undefined {
244
+ const tokens = [...bytes].filter((byte) => byte !== 0x20);
245
+ let at = 0;
246
+ const peek = () => tokens[at];
247
+ const isDigit = () => peek() !== undefined && peek()! >= 0x30 && peek()! <= 0x39;
248
+ // A number as BASIC reads it: digits and a point, then an optional E with an
249
+ // optional sign (token or character) and exponent digits, so 2E3 is 2000.
250
+ const number = (): number | undefined => {
251
+ let text = "";
252
+ while (isDigit() || peek() === 0x2e) text += String.fromCharCode(tokens[at++]!);
253
+ if (text === "" || text === ".") return undefined;
254
+ if (peek() === 0x45) {
255
+ at++;
256
+ let exponent = "";
257
+ if (peek() === TOKEN.minus || peek() === 0x2d) {
258
+ exponent = "-";
259
+ at++;
260
+ } else if (peek() === TOKEN.plus || peek() === 0x2b) at++;
261
+ let digits = "";
262
+ while (isDigit()) digits += String.fromCharCode(tokens[at++]!);
263
+ text += `e${exponent}${digits === "" ? "0" : digits}`;
264
+ }
265
+ return Number(text);
266
+ };
267
+ const primary = (): number | undefined => {
268
+ if (peek() === TOKEN.minus || peek() === TOKEN.plus) {
269
+ const negative = tokens[at++] === TOKEN.minus;
270
+ const value = primary();
271
+ return value === undefined ? undefined : negative ? -value : value;
272
+ }
273
+ if (peek() === 0x28) {
274
+ at++;
275
+ const value = sum();
276
+ if (value === undefined || tokens[at++] !== 0x29) return undefined;
277
+ return value;
278
+ }
279
+ if (peek() === TOKEN.pi) {
280
+ at++;
281
+ return Math.PI;
282
+ }
283
+ return number();
284
+ };
285
+ const product = (): number | undefined => {
286
+ let value = primary();
287
+ while (value !== undefined && (peek() === TOKEN.times || peek() === TOKEN.divide)) {
288
+ const operator = tokens[at++];
289
+ const right = primary();
290
+ if (right === undefined) return undefined;
291
+ value = operator === TOKEN.times ? value * right : value / right;
292
+ }
293
+ return value;
294
+ };
295
+ const sum = (): number | undefined => {
296
+ let value = product();
297
+ while (value !== undefined && (peek() === TOKEN.plus || peek() === TOKEN.minus)) {
298
+ const operator = tokens[at++];
299
+ const right = product();
300
+ if (right === undefined) return undefined;
301
+ value = operator === TOKEN.plus ? value + right : value - right;
302
+ }
303
+ return value;
304
+ };
305
+ const value = sum();
306
+ if (value === undefined || !Number.isFinite(value)) return undefined;
307
+ // BASIC's evaluator ends the expression at the first byte that is no operator,
308
+ // so "SYS2073 TCS-CRUNCH!" jumps to 2073. An operator
309
+ // this evaluator does not handle (^, AND, OR, a comparison) needs run-time values.
310
+ if (at < tokens.length && BASIC_OPERATORS.has(tokens[at]!)) return undefined;
311
+ return value;
312
+ }
313
+
314
+ /** BASIC V2 operator tokens: + - * / ^ AND OR > = <. */
315
+ const BASIC_OPERATORS = new Set([0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, 0xb1, 0xb2, 0xb3]);
316
+
317
+ /** SYS and USR in program text: not in strings, REM or DATA. */
318
+ export function findHandoffs(lines: TokenizedLine[]): BasicHandoff[] {
319
+ const handoffs: BasicHandoff[] = [];
320
+ for (const { number, tokens } of lines) {
321
+ let quoted = false;
322
+ let inData = false;
323
+ for (let at = 0; at < tokens.length; at++) {
324
+ const byte = tokens[at]!;
325
+ if (byte === QUOTE) quoted = !quoted;
326
+ if (quoted) continue;
327
+ if (inData) {
328
+ if (byte === COLON) inData = false;
329
+ continue;
330
+ }
331
+ if (byte === TOKEN.rem) break;
332
+ if (byte === TOKEN.data) inData = true;
333
+ else if (byte === TOKEN.usr) handoffs.push({ kind: "usr", line: number, computed: true });
334
+ else if (byte === TOKEN.sys) {
335
+ // The argument ends at the statement end or at a comma (extra SYS parameters).
336
+ let end = at + 1;
337
+ while (end < tokens.length && tokens[end] !== COLON && tokens[end] !== 0x2c) end++;
338
+ const argument = tokens.subarray(at + 1, end);
339
+ const value = constantExpression(argument);
340
+ if (value === undefined) handoffs.push({ kind: "sys", line: number, computed: true, expression: basicText(argument).trim() });
341
+ // BASIC drops the fraction; outside 0-65535 SYS stops with ILLEGAL QUANTITY and jumps nowhere.
342
+ else if (Math.trunc(value) >= 0 && Math.trunc(value) <= 0xffff) handoffs.push({ kind: "sys", line: number, address: Math.trunc(value) });
343
+ at = end - 1;
344
+ }
345
+ }
346
+ }
347
+ return handoffs;
348
+ }
349
+
350
+ // Execution coverage (D40): the byte format of a coverage map, and the 6502
351
+ // instruction lengths that turn the instruction starts in it into code ranges.
352
+ // VICE marks only the opcode byte of an executed instruction as executed.
353
+
354
+ /** One byte per address: the access flags as VICE reports them, RAM, ROM and I/O apart. */
355
+ export const COVERAGE = {
356
+ ramRead: 1 << 0,
357
+ ramWrite: 1 << 1,
358
+ ramExecute: 1 << 2,
359
+ romRead: 1 << 3,
360
+ romExecute: 1 << 4,
361
+ ioRead: 1 << 5,
362
+ ioWrite: 1 << 6,
363
+ ioExecute: 1 << 7,
364
+ } as const;
365
+
366
+ export const EXECUTE = COVERAGE.ramExecute | COVERAGE.romExecute | COVERAGE.ioExecute;
367
+ export const READ = COVERAGE.ramRead | COVERAGE.romRead | COVERAGE.ioRead;
368
+ export const WRITE = COVERAGE.ramWrite | COVERAGE.ioWrite;
369
+
370
+ /**
371
+ * The length in bytes of the NMOS 6502/6510 instruction with this opcode, for
372
+ * all 256 opcodes with the undocumented ones. The opcode is aaabbbcc: bbb
373
+ * selects the addressing mode within a group cc. JAM (KIL) and BRK count one byte.
374
+ */
375
+ export function instructionLength(opcode: number): 1 | 2 | 3 {
376
+ const group = opcode & 0b11;
377
+ const mode = (opcode >> 2) & 0b111;
378
+ if (group === 0b00) {
379
+ if (mode === 0b000) return opcode === 0x20 ? 3 : opcode === 0x00 || opcode === 0x40 || opcode === 0x60 ? 1 : 2;
380
+ return ([2, 2, 1, 3, 2, 2, 1, 3] as const)[mode]!;
381
+ }
382
+ if (group === 0b10) {
383
+ if (mode === 0b000) return opcode === 0x02 || opcode === 0x22 || opcode === 0x42 || opcode === 0x62 ? 1 : 2;
384
+ return ([2, 2, 1, 3, 1, 2, 1, 3] as const)[mode]!;
385
+ }
386
+ // Groups 01 and 11 share their addressing modes.
387
+ return ([2, 2, 2, 3, 2, 2, 3, 3] as const)[mode]!;
388
+ }
389
+
390
+ export interface CodeRange {
391
+ start: number;
392
+ end: number;
393
+ }
394
+
395
+ /**
396
+ * The executed code in RAM as ranges: each RAM instruction start in `map`
397
+ * with its operand bytes, by the opcode in `memory` (64 KiB from $0000).
398
+ * `map` starts at `base`. Adjacent and overlapping instructions merge.
399
+ */
400
+ export function executedRamCode(map: Uint8Array, base: number, memory: Uint8Array): CodeRange[] {
401
+ const ranges: CodeRange[] = [];
402
+ for (let offset = 0; offset < map.length; offset++) {
403
+ if ((map[offset]! & COVERAGE.ramExecute) === 0) continue;
404
+ const start = base + offset;
405
+ const end = Math.min(0xffff, start + instructionLength(memory[start]!) - 1);
406
+ const last = ranges.at(-1);
407
+ if (last !== undefined && start <= last.end + 1) last.end = Math.max(last.end, end);
408
+ else ranges.push({ start, end });
409
+ }
410
+ return ranges;
411
+ }
@@ -0,0 +1,44 @@
1
+ // Runs the AP SDK CLI of this package's own dependency as a bounded native
2
+ // run: its own process group, a time limit, and the whole group stopped when
3
+ // the limit ends or the CLI stops.
4
+
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { runToolOrFail, type ToolContext } from "../native/run.ts";
9
+ import { WireFailure } from "../protocol/messages.ts";
10
+
11
+ /** How long one AP SDK install, update or uninstall may take. */
12
+ export const AP_SDK_TIMEOUT_MS = 120_000;
13
+
14
+ const PLUGIN = fileURLToPath(new URL("../../distribution/plugin.ts", import.meta.url));
15
+
16
+ export interface ApSdkRun {
17
+ code: number;
18
+ stdout: string;
19
+ stderr: string;
20
+ }
21
+
22
+ /**
23
+ * Runs `ap-sdk <command> <plugin or id> <flags>`. Returns its exit status and
24
+ * what it printed. Throws WireFailure(operation-failed) with the name AP SDK
25
+ * when it does not start, stops on a signal, does not finish within the time
26
+ * limit, or the run is cancelled.
27
+ */
28
+ export async function runApSdk(
29
+ command: "install" | "uninstall",
30
+ flags: readonly string[],
31
+ context: ToolContext,
32
+ options: { cli?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv } = {},
33
+ ): Promise<ApSdkRun> {
34
+ const cli = options.cli ?? join(dirname(fileURLToPath(import.meta.resolve("@jalco/ap-sdk"))), "cli.js");
35
+ // execArgv keeps the TypeScript loader (tsx) for the plugin module under node_modules.
36
+ const argv = [process.execPath, ...process.execArgv, cli, command, command === "uninstall" ? "c64-re-tools" : PLUGIN, ...flags];
37
+ const run = await runToolOrFail("AP SDK", "The AP SDK run", { argv, cwd: process.cwd(), timeoutMs: options.timeoutMs ?? AP_SDK_TIMEOUT_MS }, {
38
+ ...context,
39
+ env: options.env ?? context.env,
40
+ });
41
+ // runToolOrFail refuses a run that did not start or that a signal stopped, so an exit status is here.
42
+ if (run.code === null) throw new WireFailure("operation-failed", "AP SDK ended without an exit status.");
43
+ return { code: run.code, stdout: run.stdout, stderr: run.stderr };
44
+ }
@@ -0,0 +1,194 @@
1
+ // Tidying that AP SDK leaves to its caller:
2
+ // - A project install must not change the home directory. AP SDK writes the
3
+ // Windsurf MCP declaration to ~/.codeium/windsurf/mcp_config.json even in
4
+ // project scope, and records it in the project manifest, so a project
5
+ // uninstall would also remove a declaration that a global install made.
6
+ // - AP SDK's uninstall deletes the installed files but leaves their
7
+ // directories, and leaves each MCP configuration file with an empty server
8
+ // list. Only empty directories and files with nothing else in them go.
9
+
10
+ import { existsSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+
14
+ const PLUGIN_ID = "c64-re-tools";
15
+
16
+ export type Scope = "project" | "global";
17
+
18
+ export interface InstallItem {
19
+ harness: string;
20
+ kind: string;
21
+ name: string;
22
+ files: string[];
23
+ detail?: { mergeKey?: string; names?: string[] };
24
+ }
25
+
26
+ interface Manifest {
27
+ version: number;
28
+ plugins: Record<string, { items: InstallItem[] } & Record<string, unknown>>;
29
+ }
30
+
31
+ /** The directory that the manifest and relative item paths belong to. */
32
+ function scopeRoot(scope: Scope, cwd = process.cwd()): string {
33
+ return scope === "global" ? homedir() : cwd;
34
+ }
35
+
36
+ function manifestPath(scope: Scope, cwd = process.cwd()): string {
37
+ return join(scopeRoot(scope, cwd), ".ap-sdk", "install-manifest.json");
38
+ }
39
+
40
+ function readManifest(path: string): Manifest | undefined {
41
+ if (!existsSync(path)) return undefined;
42
+ try {
43
+ return JSON.parse(readFileSync(path, "utf8")) as Manifest;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ /** The recorded items of this plugin, for the given harnesses (all when undefined). */
50
+ export function installedItems(scope: Scope, targets: readonly string[] | undefined, cwd = process.cwd()): InstallItem[] {
51
+ const items = readManifest(manifestPath(scope, cwd))?.plugins[PLUGIN_ID]?.items ?? [];
52
+ return targets === undefined ? items : items.filter((item) => targets.includes(item.harness));
53
+ }
54
+
55
+ /** The harnesses of a --target value; undefined means all of them. */
56
+ export function parseTargets(value: string | undefined): string[] | undefined {
57
+ return value === undefined ? undefined : value.split(",").map((target) => target.trim()).filter((target) => target !== "");
58
+ }
59
+
60
+ const WINDSURF_MCP = () => join(homedir(), ".codeium", "windsurf", "mcp_config.json");
61
+ const WINDSURF_KEY = "mcpServers";
62
+
63
+ function readJson(path: string): Record<string, unknown> | undefined {
64
+ try {
65
+ const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
66
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ /** What the Windsurf configuration held before an install, to put it back after a project install. */
73
+ export interface WindsurfBefore {
74
+ /** The file's bytes, or undefined when the file did not exist. */
75
+ bytes: Buffer | undefined;
76
+ hadServer: boolean;
77
+ }
78
+
79
+ export function windsurfBefore(): WindsurfBefore {
80
+ const path = WINDSURF_MCP();
81
+ const servers = readJson(path)?.[WINDSURF_KEY] as Record<string, unknown> | undefined;
82
+ let bytes: Buffer | undefined;
83
+ try {
84
+ bytes = readFileSync(path);
85
+ } catch {
86
+ bytes = undefined;
87
+ }
88
+ return { bytes, hadServer: servers !== undefined && PLUGIN_ID in servers };
89
+ }
90
+
91
+ /**
92
+ * After a project install for Windsurf: puts the home configuration back as
93
+ * it was, and removes the Windsurf MCP item from the project manifest.
94
+ * Returns a note for the user, or undefined when Windsurf was not a target.
95
+ */
96
+ export function undoWindsurfProjectMcp(before: WindsurfBefore, cwd = process.cwd()): string | undefined {
97
+ const manifestFile = manifestPath("project", cwd);
98
+ const manifest = readManifest(manifestFile);
99
+ const entry = manifest?.plugins[PLUGIN_ID];
100
+ const item = entry?.items.find((candidate) => candidate.harness === "windsurf" && candidate.kind === "mcp" && candidate.files.length > 0);
101
+ if (manifest === undefined || entry === undefined || item === undefined) return undefined;
102
+
103
+ const path = WINDSURF_MCP();
104
+ if (before.bytes !== undefined) {
105
+ // The file existed: its bytes go back unchanged.
106
+ if (!existsSync(path) || !readFileSync(path).equals(before.bytes)) writeFileSync(path, before.bytes);
107
+ } else {
108
+ // The install made the file: it goes when it holds only this declaration.
109
+ const root = readJson(path);
110
+ const servers = root?.[WINDSURF_KEY] as Record<string, unknown> | undefined;
111
+ if (root !== undefined && servers !== undefined) {
112
+ delete servers[PLUGIN_ID];
113
+ if (isEmptyConfig(root, WINDSURF_KEY)) {
114
+ unlinkSync(path);
115
+ removeEmptyDirectories(dirname(path), homedir());
116
+ } else {
117
+ writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
118
+ }
119
+ }
120
+ }
121
+ entry.items = entry.items.filter((candidate) => candidate !== item);
122
+ writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
123
+ return before.hadServer
124
+ ? "Windsurf reads MCP servers only from your home directory. Its c64-re-tools declaration from a global install stays as it is."
125
+ : "Windsurf reads MCP servers only from your home directory, so this project install did not declare the MCP for Windsurf. To declare it, run: c64-re-tools install --global --target windsurf";
126
+ }
127
+
128
+ /** A configuration that holds nothing but an empty server list (and perhaps a $schema). */
129
+ function isEmptyConfig(root: Record<string, unknown>, mergeKey: string): boolean {
130
+ const bucket = root[mergeKey];
131
+ if (typeof bucket !== "object" || bucket === null || Object.keys(bucket).length > 0) return false;
132
+ return Object.keys(root).every((key) => key === mergeKey || key === "$schema");
133
+ }
134
+
135
+ /** True when `path` is `root` itself or inside it. */
136
+ function isWithin(root: string, path: string): boolean {
137
+ const rel = relative(root, path);
138
+ return !isAbsolute(rel) && rel.split(sep)[0] !== "..";
139
+ }
140
+
141
+ /** Removes `directory` and its parents while they are empty, up to `stop` (never `stop` itself or anything outside it). */
142
+ export function removeEmptyDirectories(directory: string, stop: string): void {
143
+ let current = resolve(directory);
144
+ const limit = resolve(stop);
145
+ while (current !== limit) {
146
+ if (relative(limit, current) === "" || !isWithin(limit, current)) return;
147
+ try {
148
+ rmdirSync(current);
149
+ } catch {
150
+ return; // not empty, or gone
151
+ }
152
+ current = dirname(current);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * After an uninstall: deletes MCP configuration files that hold nothing but
158
+ * an empty server list, then the directories that the items left empty.
159
+ */
160
+ export function tidyAfterUninstall(items: readonly InstallItem[], scope: Scope, cwd = process.cwd()): void {
161
+ const root = scopeRoot(scope, cwd);
162
+ const absolute = (file: string) => (isAbsolute(file) ? file : resolve(root, file));
163
+ for (const item of items) {
164
+ if (item.kind !== "mcp" || item.detail?.mergeKey === undefined) continue;
165
+ for (const file of item.files.map(absolute)) {
166
+ const config = existsSync(file) ? readJson(file) : undefined;
167
+ if (config !== undefined && isEmptyConfig(config, item.detail.mergeKey)) unlinkSync(file);
168
+ }
169
+ }
170
+ const directories = new Set<string>([dirname(manifestPath(scope, cwd))]);
171
+ for (const item of items) for (const file of item.files) directories.add(dirname(absolute(file)));
172
+ // Deepest first, so a parent is tried after its children.
173
+ for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
174
+ // Each file's own home: the scope root, or the home directory for a file outside it.
175
+ const stop = isWithin(root, directory) ? root : homedir();
176
+ removeEmptyDirectories(directory, stop);
177
+ }
178
+ }
179
+
180
+ const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*m", "g");
181
+
182
+ /** AP SDK's report without its Windsurf MCP line and that line's detail, for when the CLI undid that item. */
183
+ export function withoutWindsurfMcp(output: string): string {
184
+ const lines = output.split("\n");
185
+ const kept: string[] = [];
186
+ for (let index = 0; index < lines.length; index++) {
187
+ if (/^\s*\S+\s+windsurf\s+mcp\s/.test(lines[index]!.replace(ANSI, ""))) {
188
+ if (lines[index + 1]?.replace(ANSI, "").trim().startsWith("\u21b3")) index++;
189
+ continue;
190
+ }
191
+ kept.push(lines[index]!);
192
+ }
193
+ return kept.join("\n");
194
+ }