@henols/c64-re-tools 2.0.0-rc.2 → 2.0.0-rc.21
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 +28 -62
- package/distribution/package.ts +1 -16
- package/distribution/plugin.ts +5 -3
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +25 -6
- package/skills/c64-assembler/scripts/assemble.ts +45 -10
- package/skills/c64-assembler/scripts/lib/compare.ts +48 -0
- package/skills/c64-basic/SKILL.md +15 -7
- package/skills/c64-basic/scripts/basic.ts +4 -4
- package/skills/c64-disk/SKILL.md +34 -11
- package/skills/c64-disk/scripts/disk.ts +65 -10
- package/skills/c64-disk/scripts/lib/containers.ts +124 -0
- package/skills/c64-docs/SKILL.md +138 -0
- package/skills/c64-docs/references/page-types.md +141 -0
- package/skills/c64-docs/references/style.md +43 -0
- package/skills/c64-docs/scripts/generate.ts +102 -0
- package/skills/c64-docs/scripts/lib/links.ts +155 -0
- package/skills/c64-docs/scripts/lib/markdown.ts +79 -0
- package/skills/c64-docs/scripts/lib/reference.ts +406 -0
- package/skills/c64-docs/scripts/lib/source-map.ts +81 -0
- package/skills/c64-docs/scripts/link.ts +207 -0
- package/skills/c64-emulator/SKILL.md +29 -8
- package/skills/c64-knowledge/SKILL.md +201 -15
- package/skills/c64-knowledge/scripts/knowledge.ts +927 -78
- package/skills/c64-knowledge/scripts/lib/listing.ts +162 -0
- package/skills/c64-memory-map/SKILL.md +14 -2
- package/skills/c64-memory-map/scripts/memmap.ts +15 -4
- package/skills/c64-provenance/SKILL.md +100 -11
- package/skills/c64-provenance/scripts/lib/compare.ts +152 -0
- package/skills/c64-provenance/scripts/lib/library.ts +584 -0
- package/skills/c64-provenance/scripts/lib/runtime.ts +191 -0
- package/skills/c64-provenance/scripts/lib/separate.ts +140 -0
- package/skills/c64-provenance/scripts/provenance.ts +477 -24
- package/skills/c64-reverse-engineering/SKILL.md +90 -11
- package/skills/c64-source/SKILL.md +174 -0
- package/skills/c64-source/scripts/generate.ts +788 -0
- package/skills/c64-source/scripts/lib/loader.ts +630 -0
- package/skills/c64-source/scripts/lib/source.ts +1121 -0
- package/skills/c64-static-analysis/SKILL.md +53 -13
- package/skills/c64-static-analysis/scripts/analyze.ts +260 -31
- package/skills/c64-static-analysis/scripts/lib/coverage.ts +72 -0
- package/skills/c64-static-analysis/scripts/{findings.ts → lib/findings.ts} +11 -5
- package/skills/c64-static-analysis/scripts/lib/idioms.ts +158 -0
- package/skills/c64-static-analysis/scripts/lib/instructions.ts +105 -0
- package/skills/c64-static-analysis/scripts/lib/macros.ts +233 -0
- package/skills/c64-static-analysis/scripts/lib/pitfalls.ts +68 -0
- package/skills/c64-static-analysis/scripts/lib/pointers.ts +108 -0
- package/skills/c64-testing/SKILL.md +57 -4
- package/skills/c64-testing/references/scenario-format.md +50 -6
- package/skills/c64-testing/scripts/checklist.ts +3 -3
- package/skills/c64-testing/scripts/diff.ts +167 -0
- package/skills/c64-testing/scripts/lib/diff.ts +70 -0
- package/skills/c64-testing/scripts/{scenario.ts → lib/scenario.ts} +183 -152
- package/skills/c64-testing/scripts/run.ts +241 -0
- package/skills/c64-testing/scripts/test.ts +20 -10
- package/skills/c64-unpacker/SKILL.md +88 -21
- package/skills/c64-unpacker/scripts/lib/evidence.ts +207 -0
- package/skills/c64-unpacker/scripts/lib/loads.ts +209 -0
- package/skills/c64-unpacker/scripts/lib/machine.ts +80 -0
- package/skills/c64-unpacker/scripts/lib/stages.ts +142 -0
- package/skills/c64-unpacker/scripts/unpack.ts +545 -61
- package/src/c64.ts +240 -12
- package/src/cli/cleanup.ts +1 -1
- package/src/cli/main.ts +14 -8
- package/src/cli/status.ts +105 -11
- package/src/graphics.ts +158 -0
- package/src/host/main.ts +57 -31
- package/src/host/server.ts +30 -47
- package/src/host/tools/c1541.ts +34 -5
- package/src/host/tools/index.ts +6 -2
- package/src/host/tools/petcat.ts +10 -3
- package/src/host/tools/status.ts +23 -6
- package/src/host/vice/adapter.ts +64 -20
- package/src/host/vice/chips.ts +11 -1
- package/src/host/vice/process.ts +19 -8
- package/src/host/vice/screen.ts +4 -44
- package/src/host/vice/session.ts +343 -60
- package/src/host-client/autostart.ts +55 -0
- package/src/host-client/connect.ts +14 -14
- package/src/host-client/coverage.ts +36 -0
- package/src/host-client/image.ts +63 -0
- package/src/host-client/steps.ts +175 -0
- package/src/host-client/tools.ts +7 -6
- package/src/host-client/transfer.ts +60 -10
- package/src/host-client/vice-session.ts +107 -28
- package/src/knowledge/conflicts.ts +161 -0
- package/src/knowledge/database.ts +150 -38
- package/src/knowledge/expression.ts +209 -0
- package/src/knowledge/graph.ts +104 -0
- package/src/knowledge/history.ts +105 -27
- package/src/knowledge/import.ts +141 -85
- package/src/knowledge/join.ts +211 -0
- package/src/knowledge/program.ts +258 -0
- package/src/knowledge/read.ts +445 -63
- package/src/knowledge/schema.ts +277 -65
- package/src/knowledge/text.ts +415 -0
- package/src/knowledge/write.ts +1145 -150
- package/src/mcp/main.ts +44 -19
- package/src/mcp/output.ts +39 -0
- package/src/mcp/server.ts +15 -4
- package/src/mcp/tools/debug.ts +202 -31
- package/src/mcp/tools/execution.ts +39 -5
- package/src/mcp/tools/input.ts +7 -4
- package/src/mcp/tools/machine.ts +14 -2
- package/src/mcp/tools/media.ts +44 -16
- package/src/mcp/tools/memory.ts +68 -12
- package/src/mcp/tools/video.ts +20 -9
- package/src/native/discover.ts +44 -6
- package/src/native/ghidra/analyze.ts +2 -2
- package/src/native/ghidra/index.ts +19 -3
- package/src/native/ghidra/language/c64rt_6510.slaspec +4 -1
- package/src/native/ghidra/scripts/C64Export.java +33 -1
- package/src/native/ghidra/scripts/C64Prepare.java +7 -1
- package/src/native/status.ts +15 -3
- package/src/native/types.ts +43 -8
- package/src/opcodes.ts +133 -0
- package/src/package.ts +21 -0
- package/{skills/c64-memory-map/scripts → src}/platform.ts +87 -7
- package/src/project.ts +166 -3
- package/src/protocol/messages.ts +17 -10
- package/src/protocol/tools.ts +27 -22
- package/src/protocol/vice.ts +255 -33
- package/src/script.ts +49 -9
- package/src/trace.ts +55 -23
- package/skills/c64-provenance/scripts/compare.ts +0 -92
- package/skills/c64-unpacker/scripts/evidence.ts +0 -135
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Autostart without the autostart's own RUN: the caller decides when the
|
|
2
|
+
// program starts, so keys it types later never mix with VICE's typing.
|
|
3
|
+
|
|
4
|
+
import { textToPetscii } from "../c64.ts";
|
|
5
|
+
import type { ViceSessionClient } from "./vice-session.ts";
|
|
6
|
+
|
|
7
|
+
/** How often, and for how long, a load is checked. */
|
|
8
|
+
export const LOAD_POLL_FRAMES = 25;
|
|
9
|
+
export const LOAD_LIMIT_FRAMES = 1500;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Autostarts the PRG at `path` with run false and advances frames until its
|
|
13
|
+
* first bytes are at its load address. Returns false when they do not arrive
|
|
14
|
+
* within LOAD_LIMIT_FRAMES.
|
|
15
|
+
*/
|
|
16
|
+
export async function autostartLoaded(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<boolean> {
|
|
17
|
+
const load = prg[0]! | (prg[1]! << 8);
|
|
18
|
+
const head = Buffer.from(prg.subarray(2, Math.min(prg.length, 2 + 64))).toString("hex");
|
|
19
|
+
await client.autostart({ path, index: 0, run: false });
|
|
20
|
+
for (let frames = 0; frames < LOAD_LIMIT_FRAMES; frames += LOAD_POLL_FRAMES) {
|
|
21
|
+
await client.execution({ action: "advance-frames", count: LOAD_POLL_FRAMES, space: "c64" });
|
|
22
|
+
if ((await client.memoryRead({ address: load, size: head.length / 2, space: "c64", view: "ram" })).data === head) return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** CURLIN high byte: $ff while BASIC waits in direct mode, a line number's high byte while a program runs. */
|
|
28
|
+
const CURLIN_HIGH = 0x003a;
|
|
29
|
+
export const RUN_LIMIT_FRAMES = 300;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Autostart that returns only when the program runs: load without VICE's
|
|
33
|
+
* own RUN, type RUN, and wait until BASIC runs a program line. A key that a
|
|
34
|
+
* later step types then reaches the program, never the READY prompt. Returns
|
|
35
|
+
* why it did not start, or undefined when it runs.
|
|
36
|
+
*/
|
|
37
|
+
export async function runProgram(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<"not-loaded" | "not-started" | undefined> {
|
|
38
|
+
if (!(await autostartLoaded(client, path, prg))) return "not-loaded";
|
|
39
|
+
return (await typeRun(client)) ? undefined : "not-started";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Types RUN at the READY prompt and stops where BASIC starts the first line,
|
|
44
|
+
* before its SYS. Every tool that starts a loaded program this way reaches
|
|
45
|
+
* its entry at the same raster line and timer values (D62). False when BASIC
|
|
46
|
+
* runs no line within RUN_LIMIT_FRAMES.
|
|
47
|
+
*/
|
|
48
|
+
export async function typeRun(client: ViceSessionClient): Promise<boolean> {
|
|
49
|
+
await client.keyboard(textToPetscii("RUN\n"));
|
|
50
|
+
const started = await client.runUntil({
|
|
51
|
+
target: { kind: "memory", address: CURLIN_HIGH, operator: "ne", value: 0xff, space: "c64", view: "ram" },
|
|
52
|
+
timeoutFrames: RUN_LIMIT_FRAMES,
|
|
53
|
+
});
|
|
54
|
+
return started.reached;
|
|
55
|
+
}
|
|
@@ -4,7 +4,8 @@ import { connect as connectTcp, type Socket } from "node:net";
|
|
|
4
4
|
|
|
5
5
|
import { encodeFrame, MessageReader } from "../protocol/framing.ts";
|
|
6
6
|
import { elapsedMs, trace } from "../trace.ts";
|
|
7
|
-
import { DEFAULT_HOST_PORT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HOST_PROTOCOL_ID, HOST_PROTOCOL_VERSION, parseHostMessage, ProtocolError, WireFailure,
|
|
7
|
+
import { DEFAULT_HOST_PORT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HOST_PROTOCOL_ID, HOST_PROTOCOL_VERSION, parseHostMessage, ProtocolError, WireFailure, withStopAndAsk, type Hello, type HostMessage, type Ping, type Request, type Role, type VideoStandard } from "../protocol/messages.ts";
|
|
8
|
+
import { PACKAGE } from "../package.ts";
|
|
8
9
|
|
|
9
10
|
export interface Endpoint {
|
|
10
11
|
host: string;
|
|
@@ -36,17 +37,19 @@ export function hostEndpoints(env: NodeJS.ProcessEnv = process.env): Endpoint[]
|
|
|
36
37
|
return ["127.0.0.1", "host.docker.internal", "host.containers.internal"].map((host) => ({ host, port: DEFAULT_HOST_PORT }));
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
/** A host that does not run or cannot be reached is machine-unavailable for every role (D37). */
|
|
41
|
+
function unreachable(): WireFailure {
|
|
41
42
|
return new WireFailure(
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
"machine-unavailable",
|
|
44
|
+
withStopAndAsk(
|
|
45
|
+
`The c64-re-tools host runtime is not running or cannot be reached. Start it on the host (npx -y --package=${PACKAGE.name}@${PACKAGE.tag} c64-re-tools-host) and try again.`,
|
|
46
|
+
),
|
|
44
47
|
);
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
function stopped(
|
|
50
|
+
function stopped(): WireFailure {
|
|
48
51
|
return new WireFailure(
|
|
49
|
-
|
|
52
|
+
"machine-unavailable",
|
|
50
53
|
"The connection to the c64-re-tools host runtime stopped before the host runtime was ready.",
|
|
51
54
|
);
|
|
52
55
|
}
|
|
@@ -143,16 +146,13 @@ export class HostConnection {
|
|
|
143
146
|
}
|
|
144
147
|
if (signal?.aborted) {
|
|
145
148
|
socket?.destroy();
|
|
146
|
-
throw stopped(
|
|
149
|
+
throw stopped();
|
|
147
150
|
}
|
|
148
|
-
if (socket === undefined) throw unreachable(
|
|
151
|
+
if (socket === undefined) throw unreachable();
|
|
149
152
|
|
|
150
153
|
const connection = new HostConnection(socket);
|
|
151
154
|
const hello: Hello = { type: "hello", protocol: HOST_PROTOCOL_ID, version: HOST_PROTOCOL_VERSION, role: options.role };
|
|
152
155
|
if (options.videoStandard !== undefined) hello.videoStandard = options.videoStandard;
|
|
153
|
-
// A host that listens beyond loopback checks this shared secret.
|
|
154
|
-
const token = (options.env ?? process.env).C64RT_HOST_TOKEN;
|
|
155
|
-
if (token !== undefined && token !== "") hello.token = token;
|
|
156
156
|
|
|
157
157
|
const stop = () => void connection.close();
|
|
158
158
|
signal?.addEventListener("abort", stop, { once: true });
|
|
@@ -172,7 +172,7 @@ export class HostConnection {
|
|
|
172
172
|
signal?.removeEventListener("abort", stop);
|
|
173
173
|
if (signal?.aborted) {
|
|
174
174
|
await connection.close();
|
|
175
|
-
throw stopped(
|
|
175
|
+
throw stopped();
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
if (!(answer instanceof Error) && answer.type === "ready") {
|
|
@@ -190,7 +190,7 @@ export class HostConnection {
|
|
|
190
190
|
);
|
|
191
191
|
}
|
|
192
192
|
throw new WireFailure(
|
|
193
|
-
|
|
193
|
+
"machine-unavailable",
|
|
194
194
|
"The c64-re-tools host runtime did not finish the handshake. Check that it is running and try again.",
|
|
195
195
|
);
|
|
196
196
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Reads the emulator's memory access map as coverage bytes (D40): one byte
|
|
2
|
+
// per address, RAM, ROM and I/O apart. The MCP tool and the skill scripts share it.
|
|
3
|
+
|
|
4
|
+
import { COVERAGE, type CodeRange } from "../c64.ts";
|
|
5
|
+
import { MAX_MEMMAP_RANGES, type MemmapRange } from "../protocol/vice.ts";
|
|
6
|
+
import type { ViceSessionClient } from "./vice-session.ts";
|
|
7
|
+
|
|
8
|
+
/** A full coverage file: offset n is address n (D40). */
|
|
9
|
+
export const FULL_COVERAGE = 0x10000;
|
|
10
|
+
|
|
11
|
+
/** The coverage bytes of start..end, read from the session in parts. */
|
|
12
|
+
export async function readCoverage(session: Pick<ViceSessionClient, "memmap">, start = 0x0000, end = 0xffff): Promise<Uint8Array> {
|
|
13
|
+
const map = new Uint8Array(end - start + 1);
|
|
14
|
+
for (let from: number | undefined = start; from !== undefined; ) {
|
|
15
|
+
const part: { ranges: MemmapRange[]; next?: number } = await session.memmap({ action: "read", start: from, end, maxRanges: MAX_MEMMAP_RANGES });
|
|
16
|
+
for (const range of part.ranges) map.fill(range.flags, range.start - start, range.end - start + 1);
|
|
17
|
+
from = part.next;
|
|
18
|
+
}
|
|
19
|
+
return map;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Runs of RAM that a program wrote, at least `minimum` bytes each, in a map from $0000. */
|
|
23
|
+
export function writtenBlocks(map: Uint8Array, minimum: number): CodeRange[] {
|
|
24
|
+
const blocks: CodeRange[] = [];
|
|
25
|
+
let start = -1;
|
|
26
|
+
for (let address = 0; address <= map.length; address++) {
|
|
27
|
+
const written = address < map.length && (map[address]! & COVERAGE.ramWrite) !== 0;
|
|
28
|
+
if (written && start < 0) start = address;
|
|
29
|
+
if (!written && start >= 0) {
|
|
30
|
+
if (address - start >= minimum) blocks.push({ start, end: address - 1 });
|
|
31
|
+
start = -1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return blocks;
|
|
35
|
+
}
|
|
36
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// An image.json of the c64-unpacker skill (a 64 KiB memory and its segments)
|
|
2
|
+
// or a PRG, read from the project to put it into the knowledge (D83).
|
|
3
|
+
|
|
4
|
+
import { posix } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { formatC64Address, prgRange } from "../c64.ts";
|
|
7
|
+
import { readProjectFile } from "./transfer.ts";
|
|
8
|
+
|
|
9
|
+
/** A file that is no image: the scripts report it as a bad call. */
|
|
10
|
+
export class ImageError extends Error {
|
|
11
|
+
override name = "ImageError";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Image {
|
|
15
|
+
segments: Array<{ start: number; end: number }>;
|
|
16
|
+
/** The byte at an address inside a segment. */
|
|
17
|
+
byteAt: (address: number) => number;
|
|
18
|
+
entry?: number;
|
|
19
|
+
/** entry_state.json next to image.json. */
|
|
20
|
+
statePath?: string;
|
|
21
|
+
/** The stages of the capture in image.json, as the unpacker wrote them. */
|
|
22
|
+
stages?: Array<Record<string, unknown>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Reads an image.json or a PRG, relative to the project folder. */
|
|
26
|
+
export function readImage(path: string): Image {
|
|
27
|
+
const { bytes } = readProjectFile(path);
|
|
28
|
+
if (!path.endsWith(".json")) {
|
|
29
|
+
let range: ReturnType<typeof prgRange>;
|
|
30
|
+
try {
|
|
31
|
+
range = prgRange(bytes);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new ImageError(`${path}: ${(error as Error).message}`);
|
|
34
|
+
}
|
|
35
|
+
return { segments: [{ start: range.start, end: range.end }], byteAt: (address) => range.body[address - range.start]! };
|
|
36
|
+
}
|
|
37
|
+
const fail = (what: string): never => {
|
|
38
|
+
throw new ImageError(`${path} is not an image.json of the c64-unpacker skill: ${what}`);
|
|
39
|
+
};
|
|
40
|
+
let manifest: Record<string, unknown>;
|
|
41
|
+
try {
|
|
42
|
+
manifest = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return fail((error as Error).message);
|
|
45
|
+
}
|
|
46
|
+
const address = (value: unknown, what: string) => (typeof value === "string" && /^\$[0-9a-f]{4}$/i.test(value) ? Number.parseInt(value.slice(1), 16) : fail(`${what} is not a $ address`));
|
|
47
|
+
if (typeof manifest.image !== "string") fail("it has no image");
|
|
48
|
+
if (!Array.isArray(manifest.segments) || manifest.segments.length === 0) fail("it has no segments");
|
|
49
|
+
const segments = (manifest.segments as Array<Record<string, unknown>>)
|
|
50
|
+
.map((segment, index) => ({ start: address(segment.start, `segment ${index + 1} start`), end: address(segment.end, `segment ${index + 1} end`) }))
|
|
51
|
+
.sort((a, b) => a.start - b.start);
|
|
52
|
+
segments.forEach((segment, index) => {
|
|
53
|
+
if (segment.end < segment.start) fail(`the segment at ${formatC64Address(segment.start)} ends before it starts`);
|
|
54
|
+
if (index > 0 && segment.start <= segments[index - 1]!.end) fail(`the segments at ${formatC64Address(segments[index - 1]!.start)} and ${formatC64Address(segment.start)} overlap`);
|
|
55
|
+
});
|
|
56
|
+
const directory = posix.dirname(path);
|
|
57
|
+
const memory = readProjectFile(posix.join(directory, manifest.image as string)).bytes;
|
|
58
|
+
if (memory.length !== 0x10000) fail(`its image has ${memory.length} bytes, not 65536`);
|
|
59
|
+
const image: Image = { segments, byteAt: (at) => memory[at]!, statePath: posix.join(directory, "entry_state.json") };
|
|
60
|
+
if (manifest.entry !== undefined) image.entry = address(manifest.entry, "entry");
|
|
61
|
+
if (Array.isArray(manifest.stages)) image.stages = manifest.stages as Array<Record<string, unknown>>;
|
|
62
|
+
return image;
|
|
63
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The action steps of the step language (D43): 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.
|
|
4
|
+
|
|
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";
|
|
8
|
+
import type { ViceSessionClient } from "./vice-session.ts";
|
|
9
|
+
|
|
10
|
+
/** A "$xxxx" address, or a symbol name where the caller resolves names. */
|
|
11
|
+
export type Location = string;
|
|
12
|
+
|
|
13
|
+
type JoystickDirection = (typeof JOYSTICK_DIRECTIONS)[number];
|
|
14
|
+
|
|
15
|
+
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 }
|
|
20
|
+
| { frames: number }
|
|
21
|
+
| { runUntil: { at: Location; raster?: number } | { raster: number } | { memory: { at: Location; equals: number } }; timeoutFrames: number };
|
|
22
|
+
|
|
23
|
+
/** The highest raster line of a PAL frame; an NTSC frame has fewer, which the emulator checks. */
|
|
24
|
+
const MAX_RASTER_LINE = 311;
|
|
25
|
+
|
|
26
|
+
export const ACTION_STEP_KINDS = ["write", "registers", "joystick", "type", "frames", "runUntil"] as const;
|
|
27
|
+
|
|
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"];
|
|
33
|
+
|
|
34
|
+
export const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
export const isInteger = (value: unknown, min: number, max: number): value is number => Number.isInteger(value) && (value as number) >= min && (value as number) <= max;
|
|
36
|
+
|
|
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}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
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 {
|
|
53
|
+
switch (kind) {
|
|
54
|
+
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;
|
|
62
|
+
}
|
|
63
|
+
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}`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
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;
|
|
80
|
+
}
|
|
81
|
+
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}`);
|
|
88
|
+
}
|
|
89
|
+
if (bytes.length > MAX_KEYBOARD_BYTES) fail(`${at}: type can have at most ${MAX_KEYBOARD_BYTES} keys`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
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;
|
|
95
|
+
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, D46), 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;
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
fail(`${at}: ${kind} is not an action step`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
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[] {
|
|
119
|
+
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) => {
|
|
121
|
+
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);
|
|
129
|
+
});
|
|
130
|
+
return value as ActionStep[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Stops the CPU when it runs: a write, a register change or an exact frame count needs it stopped. */
|
|
134
|
+
async function stop(client: ViceSessionClient): Promise<void> {
|
|
135
|
+
if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
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.
|
|
141
|
+
*/
|
|
142
|
+
export async function runActionStep(client: ViceSessionClient, step: ActionStep, at: (location: Location) => number): Promise<RunUntilResult | undefined> {
|
|
143
|
+
if ("write" in step) {
|
|
144
|
+
await stop(client);
|
|
145
|
+
await client.memoryWrite({ address: at(step.write.at), data: Buffer.from(step.write.bytes).toString("hex"), space: "c64", view: "cpu" });
|
|
146
|
+
} else if ("registers" in step) {
|
|
147
|
+
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);
|
|
151
|
+
} else if ("joystick" in step) {
|
|
152
|
+
await client.joystick({ port: step.joystick.port, direction: step.joystick.direction, fire: step.joystick.fire ?? false });
|
|
153
|
+
} else if ("type" in step) {
|
|
154
|
+
await client.keyboard(textToPetscii(step.type));
|
|
155
|
+
} else if ("frames" in step) {
|
|
156
|
+
await stop(client);
|
|
157
|
+
// One advance-frames runs at most MAX_EXECUTION_COUNT frames. Each part ends at the raster position
|
|
158
|
+
// where it started, so the parts give the same frames as one run; a breakpoint ends them all.
|
|
159
|
+
for (let left = step.frames; left > 0; left -= MAX_EXECUTION_COUNT) {
|
|
160
|
+
const part = await client.execution({ action: "advance-frames", count: Math.min(left, MAX_EXECUTION_COUNT), space: "c64" });
|
|
161
|
+
if (part.stopReason !== "frames") break;
|
|
162
|
+
}
|
|
163
|
+
} 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 });
|
|
172
|
+
if (!reached.reached) return reached;
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
package/src/host-client/tools.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// ACME, DXA and Ghidra themselves through src/native.
|
|
5
5
|
|
|
6
6
|
import { ProtocolError, WireFailure } from "../protocol/messages.ts";
|
|
7
|
-
import { DISK_IMAGE_TYPES, validateToolResult, type C1541Result, type DiskAction, type DiskImageType, type PetcatResult, type ToolOperation, type ToolOperations, type ToolStatus } from "../protocol/tools.ts";
|
|
7
|
+
import { DISK_IMAGE_TYPES, validateToolResult, type C1541Result, type DiskAction, type DiskImageType, type PetcatResult, type ToolOperation, type ToolOperations, type HostInfo, type ToolStatus } from "../protocol/tools.ts";
|
|
8
8
|
import { HostConnection } from "./connect.ts";
|
|
9
9
|
import { readProjectFile } from "./transfer.ts";
|
|
10
10
|
|
|
@@ -43,7 +43,7 @@ export async function callTool<O extends ToolOperation>(
|
|
|
43
43
|
}
|
|
44
44
|
});
|
|
45
45
|
void connection.closed.then(() =>
|
|
46
|
-
reject(new WireFailure("
|
|
46
|
+
reject(new WireFailure("machine-unavailable", "The connection to the c64-re-tools host runtime was lost during the request. Check that the Host Runtime (c64-re-tools-host) is running.")),
|
|
47
47
|
);
|
|
48
48
|
connection.send({ type: "request", id: 1, op, params }, attachments);
|
|
49
49
|
});
|
|
@@ -78,12 +78,13 @@ export async function decodeBasic(request: { program: string }, options: ToolCal
|
|
|
78
78
|
return (await callTool("petcat.decode", {}, [file.bytes], options)).result;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** host.status: each native tool on the host, found and run once. */
|
|
82
|
-
export async function hostTools(options: ToolCallOptions = {}): Promise<ToolStatus[]> {
|
|
83
|
-
return (await callTool("host.status", {}, [], options)).result
|
|
81
|
+
/** host.status: the Host Runtime, and each native tool on the host, found and run once. */
|
|
82
|
+
export async function hostTools(options: ToolCallOptions = {}): Promise<{ host: HostInfo; tools: ToolStatus[] }> {
|
|
83
|
+
return (await callTool("host.status", {}, [], options)).result;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/** Skill scripts see failures and wire limits through the host-client, never the private protocol module. */
|
|
87
87
|
export { WireFailure } from "../protocol/messages.ts";
|
|
88
88
|
export { DISK_ACTIONS } from "../protocol/tools.ts";
|
|
89
|
-
export { JOYSTICK_DIRECTIONS, MAX_BASELINES, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES } from "../protocol/vice.ts";
|
|
89
|
+
export { JOYSTICK_DIRECTIONS, MAX_BASELINES, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES, maskCoversFrame } from "../protocol/vice.ts";
|
|
90
|
+
export { MAX_TRACE_FRAMES, MAX_TRACE_HITS, MAX_TRACE_POINTS, TRACE_KINDS, type TraceHit, type TraceKind } from "../protocol/vice.ts";
|
|
@@ -1,14 +1,38 @@
|
|
|
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 { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
|
-
import { extname, join, relative, sep } from "node:path";
|
|
4
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, extname, join, relative, sep } from "node:path";
|
|
6
6
|
|
|
7
|
-
import { isInside, projectRoot, resolveProjectFile } from "../project.ts";
|
|
7
|
+
import { isInside, ProjectError, projectRoot, resolveProject, resolveProjectFile, resolveProjectPath } from "../project.ts";
|
|
8
8
|
import { MAX_ATTACHMENT_BYTES } from "../protocol/framing.ts";
|
|
9
9
|
import { WireFailure } from "../protocol/messages.ts";
|
|
10
10
|
import { MAX_TREE_FILES, type SourceTree } from "../protocol/tools.ts";
|
|
11
11
|
|
|
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
|
+
/**
|
|
23
|
+
* The project folder of a call that names it with `project` (relative to the
|
|
24
|
+
* working directory) or not (D67, D68). Throws WireFailure when the rules
|
|
25
|
+
* refuse it, for example at a folder with several projects below it.
|
|
26
|
+
*/
|
|
27
|
+
export function projectFolder(project: string | undefined): string {
|
|
28
|
+
return asWireFailure(() => resolveProject(project, process.cwd(), { remedy: "Give project: <folder>" }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The project folder of this process (--project or the D67 lookup), or a WireFailure. */
|
|
32
|
+
function currentProject(): string {
|
|
33
|
+
return asWireFailure(projectRoot);
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
export interface ProjectFile {
|
|
13
37
|
bytes: Buffer;
|
|
14
38
|
/** Lowercase extension without the dot; empty when the file has none. */
|
|
@@ -21,16 +45,16 @@ export interface ProjectFile {
|
|
|
21
45
|
* the file is too large to transfer.
|
|
22
46
|
*/
|
|
23
47
|
export function readProjectFile(path: string, options: { root?: string } = {}): ProjectFile {
|
|
24
|
-
const root = options.root ??
|
|
48
|
+
const root = options.root ?? currentProject();
|
|
25
49
|
let real: string;
|
|
26
50
|
try {
|
|
27
51
|
real = resolveProjectFile(path, root);
|
|
28
52
|
} catch (error) {
|
|
29
53
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
30
|
-
throw new WireFailure("not-found", `There is no file at ${path} in the project
|
|
54
|
+
throw new WireFailure("not-found", `There is no file at ${path} in the project folder.`);
|
|
31
55
|
}
|
|
32
56
|
if (error instanceof TypeError) {
|
|
33
|
-
throw new WireFailure("invalid-input", `${path}: give a path relative to the project
|
|
57
|
+
throw new WireFailure("invalid-input", `${path}: give a path relative to the project folder that stays inside it.`);
|
|
34
58
|
}
|
|
35
59
|
throw error;
|
|
36
60
|
}
|
|
@@ -42,22 +66,48 @@ export function readProjectFile(path: string, options: { root?: string } = {}):
|
|
|
42
66
|
return { bytes: readFileSync(real), type: extname(path).slice(1).toLowerCase() };
|
|
43
67
|
}
|
|
44
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Writes bytes to a project file on the client side, making its directories
|
|
71
|
+
* and overwriting it. Throws WireFailure(invalid-input) for a path outside the
|
|
72
|
+
* project, also one that leaves it through a symbolic link.
|
|
73
|
+
*/
|
|
74
|
+
export function writeProjectFile(path: string, bytes: Uint8Array, options: { root?: string; what?: string } = {}): void {
|
|
75
|
+
const root = options.root ?? currentProject();
|
|
76
|
+
const refuse = () => new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
|
|
77
|
+
let file: string;
|
|
78
|
+
try {
|
|
79
|
+
file = resolveProjectPath(path, root);
|
|
80
|
+
} catch {
|
|
81
|
+
throw refuse();
|
|
82
|
+
}
|
|
83
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
84
|
+
if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
|
|
85
|
+
let link = false;
|
|
86
|
+
try {
|
|
87
|
+
link = lstatSync(file).isSymbolicLink();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
90
|
+
}
|
|
91
|
+
if (link) throw refuse();
|
|
92
|
+
writeFileSync(file, bytes);
|
|
93
|
+
}
|
|
94
|
+
|
|
45
95
|
/** Directories never staged: version control and the toolkit's own project state. */
|
|
46
96
|
const SKIPPED_DIRECTORIES = new Set([".git", ".hg", ".svn", ".c64-re-tools"]);
|
|
47
97
|
|
|
48
98
|
/**
|
|
49
|
-
* Reads a project
|
|
99
|
+
* Reads a project folder as a source tree for staging: ordinary
|
|
50
100
|
* files with relative POSIX paths. A symbolic link is followed only when its
|
|
51
101
|
* target stays inside the source root; otherwise the tree is refused.
|
|
52
102
|
*/
|
|
53
103
|
export function readProjectTree(path: string, options: { root?: string } = {}): { files: SourceTree; contents: Buffer[] } {
|
|
54
|
-
const projectRootPath = options.root ??
|
|
104
|
+
const projectRootPath = options.root ?? currentProject();
|
|
55
105
|
let sourceRoot: string;
|
|
56
106
|
try {
|
|
57
107
|
sourceRoot = resolveProjectFile(path, projectRootPath);
|
|
58
108
|
} catch (error) {
|
|
59
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new WireFailure("not-found", `There is no directory ${path} in the project
|
|
60
|
-
if (error instanceof TypeError) throw new WireFailure("invalid-input", `${path}: give a directory relative to the project
|
|
109
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new WireFailure("not-found", `There is no directory ${path} in the project folder.`);
|
|
110
|
+
if (error instanceof TypeError) throw new WireFailure("invalid-input", `${path}: give a directory relative to the project folder that stays inside it.`);
|
|
61
111
|
throw error;
|
|
62
112
|
}
|
|
63
113
|
if (!statSync(sourceRoot).isDirectory()) throw new WireFailure("invalid-input", `${path} is not a directory.`);
|