@henols/c64-re-tools 2.0.1 → 2.1.0-rc.2

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 (104) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +24 -9
  4. package/skills/c64-assembler/scripts/assembler.ts +95 -0
  5. package/skills/c64-basic/SKILL.md +17 -9
  6. package/skills/c64-basic/scripts/basic.ts +26 -36
  7. package/skills/c64-disk/SKILL.md +17 -7
  8. package/skills/c64-disk/scripts/disk.ts +130 -104
  9. package/skills/c64-disk/scripts/lib/containers.ts +10 -12
  10. package/skills/c64-docs/SKILL.md +47 -28
  11. package/skills/c64-docs/references/style.md +6 -6
  12. package/skills/c64-docs/scripts/docs.ts +323 -0
  13. package/skills/c64-docs/scripts/lib/source-map.ts +1 -1
  14. package/skills/c64-emulator/SKILL.md +11 -7
  15. package/skills/c64-knowledge/SKILL.md +34 -21
  16. package/skills/c64-knowledge/scripts/knowledge.ts +597 -718
  17. package/skills/c64-knowledge/scripts/lib/listing.ts +5 -7
  18. package/skills/c64-memory-map/SKILL.md +18 -10
  19. package/skills/c64-memory-map/scripts/memory-map.ts +81 -0
  20. package/skills/c64-provenance/SKILL.md +82 -26
  21. package/skills/c64-provenance/scripts/lib/compare.ts +8 -12
  22. package/skills/c64-provenance/scripts/lib/leftovers.ts +442 -0
  23. package/skills/c64-provenance/scripts/lib/library.ts +15 -25
  24. package/skills/c64-provenance/scripts/lib/runtime.ts +12 -37
  25. package/skills/c64-provenance/scripts/lib/separate.ts +9 -10
  26. package/skills/c64-provenance/scripts/provenance.ts +222 -252
  27. package/skills/c64-reverse-engineering/SKILL.md +18 -15
  28. package/skills/c64-source/SKILL.md +28 -16
  29. package/skills/c64-source/scripts/lib/loader.ts +52 -77
  30. package/skills/c64-source/scripts/lib/source.ts +33 -42
  31. package/skills/c64-source/scripts/{generate.ts → source.ts} +68 -109
  32. package/skills/c64-static-analysis/SKILL.md +24 -13
  33. package/skills/c64-static-analysis/scripts/lib/coverage.ts +3 -4
  34. package/skills/c64-static-analysis/scripts/lib/findings.ts +2 -2
  35. package/skills/c64-static-analysis/scripts/lib/instructions.ts +1 -1
  36. package/skills/c64-static-analysis/scripts/lib/macros.ts +6 -8
  37. package/skills/c64-static-analysis/scripts/{analyze.ts → static-analysis.ts} +58 -118
  38. package/skills/c64-testing/SKILL.md +94 -19
  39. package/skills/c64-testing/references/recording-format.md +125 -0
  40. package/skills/c64-testing/references/scenario-format.md +24 -19
  41. package/skills/c64-testing/scripts/{diff.ts → lib/diff-files.ts} +18 -69
  42. package/skills/c64-testing/scripts/lib/diff.ts +5 -3
  43. package/skills/c64-testing/scripts/lib/play.ts +334 -0
  44. package/skills/c64-testing/scripts/lib/recording.ts +662 -0
  45. package/skills/c64-testing/scripts/lib/replay.ts +105 -0
  46. package/skills/c64-testing/scripts/{run.ts → lib/runs.ts} +36 -59
  47. package/skills/c64-testing/scripts/lib/scenario.ts +73 -81
  48. package/skills/c64-testing/scripts/testing.ts +189 -0
  49. package/skills/c64-unpacker/SKILL.md +42 -25
  50. package/skills/c64-unpacker/scripts/lib/evidence.ts +3 -4
  51. package/skills/c64-unpacker/scripts/lib/loads.ts +12 -10
  52. package/skills/c64-unpacker/scripts/lib/machine.ts +0 -2
  53. package/skills/c64-unpacker/scripts/lib/stages.ts +14 -19
  54. package/skills/c64-unpacker/scripts/{unpack.ts → unpacker.ts} +109 -211
  55. package/src/c64.ts +35 -20
  56. package/src/cli/main.ts +2 -2
  57. package/src/errors.ts +38 -0
  58. package/src/graphics.ts +3 -1
  59. package/src/host/server.ts +3 -2
  60. package/src/host/vice/session.ts +4 -4
  61. package/src/host-client/entry-state.ts +106 -0
  62. package/src/host-client/image.ts +21 -10
  63. package/src/host-client/steps.ts +285 -108
  64. package/src/host-client/transfer.ts +21 -15
  65. package/src/host-client/vice-session.ts +1 -1
  66. package/src/knowledge/database.ts +3 -23
  67. package/src/knowledge/expression.ts +8 -11
  68. package/src/knowledge/history.ts +2 -2
  69. package/src/knowledge/import.ts +13 -14
  70. package/src/knowledge/names.ts +50 -0
  71. package/src/knowledge/program.ts +11 -10
  72. package/src/knowledge/read.ts +16 -21
  73. package/src/knowledge/schema.ts +9 -3
  74. package/src/knowledge/text.ts +5 -5
  75. package/src/knowledge/write.ts +93 -137
  76. package/src/mcp/inputs.ts +105 -0
  77. package/src/mcp/output.ts +2 -13
  78. package/src/mcp/server.ts +12 -60
  79. package/src/mcp/tools/debug.ts +17 -12
  80. package/src/mcp/tools/execution.ts +5 -32
  81. package/src/mcp/tools/input.ts +7 -32
  82. package/src/mcp/tools/media.ts +6 -5
  83. package/src/mcp/tools/memory.ts +11 -38
  84. package/src/mcp/tools/video.ts +6 -5
  85. package/src/native/c64run.ts +162 -0
  86. package/src/native/discover.ts +8 -0
  87. package/src/native/dxa.ts +7 -6
  88. package/src/native/ghidra/analyze.ts +2 -1
  89. package/src/native/ghidra/index.ts +2 -1
  90. package/src/native/processes.ts +4 -3
  91. package/src/native/status.ts +25 -4
  92. package/src/native/types.ts +3 -7
  93. package/src/opcodes.ts +13 -13
  94. package/src/platform.ts +9 -9
  95. package/src/project.ts +16 -26
  96. package/src/protocol/messages.ts +3 -21
  97. package/src/protocol/vice.ts +7 -7
  98. package/src/script.ts +491 -49
  99. package/skills/c64-assembler/scripts/assemble.ts +0 -138
  100. package/skills/c64-docs/scripts/generate.ts +0 -109
  101. package/skills/c64-docs/scripts/link.ts +0 -239
  102. package/skills/c64-memory-map/scripts/memmap.ts +0 -92
  103. package/skills/c64-testing/scripts/checklist.ts +0 -51
  104. package/skills/c64-testing/scripts/test.ts +0 -65
