@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.
- package/README.md +8 -4
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +24 -9
- package/skills/c64-assembler/scripts/assembler.ts +95 -0
- package/skills/c64-basic/SKILL.md +17 -9
- package/skills/c64-basic/scripts/basic.ts +26 -36
- package/skills/c64-disk/SKILL.md +17 -7
- package/skills/c64-disk/scripts/disk.ts +130 -104
- package/skills/c64-disk/scripts/lib/containers.ts +10 -12
- package/skills/c64-docs/SKILL.md +47 -28
- package/skills/c64-docs/references/style.md +6 -6
- package/skills/c64-docs/scripts/docs.ts +323 -0
- package/skills/c64-docs/scripts/lib/source-map.ts +1 -1
- package/skills/c64-emulator/SKILL.md +11 -7
- package/skills/c64-knowledge/SKILL.md +34 -21
- package/skills/c64-knowledge/scripts/knowledge.ts +597 -718
- package/skills/c64-knowledge/scripts/lib/listing.ts +5 -7
- package/skills/c64-memory-map/SKILL.md +18 -10
- package/skills/c64-memory-map/scripts/memory-map.ts +81 -0
- package/skills/c64-provenance/SKILL.md +82 -26
- package/skills/c64-provenance/scripts/lib/compare.ts +8 -12
- package/skills/c64-provenance/scripts/lib/leftovers.ts +442 -0
- package/skills/c64-provenance/scripts/lib/library.ts +15 -25
- package/skills/c64-provenance/scripts/lib/runtime.ts +12 -37
- package/skills/c64-provenance/scripts/lib/separate.ts +9 -10
- package/skills/c64-provenance/scripts/provenance.ts +222 -252
- package/skills/c64-reverse-engineering/SKILL.md +18 -15
- package/skills/c64-source/SKILL.md +28 -16
- package/skills/c64-source/scripts/lib/loader.ts +52 -77
- package/skills/c64-source/scripts/lib/source.ts +33 -42
- package/skills/c64-source/scripts/{generate.ts → source.ts} +68 -109
- package/skills/c64-static-analysis/SKILL.md +24 -13
- package/skills/c64-static-analysis/scripts/lib/coverage.ts +3 -4
- package/skills/c64-static-analysis/scripts/lib/findings.ts +2 -2
- package/skills/c64-static-analysis/scripts/lib/instructions.ts +1 -1
- package/skills/c64-static-analysis/scripts/lib/macros.ts +6 -8
- package/skills/c64-static-analysis/scripts/{analyze.ts → static-analysis.ts} +58 -118
- package/skills/c64-testing/SKILL.md +94 -19
- package/skills/c64-testing/references/recording-format.md +125 -0
- package/skills/c64-testing/references/scenario-format.md +24 -19
- package/skills/c64-testing/scripts/{diff.ts → lib/diff-files.ts} +18 -69
- package/skills/c64-testing/scripts/lib/diff.ts +5 -3
- package/skills/c64-testing/scripts/lib/play.ts +334 -0
- package/skills/c64-testing/scripts/lib/recording.ts +662 -0
- package/skills/c64-testing/scripts/lib/replay.ts +105 -0
- package/skills/c64-testing/scripts/{run.ts → lib/runs.ts} +36 -59
- package/skills/c64-testing/scripts/lib/scenario.ts +73 -81
- package/skills/c64-testing/scripts/testing.ts +189 -0
- package/skills/c64-unpacker/SKILL.md +42 -25
- package/skills/c64-unpacker/scripts/lib/evidence.ts +3 -4
- package/skills/c64-unpacker/scripts/lib/loads.ts +12 -10
- package/skills/c64-unpacker/scripts/lib/machine.ts +0 -2
- package/skills/c64-unpacker/scripts/lib/stages.ts +14 -19
- package/skills/c64-unpacker/scripts/{unpack.ts → unpacker.ts} +109 -211
- package/src/c64.ts +35 -20
- package/src/cli/main.ts +2 -2
- package/src/errors.ts +38 -0
- package/src/graphics.ts +3 -1
- package/src/host/server.ts +3 -2
- package/src/host/vice/session.ts +4 -4
- package/src/host-client/entry-state.ts +106 -0
- package/src/host-client/image.ts +21 -10
- package/src/host-client/steps.ts +285 -108
- package/src/host-client/transfer.ts +21 -15
- package/src/host-client/vice-session.ts +1 -1
- package/src/knowledge/database.ts +3 -23
- package/src/knowledge/expression.ts +8 -11
- package/src/knowledge/history.ts +2 -2
- package/src/knowledge/import.ts +13 -14
- package/src/knowledge/names.ts +50 -0
- package/src/knowledge/program.ts +11 -10
- package/src/knowledge/read.ts +16 -21
- package/src/knowledge/schema.ts +9 -3
- package/src/knowledge/text.ts +5 -5
- package/src/knowledge/write.ts +93 -137
- package/src/mcp/inputs.ts +105 -0
- package/src/mcp/output.ts +2 -13
- package/src/mcp/server.ts +12 -60
- package/src/mcp/tools/debug.ts +17 -12
- package/src/mcp/tools/execution.ts +5 -32
- package/src/mcp/tools/input.ts +7 -32
- package/src/mcp/tools/media.ts +6 -5
- package/src/mcp/tools/memory.ts +11 -38
- package/src/mcp/tools/video.ts +6 -5
- package/src/native/c64run.ts +162 -0
- package/src/native/discover.ts +8 -0
- package/src/native/dxa.ts +7 -6
- package/src/native/ghidra/analyze.ts +2 -1
- package/src/native/ghidra/index.ts +2 -1
- package/src/native/processes.ts +4 -3
- package/src/native/status.ts +25 -4
- package/src/native/types.ts +3 -7
- package/src/opcodes.ts +13 -13
- package/src/platform.ts +9 -9
- package/src/project.ts +16 -26
- package/src/protocol/messages.ts +3 -21
- package/src/protocol/vice.ts +7 -7
- package/src/script.ts +491 -49
- package/skills/c64-assembler/scripts/assemble.ts +0 -138
- package/skills/c64-docs/scripts/generate.ts +0 -109
- package/skills/c64-docs/scripts/link.ts +0 -239
- package/skills/c64-memory-map/scripts/memmap.ts +0 -92
- package/skills/c64-testing/scripts/checklist.ts +0 -51
- package/skills/c64-testing/scripts/test.ts +0 -65
package/src/host-client/steps.ts
CHANGED
|
@@ -1,133 +1,302 @@
|
|
|
1
|
-
// The action steps of the step
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
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
|
|
35
|
+
/** A "$xxxx" address or a name, which the caller resolves. */
|
|
11
36
|
export type Location = string;
|
|
12
37
|
|
|
13
|
-
type
|
|
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: {
|
|
17
|
-
| { registers:
|
|
18
|
-
| { joystick: { port: 1 | 2; direction:
|
|
19
|
-
| { type:
|
|
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: {
|
|
62
|
+
| { runUntil: { target: StepTarget; timeoutFrames: number } };
|
|
22
63
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
export const
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
/**
|
|
38
|
-
export function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
/**
|
|
52
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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(
|
|
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
|
|
97
|
-
|
|
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
|
-
/**
|
|
118
|
-
|
|
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.
|
|
281
|
+
return value.map((step: unknown, index) => {
|
|
121
282
|
const at = `step ${index + 1}`;
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
* step that does not reach its target returns the stop, for the
|
|
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({
|
|
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
|
|
149
|
-
|
|
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(
|
|
334
|
+
await client.joystick(step.joystick);
|
|
153
335
|
} else if ("type" in step) {
|
|
154
|
-
await client.keyboard(
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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<{
|
|
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
|
|
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
|
|
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
|
}
|