@henols/c64-re-tools 2.1.0-rc.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +24 -9
  3. package/skills/c64-assembler/scripts/assembler.ts +95 -0
  4. package/skills/c64-basic/SKILL.md +17 -9
  5. package/skills/c64-basic/scripts/basic.ts +26 -36
  6. package/skills/c64-disk/SKILL.md +17 -7
  7. package/skills/c64-disk/scripts/disk.ts +130 -104
  8. package/skills/c64-disk/scripts/lib/containers.ts +10 -12
  9. package/skills/c64-docs/SKILL.md +47 -28
  10. package/skills/c64-docs/references/style.md +6 -6
  11. package/skills/c64-docs/scripts/docs.ts +323 -0
  12. package/skills/c64-docs/scripts/lib/source-map.ts +1 -1
  13. package/skills/c64-emulator/SKILL.md +11 -7
  14. package/skills/c64-knowledge/SKILL.md +34 -21
  15. package/skills/c64-knowledge/scripts/knowledge.ts +597 -718
  16. package/skills/c64-knowledge/scripts/lib/listing.ts +5 -7
  17. package/skills/c64-memory-map/SKILL.md +18 -10
  18. package/skills/c64-memory-map/scripts/memory-map.ts +81 -0
  19. package/skills/c64-provenance/SKILL.md +33 -24
  20. package/skills/c64-provenance/scripts/lib/compare.ts +8 -12
  21. package/skills/c64-provenance/scripts/lib/leftovers.ts +19 -20
  22. package/skills/c64-provenance/scripts/lib/library.ts +15 -25
  23. package/skills/c64-provenance/scripts/lib/runtime.ts +12 -37
  24. package/skills/c64-provenance/scripts/lib/separate.ts +9 -10
  25. package/skills/c64-provenance/scripts/provenance.ts +177 -279
  26. package/skills/c64-reverse-engineering/SKILL.md +13 -12
  27. package/skills/c64-source/SKILL.md +28 -16
  28. package/skills/c64-source/scripts/lib/loader.ts +52 -77
  29. package/skills/c64-source/scripts/lib/source.ts +33 -42
  30. package/skills/c64-source/scripts/{generate.ts → source.ts} +68 -109
  31. package/skills/c64-static-analysis/SKILL.md +24 -13
  32. package/skills/c64-static-analysis/scripts/lib/coverage.ts +3 -4
  33. package/skills/c64-static-analysis/scripts/lib/findings.ts +2 -2
  34. package/skills/c64-static-analysis/scripts/lib/instructions.ts +1 -1
  35. package/skills/c64-static-analysis/scripts/lib/macros.ts +6 -8
  36. package/skills/c64-static-analysis/scripts/{analyze.ts → static-analysis.ts} +58 -118
  37. package/skills/c64-testing/SKILL.md +48 -32
  38. package/skills/c64-testing/references/recording-format.md +48 -19
  39. package/skills/c64-testing/references/scenario-format.md +24 -19
  40. package/skills/c64-testing/scripts/{diff.ts → lib/diff-files.ts} +18 -69
  41. package/skills/c64-testing/scripts/lib/diff.ts +5 -3
  42. package/skills/c64-testing/scripts/lib/play.ts +244 -54
  43. package/skills/c64-testing/scripts/lib/recording.ts +153 -120
  44. package/skills/c64-testing/scripts/{replay.ts → lib/replay.ts} +28 -86
  45. package/skills/c64-testing/scripts/{run.ts → lib/runs.ts} +36 -59
  46. package/skills/c64-testing/scripts/lib/scenario.ts +73 -81
  47. package/skills/c64-testing/scripts/testing.ts +189 -0
  48. package/skills/c64-unpacker/SKILL.md +42 -25
  49. package/skills/c64-unpacker/scripts/lib/evidence.ts +3 -4
  50. package/skills/c64-unpacker/scripts/lib/loads.ts +12 -10
  51. package/skills/c64-unpacker/scripts/lib/machine.ts +0 -2
  52. package/skills/c64-unpacker/scripts/lib/stages.ts +14 -19
  53. package/skills/c64-unpacker/scripts/{unpack.ts → unpacker.ts} +109 -211
  54. package/src/c64.ts +35 -20
  55. package/src/cli/main.ts +2 -2
  56. package/src/errors.ts +38 -0
  57. package/src/graphics.ts +3 -1
  58. package/src/host/server.ts +3 -2
  59. package/src/host/vice/session.ts +4 -4
  60. package/src/host-client/entry-state.ts +106 -0
  61. package/src/host-client/image.ts +21 -10
  62. package/src/host-client/steps.ts +285 -108
  63. package/src/host-client/transfer.ts +21 -15
  64. package/src/host-client/vice-session.ts +1 -1
  65. package/src/knowledge/database.ts +3 -23
  66. package/src/knowledge/expression.ts +8 -11
  67. package/src/knowledge/history.ts +2 -2
  68. package/src/knowledge/import.ts +13 -14
  69. package/src/knowledge/names.ts +50 -0
  70. package/src/knowledge/program.ts +11 -10
  71. package/src/knowledge/read.ts +16 -21
  72. package/src/knowledge/schema.ts +9 -3
  73. package/src/knowledge/text.ts +5 -5
  74. package/src/knowledge/write.ts +93 -137
  75. package/src/mcp/inputs.ts +105 -0
  76. package/src/mcp/output.ts +2 -13
  77. package/src/mcp/server.ts +12 -60
  78. package/src/mcp/tools/debug.ts +17 -12
  79. package/src/mcp/tools/execution.ts +5 -32
  80. package/src/mcp/tools/input.ts +7 -32
  81. package/src/mcp/tools/media.ts +6 -5
  82. package/src/mcp/tools/memory.ts +11 -38
  83. package/src/mcp/tools/video.ts +6 -5
  84. package/src/native/c64run.ts +1 -1
  85. package/src/native/dxa.ts +7 -6
  86. package/src/native/ghidra/analyze.ts +2 -1
  87. package/src/native/ghidra/index.ts +2 -1
  88. package/src/native/processes.ts +4 -3
  89. package/src/native/status.ts +2 -2
  90. package/src/native/types.ts +3 -7
  91. package/src/opcodes.ts +13 -13
  92. package/src/platform.ts +9 -9
  93. package/src/project.ts +16 -26
  94. package/src/protocol/messages.ts +3 -21
  95. package/src/protocol/vice.ts +7 -7
  96. package/src/script.ts +491 -49
  97. package/skills/c64-assembler/scripts/assemble.ts +0 -138
  98. package/skills/c64-docs/scripts/generate.ts +0 -109
  99. package/skills/c64-docs/scripts/link.ts +0 -239
  100. package/skills/c64-memory-map/scripts/memmap.ts +0 -92
  101. package/skills/c64-testing/scripts/checklist.ts +0 -51
  102. package/skills/c64-testing/scripts/record.ts +0 -96
  103. package/skills/c64-testing/scripts/test.ts +0 -65