@@ -0,0 +1,162 @@
1
+ // The c64run session: one replay core (a C64 emulator, see
2
+ // devcontainer/src/c64-re-tools/c64run.c) for the life of a skill script,
3
+ // driven by one command line and one answer line at a time over its stdin and
4
+ // stdout. It is started through the supervisor like every native tool, so it
5
+ // stops with the script.
6
+
7
+ import { createInterface } from "node:readline";
8
+
9
+ import { WireFailure } from "../protocol/messages.ts";
10
+ import { C64RUN, findTool, INSTALL_STEPS } from "./discover.ts";
11
+ import type { SupervisedProcess } from "./processes.ts";
12
+ import type { ToolContext } from "./run.ts";
13
+
14
+ /** The command protocol that this module speaks; c64run --version and version name it. */
15
+ export const C64RUN_PROTOCOL = 2;
16
+
17
+ /** CPU cycles in one PAL frame: 312 lines of 63 cycles. */
18
+ export const PAL_FRAME_CYCLES = 19_656;
19
+
20
+ const STOP_MS = 5_000;
21
+
22
+ /** The machine state that most commands answer with. */
23
+ export interface CoreStatus {
24
+ /** CPU cycles since the opcode fetch at the reset vector. */
25
+ tick: number;
26
+ /** At an opcode fetch: pc is the address of the next instruction. */
27
+ sync: boolean;
28
+ pc: number;
29
+ a: number;
30
+ x: number;
31
+ y: number;
32
+ s: number;
33
+ p: number;
34
+ line: number;
35
+ cycle: number;
36
+ /** FNV-1a of RAM from $0002 and of colour RAM. */
37
+ hash: string;
38
+ /** The word after "ok", if any: "reached" or "not-reached" for run-pc and run-reads. */
39
+ word?: string;
40
+ }
41
+
42
+ export function parseStatus(answer: string): CoreStatus {
43
+ const parts = answer.split(" ").slice(1);
44
+ const fields = new Map<string, string>();
45
+ let word: string | undefined;
46
+ for (const part of parts) {
47
+ const at = part.indexOf("=");
48
+ if (at < 0) word ??= part;
49
+ else fields.set(part.slice(0, at), part.slice(at + 1));
50
+ }
51
+ const hex = (key: string) => Number.parseInt(fields.get(key) ?? "", 16);
52
+ if (!fields.has("tick")) throw new WireFailure("operation-failed", `c64run gave no machine state: ${answer}`);
53
+ return {
54
+ tick: Number(fields.get("tick")),
55
+ sync: fields.get("sync") === "1",
56
+ pc: hex("pc"),
57
+ a: hex("a"),
58
+ x: hex("x"),
59
+ y: hex("y"),
60
+ s: hex("s"),
61
+ p: hex("p"),
62
+ line: Number(fields.get("line")),
63
+ cycle: Number(fields.get("cycle")),
64
+ hash: fields.get("hash") ?? "",
65
+ ...(word === undefined ? {} : { word }),
66
+ };
67
+ }
68
+
69
+ export class C64RunSession {
70
+ readonly #process: SupervisedProcess;
71
+ readonly #waiting: Array<{ resolve: (line: string) => void; reject: (error: Error) => void }> = [];
72
+ #stderr = "";
73
+ #ended: WireFailure | undefined;
74
+
75
+ private constructor(process: SupervisedProcess) {
76
+ this.#process = process;
77
+ const child = process.child;
78
+ // A core that ended (or never started) closes its stdin: the exit handler reports it.
79
+ child.stdin!.on("error", () => {});
80
+ child.stderr!.setEncoding("utf8");
81
+ child.stderr!.on("data", (chunk: string) => (this.#stderr = (this.#stderr + chunk).slice(-4000)));
82
+ createInterface({ input: child.stdout! }).on("line", (line) => this.#waiting.shift()?.resolve(line));
83
+ void process.exited.then((status) => {
84
+ const how = status.spawnError !== undefined ? `did not start (${status.spawnError})` : `ended (exit ${status.code ?? status.signal})`;
85
+ this.#ended = new WireFailure(status.spawnError !== undefined ? "installation-incomplete" : "operation-failed", `c64run ${how}.${this.#stderr === "" ? "" : ` ${this.#stderr.trim()}`}`);
86
+ for (const waiting of this.#waiting.splice(0)) waiting.reject(this.#ended);
87
+ });
88
+ }
89
+
90
+ /** Starts c64run (C64RT_C64RUN, else c64run on PATH) and checks its protocol. Refuses by name when it is missing. */
91
+ static async open(context: ToolContext): Promise<C64RunSession> {
92
+ const env = context.env ?? process.env;
93
+ const path = findTool(C64RUN, env);
94
+ const session = new C64RunSession(context.supervisor.spawn([path], { stdio: ["pipe", "pipe", "pipe"], env }));
95
+ const onAbort = () => void session.close();
96
+ context.signal.addEventListener("abort", onAbort, { once: true });
97
+ const version = await session.command("version");
98
+ const protocol = Number(/protocol=(\d+)/.exec(version)?.[1]);
99
+ if (protocol !== C64RUN_PROTOCOL) {
100
+ await session.close();
101
+ throw new WireFailure("installation-incomplete", `${path} speaks c64run protocol ${Number.isNaN(protocol) ? "(none)" : protocol}; c64-re-tools needs protocol ${C64RUN_PROTOCOL}. ${C64RUN.remedy}`);
102
+ }
103
+ return session;
104
+ }
105
+
106
+ /** Sends one command and gives its "ok ..." answer. An "err ..." answer throws operation-failed. */
107
+ command(text: string): Promise<string> {
108
+ if (this.#ended !== undefined) return Promise.reject(this.#ended);
109
+ const answer = new Promise<string>((resolve, reject) => this.#waiting.push({ resolve, reject }));
110
+ this.#process.child.stdin!.write(`${text}\n`);
111
+ return answer.then((line) => {
112
+ if (line.startsWith("ok")) return line;
113
+ throw new WireFailure("operation-failed", `c64run ${text.split(" ")[0]}: ${line.replace(/^err /, "")}`);
114
+ });
115
+ }
116
+
117
+ /** A command that answers with the machine state. */
118
+ async status(text: string): Promise<CoreStatus> {
119
+ return parseStatus(await this.command(text));
120
+ }
121
+
122
+ /** Power-on: the CPU stops at the reset vector, tick 0. Missing ROMs refuse with the remedy. */
123
+ async init(romFolder?: string): Promise<CoreStatus> {
124
+ try {
125
+ return await this.status(romFolder === undefined ? "init" : `init ${romFolder}`);
126
+ } catch (error) {
127
+ if (error instanceof WireFailure && /no ROM/.test(error.message)) {
128
+ throw new WireFailure(
129
+ "installation-incomplete",
130
+ `${error.message}. c64run needs the C64 ROMs kernal-901227-03.bin, basic-901226-01.bin and chargen-901225-01.bin: set C64RT_C64_ROMS to a folder with them (the C64 folder of VICE has them). ${INSTALL_STEPS}`,
131
+ );
132
+ }
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ /** Writes bytes into RAM (the RAM view), in parts that fit one command line. */
138
+ async write(address: number, bytes: Uint8Array): Promise<void> {
139
+ for (let at = 0; at < bytes.length; at += 4096) {
140
+ await this.command(`write ${((address + at) & 0xffff).toString(16)} ${Buffer.from(bytes.subarray(at, at + 4096)).toString("hex")}`);
141
+ }
142
+ }
143
+
144
+ /** RAM bytes (the RAM view). */
145
+ async peek(address: number, size: number): Promise<Uint8Array> {
146
+ const parts: Buffer[] = [];
147
+ for (let at = 0; at < size; at += 4096) {
148
+ const answer = await this.command(`peek ${((address + at) & 0xffff).toString(16)} ${Math.min(4096, size - at)}`);
149
+ parts.push(Buffer.from(answer.slice(3), "hex"));
150
+ }
151
+ return Buffer.concat(parts);
152
+ }
153
+
154
+ /** Ends c64run: quit, then the stop of its process group (at once when it ended, else after STOP_MS). */
155
+ async close(): Promise<void> {
156
+ if (this.#ended === undefined) {
157
+ this.#process.child.stdin!.end("quit\n");
158
+ await Promise.race([this.#process.exited, new Promise((resolve) => setTimeout(resolve, STOP_MS).unref())]);
159
+ }
160
+ await this.#process.stop();
161
+ }
162
+ }
@@ -55,6 +55,14 @@ export const DXA: ToolSpec = {
55
55
  remedy: `Install dxa 0.1.5 on the machine that runs the skill scripts so that dxa is on PATH, or set C64RT_DXA to its full path. ${INSTALL_STEPS}`,
56
56
  };
57
57
 
58
+ export const C64RUN: ToolSpec = {
59
+ name: "c64run",
60
+ envVar: "C64RT_C64RUN",
61
+ binaries: ["c64run"],
62
+ where: "on this machine",
63
+ remedy: `Install c64run (the replay core of c64-testing, with its C64 ROMs) on the machine that runs the skill scripts so that c64run is on PATH, or set C64RT_C64RUN to its full path. The dev container has it. ${INSTALL_STEPS}`,
64
+ };
65
+
58
66
  export const VICE: ToolSpec = {
59
67
  name: "VICE (x64sc)",
60
68
  envVar: "C64RT_VICE",
package/src/native/dxa.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { writeFileSync } from "node:fs";
9
9
 
10
+ import { Refusal } from "../errors.ts";
10
11
  import { SYMBOL_NAME } from "../c64.ts";
11
12
  import { describeError, WireFailure } from "../protocol/messages.ts";
12
13
  import { checkDxaResult, checkRequestBounds, imageRange, seedsInside, type DxaParams, type DxaResult } from "./types.ts";
@@ -61,7 +62,7 @@ export function parseListing(listing: string, start: number, end: number): Pick<
61
62
  const label = /^([0-9a-f]{4})\s+(\.?[A-Za-z_]\w*):\s*$/.exec(line);
62
63
  if (label !== null) {
63
64
  const address = Number.parseInt(label[1]!, 16);
64
- if (address < start || address > end || !SYMBOL_NAME.test(label[2]!)) throw new Error(`label ${label[2]} at $${label[1]} is not usable`);
65
+ if (address < start || address > end || !SYMBOL_NAME.test(label[2]!)) throw new Refusal("operation-failed", `label ${label[2]} at $${label[1]} is not usable`);
65
66
  labels.push({ address, name: label[2]! });
66
67
  continue;
67
68
  }
@@ -72,17 +73,17 @@ export function parseListing(listing: string, start: number, end: number): Pick<
72
73
  }
73
74
  const first = statements[0];
74
75
  const last = statements.at(-1);
75
- if (first === undefined || last === undefined) throw new Error("the listing has no statements");
76
- if (first.address !== start) throw new Error(`the listing starts at $${hex(first.address)}, not at $${hex(start)}`);
77
- if (last.address > end) throw new Error("the listing runs past the end of the program");
76
+ if (first === undefined || last === undefined) throw new Refusal("operation-failed", "the listing has no statements");
77
+ if (first.address !== start) throw new Refusal("operation-failed", `the listing starts at $${hex(first.address)}, not at $${hex(start)}`);
78
+ if (last.address > end) throw new Refusal("operation-failed", "the listing runs past the end of the program");
78
79
  const regions: DxaResult["regions"] = [];
79
80
  statements.forEach((statement, index) => {
80
81
  const next = statements[index + 1]?.address ?? end + 1;
81
82
  const size = next - statement.address;
82
- if (size <= 0) throw new Error(`the listing goes back at $${hex(next)}`);
83
+ if (size <= 0) throw new Refusal("operation-failed", `the listing goes back at $${hex(next)}`);
83
84
  // Data lines count their items; an instruction line dumps exactly its bytes.
84
85
  const expected = statement.data ?? statement.dumped;
85
- if (expected !== size) throw new Error(`the line at $${hex(statement.address)} holds ${expected} bytes, but the next line is ${size} bytes later`);
86
+ if (expected !== size) throw new Refusal("operation-failed", `the line at $${hex(statement.address)} holds ${expected} bytes, but the next line is ${size} bytes later`);
86
87
  const classification = statement.data === undefined ? "code" : "data";
87
88
  const last = regions.at(-1);
88
89
  if (last?.classification === classification) last.end = next - 1;
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
6
 
7
+ import { Refusal } from "../../errors.ts";
7
8
  import { describeError, isObject, WireFailure } from "../../protocol/messages.ts";
8
9
  import { checkGhidraResult, checkRequestBounds, GHIDRA_LIMITS, imageRange, seedsInside, type GhidraParams, type GhidraResult } from "../types.ts";
9
10
  import { outputTail, type ToolContext } from "../run.ts";
@@ -74,7 +75,7 @@ export async function analyze(
74
75
  // The version comes from the installation's application.properties, which findGhidra read.
75
76
  result = checkGhidraResult(isObject(exported) ? { ...exported, toolVersion: ghidraVersion(ghidra) } : exported);
76
77
  const [range] = result.coverage;
77
- if (result.coverage.length !== 1 || range === undefined || range.start !== start || range.end !== end) throw new Error("the coverage is not the loaded program");
78
+ if (result.coverage.length !== 1 || range === undefined || range.start !== start || range.end !== end) throw new Refusal("operation-failed", "the coverage is not the loaded program");
78
79
  } catch (error) {
79
80
  context.log?.(`Ghidra result rejected: ${describeError(error)}`);
80
81
  throw new WireFailure("operation-failed", `Ghidra returned an incomplete or inconsistent result. Nothing was imported. The check found: ${describeError(error)}.`);
@@ -9,6 +9,7 @@ import { userInfo } from "node:os";
9
9
  import { dirname, isAbsolute, join, resolve } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
 
12
+ import { Refusal } from "../../errors.ts";
12
13
  import { WireFailure } from "../../protocol/messages.ts";
13
14
  import { findOnPath, INSTALL_STEPS, isExecutableFile } from "../discover.ts";
14
15
  import type { ProcessSupervisor } from "../processes.ts";
@@ -42,7 +43,7 @@ function installationAt(root: string, source: string): GhidraInstallation {
42
43
  const analyzeHeadless = join(root, "support", `analyzeHeadless${LAUNCHER_SUFFIX}`);
43
44
  let properties: string;
44
45
  try {
45
- if (!isExecutableFile(analyzeHeadless)) throw new Error("no analyzeHeadless");
46
+ if (!isExecutableFile(analyzeHeadless)) throw new Refusal("operation-failed", "no analyzeHeadless");
46
47
  properties = readFileSync(join(root, "Ghidra", "application.properties"), "utf8");
47
48
  } catch {
48
49
  throw new WireFailure("installation-incomplete", `${source} does not point to a Ghidra installation. ${REMEDY}`);
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { setTimeout as sleep } from "node:timers/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
+ import { Refusal } from "../errors.ts";
9
10
  import { WireFailure } from "../protocol/messages.ts";
10
11
 
11
12
  /** How long a process group gets between SIGTERM and SIGKILL. */
@@ -189,7 +190,7 @@ export class ProcessSupervisor {
189
190
  /** Starts argv[0] with argv[1..] as the leader of a new process group. */
190
191
  spawn(argv: readonly string[], options: SpawnOptions = {}): SupervisedProcess {
191
192
  const program = argv[0];
192
- if (program === undefined) throw new TypeError("argv must name a command");
193
+ if (program === undefined) throw new Refusal("operation-failed", "argv must name a command");
193
194
  const batch = process.platform === "win32" ? batchInvocation(argv, process.env.ComSpec) : undefined;
194
195
  const { command, args } = batch ?? { command: program, args: argv.slice(1) };
195
196
  let child: ChildProcess;
@@ -333,14 +334,14 @@ export class ProcessSupervisor {
333
334
  // The leader has ended already.
334
335
  }
335
336
  await Promise.race([exited, sleep(this.#graceMs)]);
336
- if (!hasExited(child)) throw new Error(`process ${pid} did not end${failure === undefined ? "" : `: ${failure}`}`);
337
+ if (!hasExited(child)) throw new Refusal("operation-failed", `process ${pid} did not end${failure === undefined ? "" : `: ${failure}`}`);
337
338
  return;
338
339
  }
339
340
  // The group outlives its leader when descendants remain, so wait on the group, not the leader.
340
341
  if (signalGroup(pid, "SIGTERM") && !(await waitForGroupGone(pid, this.#graceMs))) {
341
342
  signalGroup(pid, "SIGKILL");
342
343
  if (!(await waitForGroupGone(pid, KILL_WAIT_MS))) {
343
- throw new Error(`process group ${pid} survived SIGKILL`);
344
+ throw new Refusal("operation-failed", `process group ${pid} survived SIGKILL`);
344
345
  }
345
346
  }
346
347
  // A group of zombies counts as gone before Node reaps the leader and reports its exit;
@@ -6,9 +6,9 @@
6
6
  import { tmpdir } from "node:os";
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
 
9
- import { WireFailure } from "../protocol/messages.ts";
9
+ import { isRefusal } from "../errors.ts";
10
10
  import { type ToolStatus } from "../protocol/tools.ts";
11
- import { ACME, DXA, findTool } from "./discover.ts";
11
+ import { ACME, C64RUN, DXA, findTool, INSTALL_STEPS } from "./discover.ts";
12
12
  import { findGhidra, ghidraVersion, NO_USABLE_JDK, noJdk } from "./ghidra/index.ts";
13
13
  import { runTool, type ToolContext } from "./run.ts";
14
14
  import { Workspace } from "./staging.ts";
@@ -55,7 +55,7 @@ async function run(
55
55
  }
56
56
 
57
57
  function missing(name: string, error: unknown): ToolStatus {
58
- if (!(error instanceof WireFailure)) throw error;
58
+ if (!isRefusal(error)) throw error;
59
59
  return { name, found: false, runs: false, problem: error.message };
60
60
  }
61
61
 
@@ -101,6 +101,27 @@ export async function probeGhidra(context: ToolContext): Promise<ToolStatus> {
101
101
  }
102
102
  }
103
103
 
104
+ /** c64run: --version prints its version and whether its ROM folder has the three C64 ROMs. */
105
+ export async function probeC64run(context: ToolContext): Promise<ToolStatus> {
106
+ const env = context.env ?? process.env;
107
+ try {
108
+ const path = findTool(C64RUN, env);
109
+ return await run(
110
+ "c64run",
111
+ path,
112
+ ["--version"],
113
+ (output) => (/^roms=ok /m.test(output) ? versionLine(output, /^c64run protocol=/) : undefined),
114
+ context,
115
+ (output) => {
116
+ const missing = /^roms=missing (.*)$/m.exec(output)?.[1];
117
+ return missing === undefined ? undefined : `It has no C64 ROMs: ${missing}. Set C64RT_C64_ROMS to a folder with kernal-901227-03.bin, basic-901226-01.bin and chargen-901225-01.bin (the C64 folder of VICE has them). ${INSTALL_STEPS}`;
118
+ },
119
+ );
120
+ } catch (error) {
121
+ return missing("c64run", error);
122
+ }
123
+ }
124
+
104
125
  /** The tools that skill scripts run themselves, on this machine. */
105
126
  export async function localToolStatus(context: ToolContext): Promise<ToolStatus[]> {
106
127
  const probes: Probe[] = [
@@ -109,5 +130,5 @@ export async function localToolStatus(context: ToolContext): Promise<ToolStatus[
109
130
  // directory only where that uses "/": on Windows the line starts with the full path.
110
131
  { name: "dxa", find: (env) => findTool(DXA, env), args: ["-V"], pattern: DXA_VERSION },
111
132
  ];
112
- return [...(await probeTools(probes, context)), await probeGhidra(context)];
133
+ return [...(await probeTools(probes, context)), await probeGhidra(context), await probeC64run(context)];
113
134
  }
@@ -3,6 +3,7 @@
3
3
  // these tools itself: nothing here crosses the Host Runtime wire. Bytes
4
4
  // (a program, an image, a listing) go next to these values as attachments.
5
5
 
6
+ import { Refusal } from "../errors.ts";
6
7
  import { prgRange, REFERENCE_KINDS, SYMBOL_NAME, type ReferenceKind } from "../c64.ts";
7
8
  import { isInteger, isObject, isOneOf, WireFailure } from "../protocol/messages.ts";
8
9
  import { type SourceTree } from "../protocol/tools.ts";
@@ -118,7 +119,7 @@ const isToolVersion = (value: unknown) => value === undefined || (typeof value =
118
119
 
119
120
  /** Ends a result check with the Error that names its first problem. */
120
121
  function fail(message: string): never {
121
- throw new Error(message);
122
+ throw new Refusal("operation-failed", message);
122
123
  }
123
124
 
124
125
  /** Narrows to a list whose items are still unchecked. */
@@ -237,12 +238,7 @@ export function imageRange(kind: ImageKind, image: Uint8Array): { start: number;
237
238
  if (image.length !== 0x10000) throw new WireFailure("invalid-input", "A flat 64 KiB image has exactly 65536 bytes.");
238
239
  return { start: 0, end: 0xffff, body: image };
239
240
  }
240
- try {
241
- return prgRange(image);
242
- } catch (error) {
243
- if (!(error instanceof RangeError)) throw error;
244
- throw new WireFailure("invalid-input", error.message);
245
- }
241
+ return prgRange(image);
246
242
  }
247
243
 
248
244
  /**
package/src/opcodes.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  // spelling, ACME writes only one of them: the generator finds those with a
5
5
  // probe and writes the others as bytes.
6
6
 
7
+ import { formatC64Byte, formatC64Address } from "./c64.ts";
8
+
7
9
  export type Mode = "imp" | "imm" | "zp" | "zpx" | "zpy" | "abs" | "abx" | "aby" | "ind" | "izx" | "izy" | "rel";
8
10
 
9
11
  export interface Opcode {
@@ -75,8 +77,6 @@ export function useKind(opcode: number): UseKind {
75
77
  return "read";
76
78
  }
77
79
 
78
- const hex2 = (value: number) => `$${value.toString(16).padStart(2, "0")}`;
79
- const hex4 = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
80
80
 
81
81
  /** The text of the instruction in `bytes` at `pc`, for example "sta $d011" or "bne $c002". */
82
82
  export function instructionText(bytes: readonly number[], pc: number): string {
@@ -85,17 +85,17 @@ export function instructionText(bytes: readonly number[], pc: number): string {
85
85
  const word = low | ((bytes[2] ?? 0) << 8);
86
86
  const operand: Record<Mode, string> = {
87
87
  imp: "",
88
- imm: `#${hex2(low)}`,
89
- zp: hex2(low),
90
- zpx: `${hex2(low)},x`,
91
- zpy: `${hex2(low)},y`,
92
- abs: hex4(word),
93
- abx: `${hex4(word)},x`,
94
- aby: `${hex4(word)},y`,
95
- ind: `(${hex4(word)})`,
96
- izx: `(${hex2(low)},x)`,
97
- izy: `(${hex2(low)}),y`,
98
- rel: hex4((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff),
88
+ imm: `#${formatC64Byte(low)}`,
89
+ zp: formatC64Byte(low),
90
+ zpx: `${formatC64Byte(low)},x`,
91
+ zpy: `${formatC64Byte(low)},y`,
92
+ abs: formatC64Address(word),
93
+ abx: `${formatC64Address(word)},x`,
94
+ aby: `${formatC64Address(word)},y`,
95
+ ind: `(${formatC64Address(word)})`,
96
+ izx: `(${formatC64Byte(low)},x)`,
97
+ izy: `(${formatC64Byte(low)}),y`,
98
+ rel: formatC64Address((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff),
99
99
  };
100
100
  return operand[mode] === "" ? mnemonic : `${mnemonic} ${operand[mode]}`;
101
101
  }
package/src/platform.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  // Names follow "Mapping the Commodore 64". Only platform facts live here:
4
4
  // an address without one belongs to the application.
5
5
 
6
- import { d018Offsets, vicBankBase } from "./c64.ts";
6
+ import { d018Offsets, formatC64Address, vicBankBase } from "./c64.ts";
7
+ import { Refusal } from "./errors.ts";
7
8
 
8
9
  export interface Entry {
9
10
  start: number;
@@ -321,7 +322,6 @@ const AREAS: Array<{ start: number; end: number; area: string; kind: Lookup["kin
321
322
  { start: 0xe000, end: 0xffff, area: "KERNAL ROM or RAM", kind: "platform", meaning: "KERNAL ROM when $01 bit 1 is set, else RAM." },
322
323
  ];
323
324
 
324
- const hex = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
325
325
 
326
326
  export interface Area {
327
327
  name: string;
@@ -353,7 +353,7 @@ export function lookup(address: number): Lookup {
353
353
  const base = block.start + ((address - block.start) % block.period);
354
354
  const register = block.registers.find((entry) => entry.start === base);
355
355
  const mirror = base !== address ? { mirrorOf: base } : {};
356
- if (register === undefined) return { address, kind: "platform", area: block.area, meaning: `Unused register (${hex(base)}): reads give no useful value.`, ...mirror };
356
+ if (register === undefined) return { address, kind: "platform", area: block.area, meaning: `Unused register (${formatC64Address(base)}): reads give no useful value.`, ...mirror };
357
357
  return { address, kind: "platform", area: block.area, name: register.name, meaning: register.meaning, ...mirror };
358
358
  }
359
359
  const entry = [...SYSTEM, ...ROM].find((candidate) => address >= candidate.start && address <= candidate.end);
@@ -455,13 +455,13 @@ export function decode(register: number, value: number, dd00?: number): Decoded
455
455
  return { xScroll: value & 0x07, columns: value & 0x08 ? 40 : 38, multicolorMode: (value & 0x10) !== 0 };
456
456
  case 0xd018: {
457
457
  const { screen, characters, bitmap } = d018Offsets(value);
458
- const result: Decoded = { screenOffset: hex(screen), characterOffset: hex(characters), bitmapOffset: hex(bitmap) };
458
+ const result: Decoded = { screenOffset: formatC64Address(screen), characterOffset: formatC64Address(characters), bitmapOffset: formatC64Address(bitmap) };
459
459
  if (dd00 !== undefined) {
460
460
  const bank = vicBank(dd00);
461
461
  result.bank = bank.bank;
462
- result.screen = hex(bank.start + screen);
463
- result.characters = hex(bank.start + characters);
464
- result.bitmap = hex(bank.start + bitmap);
462
+ result.screen = formatC64Address(bank.start + screen);
463
+ result.characters = formatC64Address(bank.start + characters);
464
+ result.bitmap = formatC64Address(bank.start + bitmap);
465
465
  if (bank.bank === 0 || bank.bank === 2) result.note = "In banks 0 and 2 the VIC-II sees the character ROM at offsets $1000-$1FFF.";
466
466
  } else {
467
467
  result.note = "Offsets are inside the VIC-II bank; give --bank with the $DD00 value for addresses.";
@@ -470,7 +470,7 @@ export function decode(register: number, value: number, dd00?: number): Decoded
470
470
  }
471
471
  case 0xdd00: {
472
472
  const bank = vicBank(value);
473
- return { vicBank: bank.bank, vicAddresses: `${hex(bank.start)}-${hex(bank.end)}` };
473
+ return { vicBank: bank.bank, vicAddresses: `${formatC64Address(bank.start)}-${formatC64Address(bank.end)}` };
474
474
  }
475
475
  case 0xd015:
476
476
  case 0xd017:
@@ -499,6 +499,6 @@ export function decode(register: number, value: number, dd00?: number): Decoded
499
499
  };
500
500
  }
501
501
  default:
502
- throw new RangeError(`no decoder for ${hex(register)}; decoders exist for $0001, $d010, $d011, $d015-$d01f (sprite and interrupt bits), $d016, $d018, $dc0d, $dd00 and $dd0d`);
502
+ throw new Refusal("invalid-input", `There is no decoder for ${formatC64Address(register)}. Decoders exist for $0001, $d010, $d011, $d015-$d01f (sprite and interrupt bits), $d016, $d018, $dc0d, $dd00 and $dd0d`);
503
503
  }
504
504
  }
package/src/project.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // project path is relative to that folder. .c64-re-tools/local/ holds what
5
5
  // the agent saves for itself and ignores itself in git.
6
6
 
7
+ import { Refusal } from "./errors.ts";
7
8
  import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
9
  import { homedir } from "node:os";
9
10
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -23,17 +24,6 @@ const SEARCH_DEPTH = 3;
23
24
  /** At most this many projects are named in a refusal. */
24
25
  const LISTED_PROJECTS = 20;
25
26
 
26
- /** A refusal of the project rules, with a stable code, shown to the LLM unchanged. */
27
- export class ProjectError extends Error {
28
- override name = "ProjectError";
29
- readonly code: "invalid-input" | "not-found";
30
-
31
- constructor(code: "invalid-input" | "not-found", message: string) {
32
- super(message);
33
- this.code = code;
34
- }
35
- }
36
-
37
27
  /** How a refusal tells the caller to name a project: "--project <folder>" for a script, "project" for an MCP tool. */
38
28
  export interface ProjectRemedy {
39
29
  remedy?: string;
@@ -95,7 +85,7 @@ function listed(projects: readonly string[]): string {
95
85
  * The project of the working directory `cwd` by the project rules: the nearest
96
86
  * folder at or above it with .c64-re-tools/, below the home folder. Without
97
87
  * one, `cwd` itself, unless projects are below it or it is the home folder:
98
- * then a ProjectError names them and the remedy, so no write lands outside a
88
+ * then a Refusal names them and the remedy, so no write lands outside a
99
89
  * project.
100
90
  */
101
91
  export function findProject(cwd: string = process.cwd(), options: ProjectRemedy = {}): string {
@@ -105,11 +95,11 @@ export function findProject(cwd: string = process.cwd(), options: ProjectRemedy
105
95
  if (isProject(folder)) return folder;
106
96
  }
107
97
  if (start === homedir()) {
108
- throw new ProjectError("invalid-input", `The home folder is not a project folder. Start in a project folder, or ${remedy.charAt(0).toLowerCase()}${remedy.slice(1)}.`);
98
+ throw new Refusal("invalid-input", `The home folder is not a project folder. Start in a project folder, or ${remedy.charAt(0).toLowerCase()}${remedy.slice(1)}.`);
109
99
  }
110
100
  const below = projectsBelow(start);
111
101
  if (below.length > 0) {
112
- throw new ProjectError("invalid-input", `${start} is not a project folder. These folders below it are projects: ${listed(below)}. ${remedy} with one of them.`);
102
+ throw new Refusal("invalid-input", `${start} is not a project folder. These folders below it are projects: ${listed(below)}. ${remedy} with one of them.`);
113
103
  }
114
104
  return start;
115
105
  }
@@ -121,15 +111,15 @@ export function findProject(cwd: string = process.cwd(), options: ProjectRemedy
121
111
  * home folder.
122
112
  */
123
113
  export function namedProject(folder: string, cwd: string = process.cwd()): string {
124
- if (folder.length === 0) throw new ProjectError("invalid-input", "The project folder must not be empty.");
114
+ if (folder.length === 0) throw new Refusal("invalid-input", "The project folder must not be empty.");
125
115
  const path = resolve(cwd, folder);
126
116
  const stat = statSync(path, { throwIfNoEntry: false });
127
- if (stat === undefined) throw new ProjectError("not-found", `There is no folder ${folder}.`);
128
- if (!stat.isDirectory()) throw new ProjectError("invalid-input", `${folder} is not a folder.`);
129
- if (path === homedir()) throw new ProjectError("invalid-input", "The home folder is not a project folder.");
117
+ if (stat === undefined) throw new Refusal("not-found", `There is no folder ${folder}.`);
118
+ if (!stat.isDirectory()) throw new Refusal("invalid-input", `${folder} is not a folder.`);
119
+ if (path === homedir()) throw new Refusal("invalid-input", "The home folder is not a project folder.");
130
120
  const outer = foldersUpFrom(path).slice(1).find(isProject);
131
121
  if (outer !== undefined) {
132
- throw new ProjectError("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || "."}. Give that project folder.`);
122
+ throw new Refusal("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || "."}. Give that project folder.`);
133
123
  }
134
124
  return path;
135
125
  }
@@ -159,7 +149,7 @@ export function resolveProject(folder: string | undefined, cwd: string = process
159
149
  return root;
160
150
  }
161
151
 
162
- /** Sets the project of this process from --project. Throws ProjectError for a folder that the rules refuse. */
152
+ /** Sets the project of this process from --project. Refuses a folder that the rules refuse. */
163
153
  export function useProject(folder: string): void {
164
154
  chosen = namedProject(folder);
165
155
  }
@@ -179,18 +169,18 @@ export function projectRoot(): string {
179
169
 
180
170
  export function resolveProjectPath(path: string, root = projectRoot()): string {
181
171
  if (path.length === 0) {
182
- throw new TypeError("Project path must not be empty");
172
+ throw new Refusal("invalid-input", "A project path must not be empty.");
183
173
  }
184
174
  if (isAbsolute(path)) {
185
- throw new TypeError("Project path must be relative");
175
+ throw new Refusal("invalid-input", `${path} is not relative: give a path relative to the project folder.`);
186
176
  }
187
177
 
188
178
  const candidate = resolve(root, path);
189
179
  if (isInside(root, candidate)) return candidate;
190
- throw new TypeError("Project path escapes the project root");
180
+ throw new Refusal("invalid-input", `${path} leads out of the project folder: give a path that stays inside it.`);
191
181
  }
192
182
 
193
- /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Throws TypeError as resolveProjectPath does. */
183
+ /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Refuses a path as resolveProjectPath does. */
194
184
  export function projectRelative(path: string, root = projectRoot()): string {
195
185
  return relative(root, resolveProjectPath(path, root)).split(sep).join("/");
196
186
  }
@@ -204,13 +194,13 @@ export function isInside(root: string, candidate: string): boolean {
204
194
  /**
205
195
  * Resolves a project-relative path to an existing file system entry whose real
206
196
  * location is inside the project too, so a symbolic link cannot reach outside
207
- * it. Throws TypeError for a refused path; lets ENOENT through.
197
+ * it. Refuses a path that leads out; lets ENOENT through.
208
198
  */
209
199
  export function resolveProjectFile(path: string, root = projectRoot()): string {
210
200
  const candidate = resolveProjectPath(path, root);
211
201
  const real = realpathSync(candidate);
212
202
  if (!isInside(realpathSync(root), real)) {
213
- throw new TypeError("Project path escapes the project root through a symbolic link");
203
+ throw new Refusal("invalid-input", `${path} leads out of the project folder through a symbolic link.`);
214
204
  }
215
205
  return real;
216
206
  }
@@ -1,6 +1,7 @@
1
1
  // The message layer of the Host Runtime wire: protocol identity, error codes, the message shapes and
2
2
  // their parsers, and the field checks that every validator uses.
3
3
 
4
+ import { ERROR_CODES, Refusal, type ErrorCode } from "../errors.ts";
4
5
  import type { ToolOperation, ToolOperations } from "./tools.ts";
5
6
  import type { ViceOperation, ViceOperations } from "./vice.ts";
6
7
 
@@ -18,20 +19,6 @@ export const DEFAULT_HOST_PORT = 6464;
18
19
  // ---------------------------------------------------------------------------
19
20
  // Shared vocabulary
20
21
 
21
- export const ERROR_CODES = [
22
- "invalid-input",
23
- "machine-running",
24
- "machine-unavailable",
25
- "machine-state-lost",
26
- "not-found",
27
- "media-error",
28
- "limit-exceeded",
29
- "unsupported-in-space",
30
- "installation-incomplete",
31
- "operation-failed",
32
- ] as const;
33
- export type ErrorCode = (typeof ERROR_CODES)[number];
34
-
35
22
  /** An actionable failure carried on the wire and shown to the LLM unchanged. */
36
23
  export interface WireError {
37
24
  code: ErrorCode;
@@ -122,14 +109,9 @@ export function shownMessage(error: { code: string; message: string }): string {
122
109
  return error.code === "installation-incomplete" ? withStopAndAsk(error.message) : error.message;
123
110
  }
124
111
 
125
- export class WireFailure extends Error implements WireError {
112
+ /** A refusal that crosses the Host Runtime wire. */
113
+ export class WireFailure extends Refusal implements WireError {
126
114
  override name = "WireFailure";
127
- readonly code: ErrorCode;
128
-
129
- constructor(code: ErrorCode, message: string) {
130
- super(message);
131
- this.code = code;
132
- }
133
115
 
134
116
  toWire(): WireError {
135
117
  return { code: this.code, message: this.message };