@@ -1,133 +1,302 @@
1
- // The action steps of the step language: write, registers, joystick,
2
- // type, frames and runUntil. c64-testing scenarios and the c64-unpacker
3
- // steps file share them; each caller adds its own steps around them.
1
+ // The action steps of the scripts. A step is { "<kind>": <input> }, and the
2
+ // input has the fields, ranges and defaults of the MCP tool that does the same:
3
+ //
4
+ // write c64_memory_write { address, data, space?, view? }
5
+ // registers c64_registers the values of action write: { pc?, a?, x?, y?, sp?, flags? }
6
+ // joystick c64_joystick { port, direction, fire? }
7
+ // type c64_keyboard { mode: "text", text } or { mode: "petscii", bytes }: the KERNAL keyboard queue
8
+ // frames c64_execution the count of advance-frames
9
+ // runUntil c64_run_until { target, timeoutFrames? }
10
+ // press (no tool) keys of the keyboard matrix; only the replay core of c64-testing record and replay has them
11
+ //
12
+ // An address in a step is $ and 1 to 4 hex digits, or a name that the script resolves.
13
+ // The scripts install without packages, so this checker has no zod: the MCP
14
+ // tools keep their zod schemas, and a test checks that both take the same inputs.
4
15
 
5
- import { parseC64Address, textToPetscii } from "../c64.ts";
6
- import { JOYSTICK_DIRECTIONS, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES } from "../protocol/vice.ts";
7
- import type { RunTarget, RunUntilResult } from "../protocol/vice.ts";
16
+ import { c64AddressOf, SYMBOL_NAME, textToPetscii } from "../c64.ts";
17
+ import { isRefusal, Refusal } from "../errors.ts";
18
+ import {
19
+ COMPARISONS,
20
+ CONDITION_REGISTERS,
21
+ JOYSTICK_DIRECTIONS,
22
+ MAX_EXECUTION_COUNT,
23
+ MAX_KEYBOARD_BYTES,
24
+ MAX_MEMORY_WRITE,
25
+ MAX_TIMEOUT_FRAMES,
26
+ MEMORY_VIEWS,
27
+ SPACES,
28
+ type Condition,
29
+ type RunTarget,
30
+ type RunUntilResult,
31
+ } from "../protocol/vice.ts";
32
+ import { readProjectFile } from "./transfer.ts";
8
33
  import type { ViceSessionClient } from "./vice-session.ts";
9
34
 
10
- /** A "$xxxx" address, or a symbol name where the caller resolves names. */
35
+ /** A "$xxxx" address or a name, which the caller resolves. */
11
36
  export type Location = string;
12
37
 
13
- type JoystickDirection = (typeof JOYSTICK_DIRECTIONS)[number];
38
+ type Space = (typeof SPACES)[number];
39
+ type View = (typeof MEMORY_VIEWS)[number];
40
+ type Comparison = (typeof COMPARISONS)[number];
41
+ type Flags = Partial<Record<"n" | "v" | "b" | "d" | "i" | "z" | "c", boolean>>;
42
+
43
+ export type StepCondition =
44
+ | { kind: "register"; register: (typeof CONDITION_REGISTERS)[number]; operator: Comparison; value: number }
45
+ | { kind: "memory"; address: Location; operator: Comparison; value: number; space: Space; view: View }
46
+ | { kind: "raster"; line: number; cycle?: number };
47
+
48
+ export type StepTarget =
49
+ | { kind: "address"; address: Location; space: Space; condition?: StepCondition }
50
+ | { kind: "memory"; address: Location; operator: Comparison; value: number; space: Space; view: View }
51
+ | { kind: "raster"; line: number; cycle?: number };
52
+
53
+ export type KeyboardInput = { mode: "text"; text: string } | { mode: "petscii"; bytes: number[] };
14
54
 
15
55
  export type ActionStep =
16
- | { write: { at: Location; bytes: number[] } }
17
- | { registers: Partial<Record<"pc" | "a" | "x" | "y" | "sp", number | Location>> }
18
- | { joystick: { port: 1 | 2; direction: JoystickDirection; fire?: boolean } }
19
- | { type: string }
56
+ | { write: { address: Location; data: string; space: Space; view: View } }
57
+ | { registers: { pc?: Location; a?: number; x?: number; y?: number; sp?: number; flags?: Flags } }
58
+ | { joystick: { port: 1 | 2; direction: (typeof JOYSTICK_DIRECTIONS)[number]; fire: boolean } }
59
+ | { type: KeyboardInput }
60
+ | { press: string }
20
61
  | { frames: number }
21
- | { runUntil: { at: Location; raster?: number } | { raster: number } | { memory: { at: Location; equals: number } }; timeoutFrames: number };
62
+ | { runUntil: { target: StepTarget; timeoutFrames: number } };
22
63
 
23
- /** The highest raster line of a PAL frame; an NTSC frame has fewer, which the emulator checks. */
24
- const MAX_RASTER_LINE = 311;
64
+ export const ACTION_STEP_KINDS = ["write", "registers", "joystick", "type", "press", "frames", "runUntil"] as const;
65
+ export type ActionStepKind = (typeof ACTION_STEP_KINDS)[number];
25
66
 
26
- export const ACTION_STEP_KINDS = ["write", "registers", "joystick", "type", "frames", "runUntil"] as const;
67
+ /** The fields of each object of the steps: the same as the inputs of the MCP tools. */
68
+ export const STEP_FIELDS = {
69
+ write: ["address", "data", "space", "view"],
70
+ registers: ["pc", "a", "x", "y", "sp", "flags"],
71
+ joystick: ["port", "direction", "fire"],
72
+ type: ["mode", "text", "bytes"],
73
+ runUntil: ["target", "timeoutFrames"],
74
+ addressTarget: ["kind", "address", "space", "condition"],
75
+ memoryTarget: ["kind", "address", "operator", "value", "space", "view"],
76
+ rasterTarget: ["kind", "line", "cycle"],
77
+ registerCondition: ["kind", "register", "operator", "value"],
78
+ memoryCondition: ["kind", "address", "operator", "value", "space", "view"],
79
+ rasterCondition: ["kind", "line", "cycle"],
80
+ flags: ["n", "v", "b", "d", "i", "z", "c"],
81
+ } as const;
27
82
 
28
- /** The keys a step of each kind can have, when it is more than the kind itself. */
29
- export const ACTION_STEP_KEYS: Record<string, readonly string[]> = { runUntil: ["runUntil", "timeoutFrames"] };
30
-
31
- /** The registers a registers step can set. */
32
- export const SET_REGISTERS = ["pc", "a", "x", "y", "sp"];
83
+ /** The highest raster line of a PAL frame; an NTSC frame has fewer, which the emulator checks. */
84
+ export const MAX_RASTER_LINE = 311;
85
+ /** The wait of a runUntil step without timeoutFrames, as c64_run_until has it. */
86
+ export const DEFAULT_TIMEOUT_FRAMES = 3000;
33
87
 
34
88
  export const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
35
89
  export const isInteger = (value: unknown, min: number, max: number): value is number => Number.isInteger(value) && (value as number) >= min && (value as number) <= max;
90
+ const isOneOf = <T extends string>(values: readonly T[], value: unknown): value is T => (values as readonly unknown[]).includes(value);
36
91
 
37
- /** Checks a "$xxxx" address, or also a symbol name when `names` allows it. */
38
- export function checkLocation(value: unknown, what: string, fail: (message: string) => never, names = true): void {
39
- if (typeof value !== "string" || value === "") fail(`${what} must be a $ address${names ? " or a symbol name" : ""}`);
40
- if ((value as string).startsWith("$")) {
41
- try {
42
- parseC64Address(value as string);
43
- } catch {
44
- fail(`${what} must be $ followed by four hex digits, not ${value as string}`);
45
- }
46
- } else if (!names) {
47
- fail(`${what} must be $ followed by four hex digits, not ${value as string}`);
92
+ /** True for an address of a step or a file: $ and 1 to 4 hex digits, or a name. */
93
+ export function isLocation(value: unknown): value is Location {
94
+ return typeof value === "string" && (c64AddressOf(value) !== undefined || SYMBOL_NAME.test(value));
95
+ }
96
+
97
+ /** Checks an address in a file: $ and 1 to 4 hex digits, or a name. */
98
+ export function checkLocation(value: unknown, what: string, fail: (message: string) => never): void {
99
+ if (!isLocation(value)) fail(`${what} must be $ and 1 to 4 hex digits, for example $c000 or $fb, or a name`);
100
+ }
101
+
102
+ /** The PETSCII bytes of a keyboard input, or why the text has no keys. */
103
+ export function keyboardBytes(input: { mode: "text" | "petscii"; text?: string | undefined; bytes?: number[] | undefined }): Uint8Array | string {
104
+ if (input.mode === "petscii") return Uint8Array.from(input.bytes ?? []);
105
+ try {
106
+ return textToPetscii(input.text ?? "");
107
+ } catch (error) {
108
+ if (isRefusal(error)) return `${error.message}. Use mode petscii for other keys`;
109
+ throw error;
48
110
  }
49
111
  }
50
112
 
51
- /** Checks one action step of the given kind. `names` says whether symbol names may stand for addresses. */
52
- export function checkActionStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never, names = true): void {
113
+ /** The checks of one input: `fail` names the field of the problem. */
114
+ class Fields {
115
+ readonly #value: Record<string, unknown>;
116
+ readonly #where: string;
117
+ readonly #fail: (message: string) => never;
118
+
119
+ constructor(value: unknown, where: string, fields: readonly string[], fail: (message: string) => never) {
120
+ this.#where = where;
121
+ this.#fail = fail;
122
+ if (!isObject(value)) fail(`${where} must be an object with ${fields.join(", ")}`);
123
+ this.#value = value as Record<string, unknown>;
124
+ for (const key of Object.keys(this.#value)) if (!fields.includes(key)) fail(`${where} has the field ${key}, which it does not take. Its fields are ${fields.join(", ")}`);
125
+ }
126
+
127
+ fail(field: string, message: string): never {
128
+ return this.#fail(`${this.#where}.${field} ${message}`);
129
+ }
130
+
131
+ has(field: string): boolean {
132
+ return this.#value[field] !== undefined;
133
+ }
134
+
135
+ get(field: string): unknown {
136
+ return this.#value[field];
137
+ }
138
+
139
+ location(field: string): Location {
140
+ const value = this.#value[field];
141
+ if (!isLocation(value)) return this.fail(field, "must be $ and 1 to 4 hex digits, for example $c000 or $fb, or a name");
142
+ return value;
143
+ }
144
+
145
+ integer(field: string, min: number, max: number, fallback?: number): number {
146
+ const value = this.#value[field] ?? fallback;
147
+ if (!isInteger(value, min, max)) return this.fail(field, `must be a whole number from ${min} to ${max}`);
148
+ return value;
149
+ }
150
+
151
+ oneOf<T extends string>(field: string, values: readonly T[], fallback?: T): T {
152
+ const value = this.#value[field] ?? fallback;
153
+ if (!isOneOf(values, value)) return this.fail(field, `must be one of ${values.join(", ")}`);
154
+ return value;
155
+ }
156
+
157
+ boolean(field: string, fallback?: boolean): boolean {
158
+ const value = this.#value[field] ?? fallback;
159
+ if (typeof value !== "boolean") return this.fail(field, "must be true or false");
160
+ return value;
161
+ }
162
+ }
163
+
164
+ function checkCondition(value: unknown, where: string, fail: (message: string) => never): StepCondition {
165
+ const kind = isObject(value) ? value.kind : undefined;
166
+ if (kind === "register") {
167
+ const fields = new Fields(value, where, STEP_FIELDS.registerCondition, fail);
168
+ return { kind, register: fields.oneOf("register", CONDITION_REGISTERS), operator: fields.oneOf("operator", COMPARISONS), value: fields.integer("value", 0, 0xff) };
169
+ }
170
+ if (kind === "memory") {
171
+ const fields = new Fields(value, where, STEP_FIELDS.memoryCondition, fail);
172
+ return { kind, address: fields.location("address"), operator: fields.oneOf("operator", COMPARISONS), value: fields.integer("value", 0, 0xff), space: fields.oneOf("space", SPACES, "c64"), view: fields.oneOf("view", MEMORY_VIEWS, "cpu") };
173
+ }
174
+ if (kind === "raster") return raster(new Fields(value, where, STEP_FIELDS.rasterCondition, fail));
175
+ return fail(`${where}.kind must be one of register, memory, raster`);
176
+ }
177
+
178
+ function raster(fields: Fields): { kind: "raster"; line: number; cycle?: number } {
179
+ const line = fields.integer("line", 0, MAX_RASTER_LINE);
180
+ return fields.has("cycle") ? { kind: "raster", line, cycle: fields.integer("cycle", 0, 64) } : { kind: "raster", line };
181
+ }
182
+
183
+ function checkTarget(value: unknown, where: string, fail: (message: string) => never): StepTarget {
184
+ const kind = isObject(value) ? value.kind : undefined;
185
+ if (kind === "address") {
186
+ const fields = new Fields(value, where, STEP_FIELDS.addressTarget, fail);
187
+ const target: StepTarget = { kind, address: fields.location("address"), space: fields.oneOf("space", SPACES, "c64") };
188
+ if (fields.has("condition")) target.condition = checkCondition(fields.get("condition"), `${where}.condition`, fail);
189
+ return target;
190
+ }
191
+ if (kind === "memory") {
192
+ const fields = new Fields(value, where, STEP_FIELDS.memoryTarget, fail);
193
+ return { kind, address: fields.location("address"), operator: fields.oneOf("operator", COMPARISONS), value: fields.integer("value", 0, 0xff), space: fields.oneOf("space", SPACES, "c64"), view: fields.oneOf("view", MEMORY_VIEWS, "cpu") };
194
+ }
195
+ if (kind === "raster") return raster(new Fields(value, where, STEP_FIELDS.rasterTarget, fail));
196
+ return fail(`${where}.kind must be one of address, memory, raster`);
197
+ }
198
+
199
+ /** What a step of a script without the replay core gets for press. */
200
+ const PRESS_REMEDY = "press pushes keys of the keyboard matrix, and only the record and replay commands of c64-testing have them. Use type for the KERNAL keyboard queue, or open the window and ask the user to press the key";
201
+
202
+ /** Checks the input of one step kind and returns it with the defaults of its MCP tool. */
203
+ function checkInput(kind: ActionStepKind, value: unknown, at: string, fail: (message: string) => never): ActionStep {
204
+ const where = `${at}: ${kind}`;
53
205
  switch (kind) {
54
206
  case "write": {
55
- const write = step.write;
56
- if (!isObject(write) || Object.keys(write).some((key) => key !== "at" && key !== "bytes")) return fail(`${at}: write needs at and bytes, and nothing else`);
57
- checkLocation(write.at, `${at}: write.at`, fail, names);
58
- if (!Array.isArray(write.bytes) || write.bytes.length === 0 || write.bytes.length > MAX_MEMORY_WRITE || !write.bytes.every((byte) => isInteger(byte, 0, 0xff))) {
59
- fail(`${at}: write.bytes must be a list of 1 to ${MAX_MEMORY_WRITE} numbers from 0 to 255`);
60
- }
61
- return;
207
+ const fields = new Fields(value, where, STEP_FIELDS.write, fail);
208
+ const data = fields.get("data");
209
+ if (typeof data !== "string" || !/^(?:[0-9a-fA-F]{2})+$/.test(data)) fields.fail("data", "must be hex bytes, two digits per byte, no separators, for example a9008d20d0");
210
+ if ((data as string).length / 2 > MAX_MEMORY_WRITE) fields.fail("data", `has at most ${MAX_MEMORY_WRITE} bytes`);
211
+ return { write: { address: fields.location("address"), data: (data as string).toLowerCase(), space: fields.oneOf("space", SPACES, "c64"), view: fields.oneOf("view", MEMORY_VIEWS, "cpu") } };
62
212
  }
63
213
  case "registers": {
64
- const registers = step.registers;
65
- if (!isObject(registers) || Object.keys(registers).length === 0) return fail(`${at}: registers must name at least one of ${SET_REGISTERS.join(", ")}`);
66
- for (const [name, value] of Object.entries(registers)) {
67
- if (!SET_REGISTERS.includes(name)) fail(`${at}: registers can set only ${SET_REGISTERS.join(", ")}, not ${name}`);
68
- if (typeof value === "string") checkLocation(value, `${at}: registers.${name}`, fail, names);
69
- else if (!isInteger(value, 0, name === "pc" ? 0xffff : 0xff)) fail(`${at}: registers.${name} must be ${names ? "a symbol name, " : ""}a $ address or a number from 0 to ${name === "pc" ? 65535 : 255}`);
214
+ const fields = new Fields(value, where, STEP_FIELDS.registers, fail);
215
+ const registers: Extract<ActionStep, { registers: unknown }>["registers"] = {};
216
+ if (fields.has("pc")) registers.pc = fields.location("pc");
217
+ for (const name of ["a", "x", "y", "sp"] as const) if (fields.has(name)) registers[name] = fields.integer(name, 0, 0xff);
218
+ if (fields.has("flags")) {
219
+ const flags = new Fields(fields.get("flags"), `${where}.flags`, STEP_FIELDS.flags, fail);
220
+ registers.flags = Object.fromEntries(STEP_FIELDS.flags.filter((name) => flags.has(name)).map((name) => [name, flags.boolean(name)]));
70
221
  }
71
- return;
222
+ if (Object.entries(registers).every(([name, item]) => name === "flags" && Object.keys(item as object).length === 0)) fail(`${where} must name at least one register`);
223
+ return { registers };
72
224
  }
73
225
  case "joystick": {
74
- const joystick = step.joystick;
75
- if (!isObject(joystick) || Object.keys(joystick).some((key) => !["port", "direction", "fire"].includes(key))) return fail(`${at}: joystick needs port and direction, and fire is optional`);
76
- if (joystick.port !== 1 && joystick.port !== 2) fail(`${at}: joystick.port must be 1 or 2`);
77
- if (!(JOYSTICK_DIRECTIONS as readonly unknown[]).includes(joystick.direction)) fail(`${at}: joystick.direction must be one of ${JOYSTICK_DIRECTIONS.join(", ")}`);
78
- if (joystick.fire !== undefined && typeof joystick.fire !== "boolean") fail(`${at}: joystick.fire must be true or false`);
79
- return;
226
+ const fields = new Fields(value, where, STEP_FIELDS.joystick, fail);
227
+ return { joystick: { port: fields.integer("port", 1, 2) as 1 | 2, direction: fields.oneOf("direction", JOYSTICK_DIRECTIONS), fire: fields.boolean("fire", false) } };
80
228
  }
81
229
  case "type": {
82
- if (typeof step.type !== "string" || step.type === "") return fail(`${at}: type needs text`);
83
- let bytes: Uint8Array;
84
- try {
85
- bytes = textToPetscii(step.type);
86
- } catch (error) {
87
- return fail(`${at}: ${(error as Error).message}`);
230
+ const fields = new Fields(value, where, STEP_FIELDS.type, fail);
231
+ const mode = fields.oneOf("mode", ["text", "petscii"] as const);
232
+ let input: KeyboardInput;
233
+ if (mode === "text") {
234
+ if (typeof fields.get("text") !== "string" || fields.get("text") === "" || fields.has("bytes")) fail(`${where}: mode text takes text and no bytes`);
235
+ input = { mode, text: fields.get("text") as string };
236
+ } else {
237
+ const bytes = fields.get("bytes");
238
+ if (!Array.isArray(bytes) || bytes.length === 0 || !bytes.every((byte) => isInteger(byte, 1, 255)) || fields.has("text")) fail(`${where}: mode petscii takes bytes, a list of PETSCII codes from 1 to 255, and no text`);
239
+ input = { mode, bytes: bytes as number[] };
88
240
  }
89
- if (bytes.length > MAX_KEYBOARD_BYTES) fail(`${at}: type can have at most ${MAX_KEYBOARD_BYTES} keys`);
90
- return;
241
+ const keys = keyboardBytes(input);
242
+ if (typeof keys === "string") fail(`${where}.text: ${keys}`);
243
+ else if (keys.length > MAX_KEYBOARD_BYTES) fail(`${where}: the keyboard queue takes at most ${MAX_KEYBOARD_BYTES} keys at once. This input has ${keys.length} keys`);
244
+ return { type: input };
91
245
  }
246
+ case "press":
247
+ if (typeof value !== "string" || value === "") fail(`${where} needs the keys to press as text`);
248
+ return { press: value as string };
92
249
  case "frames":
93
- if (!isInteger(step.frames, 1, MAX_TIMEOUT_FRAMES)) fail(`${at} needs a frame count from 1 to ${MAX_TIMEOUT_FRAMES}`);
94
- return;
250
+ if (!isInteger(value, 1, MAX_TIMEOUT_FRAMES)) fail(`${where} must be a frame count from 1 to ${MAX_TIMEOUT_FRAMES}`);
251
+ return { frames: value as number };
95
252
  case "runUntil": {
96
- const target = step.runUntil;
97
- const keys = isObject(target) ? Object.keys(target).sort().join(",") : "";
98
- // at, raster, at with raster (the address only on that line), or memory.
99
- if (!["at", "raster", "at,raster", "memory"].includes(keys)) return fail(`${at}: runUntil needs at, raster, at and raster, or memory`);
100
- const until = target as Record<string, unknown>;
101
- if ("raster" in until && !isInteger(until.raster, 0, MAX_RASTER_LINE)) fail(`${at}: runUntil.raster must be a raster line from 0 to ${MAX_RASTER_LINE}`);
102
- if ("at" in until) checkLocation(until.at, `${at}: runUntil.at`, fail, names);
103
- else if ("memory" in until) {
104
- const memory = until.memory;
105
- if (!isObject(memory) || Object.keys(memory).some((key) => key !== "at" && key !== "equals")) return fail(`${at}: runUntil.memory needs at and equals, and nothing else`);
106
- checkLocation(memory.at, `${at}: runUntil.memory.at`, fail, names);
107
- if (!isInteger(memory.equals, 0, 0xff)) fail(`${at}: runUntil.memory.equals must be a number from 0 to 255`);
108
- }
109
- if (!isInteger(step.timeoutFrames, 1, MAX_TIMEOUT_FRAMES)) fail(`${at} needs timeoutFrames from 1 to ${MAX_TIMEOUT_FRAMES}`);
110
- return;
253
+ const fields = new Fields(value, where, STEP_FIELDS.runUntil, fail);
254
+ return { runUntil: { target: checkTarget(fields.get("target"), `${where}.target`, fail), timeoutFrames: fields.integer("timeoutFrames", 1, MAX_TIMEOUT_FRAMES, DEFAULT_TIMEOUT_FRAMES) } };
111
255
  }
112
- default:
113
- fail(`${at}: ${kind} is not an action step`);
114
256
  }
115
257
  }
116
258
 
117
- /** Checks a list of action steps only, with $ addresses only: the steps file of c64-unpacker. */
118
- export function checkActionSteps(value: unknown, fail: (message: string) => never): ActionStep[] {
259
+ /**
260
+ * Checks one action step of the given kind and returns it with the defaults
261
+ * filled in. The step has no other key. `press` is refused unless the caller
262
+ * runs the steps on the replay core.
263
+ */
264
+ export function checkActionStep(kind: ActionStepKind, step: Record<string, unknown>, at: string, fail: (message: string) => never, options: { press?: boolean } = {}): ActionStep {
265
+ for (const key of Object.keys(step)) if (key !== kind) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
266
+ if (kind === "press" && options.press !== true) fail(`${at}: ${PRESS_REMEDY}`);
267
+ return checkInput(kind, step[kind], at, fail);
268
+ }
269
+
270
+ /** The kind of a step: its one key from `kinds`. */
271
+ export function stepKind<K extends string>(step: unknown, kinds: readonly K[], at: string, fail: (message: string) => never): K {
272
+ if (!isObject(step)) return fail(`${at} must be an object`);
273
+ const found = Object.keys(step).filter((key) => (kinds as readonly string[]).includes(key));
274
+ if (found.length !== 1) return fail(`${at} must have exactly one of ${kinds.join(", ")}`);
275
+ return found[0] as K;
276
+ }
277
+
278
+ /** Checks a list of action steps and returns them with the defaults filled in. */
279
+ export function checkActionSteps(value: unknown, fail: (message: string) => never, options: { press?: boolean } = {}): ActionStep[] {
119
280
  if (!Array.isArray(value) || value.length === 0) return fail("the steps are a JSON list with at least one step");
120
- value.forEach((step: unknown, index) => {
281
+ return value.map((step: unknown, index) => {
121
282
  const at = `step ${index + 1}`;
122
- if (!isObject(step)) return fail(`${at} must be an object`);
123
- const kinds = Object.keys(step).filter((key) => (ACTION_STEP_KINDS as readonly string[]).includes(key));
124
- if (kinds.length !== 1) return fail(`${at} must have exactly one of ${ACTION_STEP_KINDS.join(", ")}`);
125
- const kind = kinds[0]!;
126
- const allowed = ACTION_STEP_KEYS[kind] ?? [kind];
127
- for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
128
- checkActionStep(kind, step, at, fail, false);
283
+ return checkActionStep(stepKind(step, ACTION_STEP_KINDS, at, fail), step as Record<string, unknown>, at, fail, options);
129
284
  });
130
- return value as ActionStep[];
285
+ }
286
+
287
+ /** Reads a steps file of the project: a JSON list of action steps. `option` names the option of the file in a refusal. */
288
+ export function readActionSteps(path: string, option: string): ActionStep[] {
289
+ const fail = (message: string): never => {
290
+ throw new Refusal("invalid-input", `${option} ${path}: ${message}`);
291
+ };
292
+ let value: unknown;
293
+ try {
294
+ value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
295
+ } catch (error) {
296
+ if (error instanceof SyntaxError) return fail(`it is not JSON: ${error.message}`);
297
+ throw error;
298
+ }
299
+ return checkActionSteps(value, fail);
131
300
  }
132
301
 
133
302
  /** Stops the CPU when it runs: a write, a register change or an exact frame count needs it stopped. */
@@ -135,23 +304,38 @@ async function stop(client: ViceSessionClient): Promise<void> {
135
304
  if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
136
305
  }
137
306
 
307
+ /** A run target of a step with its addresses resolved. */
308
+ export function resolveTarget(target: StepTarget, at: (location: Location) => number): RunTarget {
309
+ if (target.kind === "raster") return target.cycle === undefined ? { kind: "raster", line: target.line } : { kind: "raster", line: target.line, cycle: target.cycle };
310
+ if (target.kind === "memory") return { ...target, address: at(target.address) };
311
+ const { condition } = target;
312
+ return {
313
+ kind: "address",
314
+ address: at(target.address),
315
+ space: target.space,
316
+ ...(condition === undefined ? {} : { condition: (condition.kind === "memory" ? { ...condition, address: at(condition.address) } : condition) as Condition }),
317
+ };
318
+ }
319
+
138
320
  /**
139
- * Runs one action step. `at` turns a location into an address. A runUntil
140
- * step that does not reach its target returns the stop, for the caller to judge.
321
+ * Runs one action step in VICE. `at` turns a location into an address. A
322
+ * runUntil step that does not reach its target returns the stop, for the
323
+ * caller to judge.
141
324
  */
142
325
  export async function runActionStep(client: ViceSessionClient, step: ActionStep, at: (location: Location) => number): Promise<RunUntilResult | undefined> {
143
326
  if ("write" in step) {
144
327
  await stop(client);
145
- await client.memoryWrite({ address: at(step.write.at), data: Buffer.from(step.write.bytes).toString("hex"), space: "c64", view: "cpu" });
328
+ await client.memoryWrite({ ...step.write, address: at(step.write.address) });
146
329
  } else if ("registers" in step) {
147
330
  await stop(client);
148
- const values: Record<string, number> = {};
149
- for (const [name, value] of Object.entries(step.registers)) values[name] = typeof value === "number" ? value : at(value as Location);
150
- await client.registersSet("c64", values);
331
+ const { pc, flags, ...bytes } = step.registers;
332
+ await client.registersSet("c64", { ...bytes, ...(pc === undefined ? {} : { pc: at(pc) }), ...(flags === undefined || Object.keys(flags).length === 0 ? {} : { flags }) });
151
333
  } else if ("joystick" in step) {
152
- await client.joystick({ port: step.joystick.port, direction: step.joystick.direction, fire: step.joystick.fire ?? false });
334
+ await client.joystick(step.joystick);
153
335
  } else if ("type" in step) {
154
- await client.keyboard(textToPetscii(step.type));
336
+ await client.keyboard(keyboardBytes(step.type) as Uint8Array);
337
+ } else if ("press" in step) {
338
+ throw new Refusal("invalid-input", PRESS_REMEDY);
155
339
  } else if ("frames" in step) {
156
340
  await stop(client);
157
341
  // One advance-frames runs at most MAX_EXECUTION_COUNT frames. Each part ends at the raster position
@@ -161,14 +345,7 @@ export async function runActionStep(client: ViceSessionClient, step: ActionStep,
161
345
  if (part.stopReason !== "frames") break;
162
346
  }
163
347
  } else {
164
- const until = step.runUntil;
165
- const target: RunTarget =
166
- "at" in until
167
- ? { kind: "address", address: at(until.at), space: "c64", ...(until.raster === undefined ? {} : { condition: { kind: "raster" as const, line: until.raster } }) }
168
- : "raster" in until
169
- ? { kind: "raster", line: until.raster }
170
- : { kind: "memory", address: at(until.memory.at), operator: "eq", value: until.memory.equals, space: "c64", view: "cpu" };
171
- const reached = await client.runUntil({ target, timeoutFrames: step.timeoutFrames });
348
+ const reached = await client.runUntil({ target: resolveTarget(step.runUntil.target, at), timeoutFrames: step.runUntil.timeoutFrames });
172
349
  if (!reached.reached) return reached;
173
350
  }
174
351
  return undefined;
@@ -1,36 +1,28 @@
1
1
  // Reads project files on the client side for transfer to the Host Runtime.
2
2
  // The host never sees a client path: only bytes and the file's type.
3
3
 
4
+ import { createHash } from "node:crypto";
4
5
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
5
6
  import { dirname, extname, join, relative, sep } from "node:path";
6
7
 
7
- import { isInside, ProjectError, projectRoot, resolveProject, resolveProjectFile, resolveProjectPath } from "../project.ts";
8
+ import { isInside, projectRoot, resolveProject, resolveProjectFile, resolveProjectPath } from "../project.ts";
9
+ import { isRefusal } from "../errors.ts";
8
10
  import { MAX_ATTACHMENT_BYTES } from "../protocol/framing.ts";
9
11
  import { WireFailure } from "../protocol/messages.ts";
10
12
  import { MAX_TREE_FILES, type SourceTree } from "../protocol/tools.ts";
11
13
 
12
- /** Runs a project lookup and gives a refusal of the project rules as a WireFailure with the same code and message. */
13
- function asWireFailure<T>(lookup: () => T): T {
14
- try {
15
- return lookup();
16
- } catch (error) {
17
- if (error instanceof ProjectError) throw new WireFailure(error.code, error.message);
18
- throw error;
19
- }
20
- }
21
-
22
14
  /**
23
15
  * The project folder of a call that names it with `project` (relative to the
24
16
  * working directory) or not. Throws WireFailure when the rules
25
17
  * refuse it, for example at a folder with several projects below it.
26
18
  */
27
19
  export function projectFolder(project: string | undefined): string {
28
- return asWireFailure(() => resolveProject(project, process.cwd(), { remedy: "Give project: <folder>" }));
20
+ return resolveProject(project, process.cwd(), { remedy: "Give project: <folder>" });
29
21
  }
30
22
 
31
23
  /** The project folder of this process (--project or the project lookup), or a WireFailure. */
32
24
  function currentProject(): string {
33
- return asWireFailure(projectRoot);
25
+ return projectRoot();
34
26
  }
35
27
 
36
28
  export interface ProjectFile {
@@ -53,7 +45,7 @@ export function readProjectFile(path: string, options: { root?: string } = {}):
53
45
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
54
46
  throw new WireFailure("not-found", `There is no file at ${path} in the project folder.`);
55
47
  }
56
- if (error instanceof TypeError) {
48
+ if (isRefusal(error) && error.code === "invalid-input") {
57
49
  throw new WireFailure("invalid-input", `${path}: give a path relative to the project folder that stays inside it.`);
58
50
  }
59
51
  throw error;
@@ -99,6 +91,20 @@ export function writeProjectFile(path: string, bytes: Uint8Array, options: { roo
99
91
  /** Directories never staged: version control and the toolkit's own project state. */
100
92
  const SKIPPED_DIRECTORIES = new Set([".git", ".hg", ".svn", ".c64-re-tools"]);
101
93
 
94
+ /** What a result says of a file that it wrote: the path, the size and the SHA-256. The MCP tools and the scripts report a written file in this form. */
95
+ export interface Written {
96
+ out: string;
97
+ bytes: number;
98
+ sha256: string;
99
+ }
100
+
101
+ /** Writes the bytes to a project file as writeProjectFile does, and reports the file. */
102
+ export function writeReported(path: string, data: Uint8Array | string, options: { root?: string; what?: string } = {}): Written {
103
+ const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : data;
104
+ writeProjectFile(path, bytes, options);
105
+ return { out: path, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
106
+ }
107
+
102
108
  /**
103
109
  * Reads a project folder as a source tree for staging: ordinary
104
110
  * files with relative POSIX paths. A symbolic link is followed only when its
@@ -111,7 +117,7 @@ export function readProjectTree(path: string, options: { root?: string } = {}):
111
117
  sourceRoot = resolveProjectFile(path, projectRootPath);
112
118
  } catch (error) {
113
119
  if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new WireFailure("not-found", `There is no directory ${path} in the project folder.`);
114
- if (error instanceof TypeError) throw new WireFailure("invalid-input", `${path}: give a directory relative to the project folder that stays inside it.`);
120
+ if (isRefusal(error) && error.code === "invalid-input") throw new WireFailure("invalid-input", `${path}: give a directory relative to the project folder that stays inside it.`);
115
121
  throw error;
116
122
  }
117
123
  if (!statSync(sourceRoot).isDirectory()) throw new WireFailure("invalid-input", `${path} is not a directory.`);
@@ -255,7 +255,7 @@ export class ViceSessionClient {
255
255
  return this.#request("screenBaselines", {});
256
256
  }
257
257
 
258
- screenDiscard(baseline: string): Promise<{ discarded: boolean }> {
258
+ screenDiscard(baseline: string): Promise<{ removed: boolean }> {
259
259
  return this.#request("screenDiscard", { baseline });
260
260
  }
261
261
 
@@ -6,6 +6,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
6
6
  import { DatabaseSync } from "node:sqlite";
7
7
  import { join } from "node:path";
8
8
 
9
+ import { Refusal } from "../errors.ts";
9
10
  import { ensureLocal, LOCAL_DIRECTORY, PROJECT_DIRECTORY, projectRoot } from "../project.ts";
10
11
  import { ensureSchema } from "./schema.ts";
11
12
  import { compareText, loadText, readText, REVISIONS_FILE, revisionsText, sha256, sqlName, TABLES, tableText, writeAtomic } from "./text.ts";
@@ -19,27 +20,6 @@ export const INDEX_FILE = "knowledge.db";
19
20
  // eol=lf: with core.autocrlf (the Windows default) git would check the files out with CRLF, so two computers would hold other bytes.
20
21
  export const GIT_ATTRIBUTES = "# The knowledge of c64-re-tools: git keeps the lines of both sides of a merge, with LF line ends on every system.\n*.jsonl merge=union text eol=lf\n.gitattributes text eol=lf\n";
21
22
 
22
- export const KNOWLEDGE_ERROR_CODES = [
23
- "invalid-input",
24
- "not-found",
25
- "conflict",
26
- "stale-revision",
27
- "invalid-database",
28
- "unsupported-schema",
29
- ] as const;
30
- export type KnowledgeErrorCode = (typeof KNOWLEDGE_ERROR_CODES)[number];
31
-
32
- /** A refused knowledge operation, with a stable code and an actionable message. */
33
- export class KnowledgeError extends Error {
34
- override name = "KnowledgeError";
35
- readonly code: KnowledgeErrorCode;
36
-
37
- constructor(code: KnowledgeErrorCode, message: string) {
38
- super(message);
39
- this.code = code;
40
- }
41
- }
42
-
43
23
  /** The folder of the knowledge text: .c64-re-tools/knowledge/. */
44
24
  export function textPath(root: string = projectRoot()): string {
45
25
  return join(root, KNOWLEDGE_DIRECTORY, TEXT_DIRECTORY);
@@ -53,7 +33,7 @@ export function indexPath(root: string = projectRoot()): string {
53
33
  /** Refuses a project that has only the knowledge.db of an older c64-re-tools. */
54
34
  function refuseOldLayout(root: string): void {
55
35
  if (!existsSync(textPath(root)) && existsSync(join(root, KNOWLEDGE_DIRECTORY, INDEX_FILE))) {
56
- throw new KnowledgeError(
36
+ throw new Refusal(
57
37
  "unsupported-schema",
58
38
  `${KNOWLEDGE_DIRECTORY}/${INDEX_FILE} is the knowledge of an older c64-re-tools. This c64-re-tools keeps the knowledge as text in ${KNOWLEDGE_DIRECTORY}/${TEXT_DIRECTORY}/. Convert it one time with distribution/convert-knowledge.ts of the c64-re-tools source repository.`,
59
39
  );
@@ -71,7 +51,7 @@ function open(path: string, readOnly: boolean): DatabaseSync {
71
51
  configure(db);
72
52
  db.prepare("SELECT count(*) FROM sqlite_master").get();
73
53
  } catch (error) {
74
- throw new KnowledgeError("invalid-database", `${KNOWLEDGE_DIRECTORY}/${LOCAL_DIRECTORY}/${INDEX_FILE} is not a readable SQLite database: ${(error as Error).message} Delete it: it is made again from the knowledge text.`);
54
+ throw new Refusal("invalid-database", `${KNOWLEDGE_DIRECTORY}/${LOCAL_DIRECTORY}/${INDEX_FILE} is not a readable SQLite database: ${(error as Error).message} Delete it: it is made again from the knowledge text.`);
75
55
  }
76
56
  return db;
77
57
  }