@henols/c64-re-tools 2.0.0 → 2.1.0-rc.1
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-provenance/SKILL.md +50 -3
- package/skills/c64-provenance/scripts/lib/leftovers.ts +443 -0
- package/skills/c64-provenance/scripts/provenance.ts +79 -7
- package/skills/c64-reverse-engineering/SKILL.md +7 -5
- package/skills/c64-testing/SKILL.md +61 -2
- package/skills/c64-testing/references/recording-format.md +96 -0
- package/skills/c64-testing/scripts/lib/play.ts +144 -0
- package/skills/c64-testing/scripts/lib/recording.ts +629 -0
- package/skills/c64-testing/scripts/record.ts +96 -0
- package/skills/c64-testing/scripts/replay.ts +163 -0
- package/src/cli/main.ts +7 -1
- package/src/host/main.ts +7 -1
- package/src/mcp/main.ts +7 -1
- package/src/native/c64run.ts +162 -0
- package/src/native/discover.ts +8 -0
- package/src/native/status.ts +23 -2
- package/src/package.ts +13 -3
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The c64-testing record script: plays the steps of a record file on the
|
|
2
|
+
// original program in the replay core (c64run), and writes the session as a
|
|
3
|
+
// recording folder: recording.json, reads.txt, checkpoints.txt, coverage.bin.
|
|
4
|
+
// Exit status: 0 recorded, 1 the session did not complete, 2 a bad call.
|
|
5
|
+
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
|
|
8
|
+
import { COVERAGE } from "#src/c64.ts";
|
|
9
|
+
import { writeProjectFile } from "#src/host-client/transfer.ts";
|
|
10
|
+
import { C64RunSession, PAL_FRAME_CYCLES } from "#src/native/c64run.ts";
|
|
11
|
+
import { localToolContext } from "#src/native/local.ts";
|
|
12
|
+
import { Workspace } from "#src/native/staging.ts";
|
|
13
|
+
import { resolveProjectPath } from "#src/project.ts";
|
|
14
|
+
import { runScript, UsageError } from "#src/script.ts";
|
|
15
|
+
import { play } from "./lib/play.ts";
|
|
16
|
+
import { record, readRecordFile, RECORDING_FILES } from "./lib/recording.ts";
|
|
17
|
+
import { ScenarioError } from "./lib/scenario.ts";
|
|
18
|
+
|
|
19
|
+
const USAGE = `record.ts <record.json> --out <folder> [--play [--joystick 1|2] [--http-port <n>]]
|
|
20
|
+
|
|
21
|
+
<record.json> is a record file relative to the project folder. See
|
|
22
|
+
references/recording-format.md. --out is the recording folder, for example
|
|
23
|
+
tests/recordings/<name>. With --play, a person plays after the steps: the
|
|
24
|
+
script prints the address of a page on stderr and runs until Stop on the page.
|
|
25
|
+
--joystick is the port of the arrow keys, space and the gamepad (default 2).
|
|
26
|
+
--http-port is the port of the page (default 6465). The result is one JSON object.`;
|
|
27
|
+
|
|
28
|
+
/** The default port of the --play page: next to the Host Runtime's 6464. */
|
|
29
|
+
const DEFAULT_HTTP_PORT = 6465;
|
|
30
|
+
|
|
31
|
+
async function run(argv: string[]): Promise<Record<string, unknown>> {
|
|
32
|
+
const { values, positionals } = parseArgs({
|
|
33
|
+
args: argv,
|
|
34
|
+
strict: true,
|
|
35
|
+
allowPositionals: true,
|
|
36
|
+
options: { out: { type: "string" }, play: { type: "boolean" }, joystick: { type: "string" }, "http-port": { type: "string" }, help: { type: "boolean" } },
|
|
37
|
+
});
|
|
38
|
+
if (values.help) throw new UsageError("");
|
|
39
|
+
if (positionals.length !== 1) throw new UsageError("give one record file");
|
|
40
|
+
if (values.out === undefined) throw new UsageError("give --out, the recording folder");
|
|
41
|
+
try {
|
|
42
|
+
resolveProjectPath(values.out);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
|
|
45
|
+
}
|
|
46
|
+
if (!values.play && (values.joystick !== undefined || values["http-port"] !== undefined)) throw new UsageError("--joystick and --http-port are for --play");
|
|
47
|
+
const joystick = values.joystick === undefined ? 2 : Number(values.joystick);
|
|
48
|
+
if (joystick !== 1 && joystick !== 2) throw new UsageError("--joystick must be 1 or 2");
|
|
49
|
+
const httpPort = values["http-port"] === undefined ? DEFAULT_HTTP_PORT : Number(values["http-port"]);
|
|
50
|
+
if (!Number.isInteger(httpPort) || httpPort < 0 || httpPort > 65535) throw new UsageError("--http-port must be a TCP port from 0 to 65535");
|
|
51
|
+
let file;
|
|
52
|
+
try {
|
|
53
|
+
file = readRecordFile(positionals[0]!);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof ScenarioError) throw new UsageError(error.message);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
const context = localToolContext();
|
|
59
|
+
const core = await C64RunSession.open(context);
|
|
60
|
+
const workspace = Workspace.create(context.supervisor, "c64-re-tools-record-");
|
|
61
|
+
try {
|
|
62
|
+
let outcome;
|
|
63
|
+
try {
|
|
64
|
+
const person = values.play
|
|
65
|
+
? (session: C64RunSession) =>
|
|
66
|
+
play(session, { httpPort, joystick, ready: (url) => process.stderr.write(`Play at ${url} and press Stop on the page to save the recording.\n`) })
|
|
67
|
+
: undefined;
|
|
68
|
+
outcome = await record(core, file, workspace, person);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof ScenarioError) throw new UsageError(error.message);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
const summary = {
|
|
74
|
+
name: file.name,
|
|
75
|
+
frames: Math.round(outcome.recording.ticks / PAL_FRAME_CYCLES),
|
|
76
|
+
inputSites: outcome.recording.sites.length,
|
|
77
|
+
events: outcome.recording.events.length,
|
|
78
|
+
// The instructions in the coverage map: 0 when the program never read its input from RAM.
|
|
79
|
+
executed: outcome.coverage.reduce((count, byte) => count + ((byte & COVERAGE.ramExecute) === 0 ? 0 : 1), 0),
|
|
80
|
+
};
|
|
81
|
+
if (outcome.reason !== undefined) {
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return { recorded: false, reason: outcome.reason, ...summary };
|
|
84
|
+
}
|
|
85
|
+
const write = (name: string, bytes: Uint8Array) => writeProjectFile(`${values.out}/${name}`, bytes, { what: "--out" });
|
|
86
|
+
write(RECORDING_FILES.recording, Buffer.from(`${JSON.stringify(outcome.recording, null, 2)}\n`));
|
|
87
|
+
write(RECORDING_FILES.reads, outcome.reads);
|
|
88
|
+
write(RECORDING_FILES.coverage, outcome.coverage);
|
|
89
|
+
return { recorded: true, out: values.out, ...summary, files: Object.values(RECORDING_FILES) };
|
|
90
|
+
} finally {
|
|
91
|
+
await core.close();
|
|
92
|
+
workspace.remove();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await runScript(run, { usage: USAGE });
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The c64-testing replay script: replays a recording in the replay core
|
|
2
|
+
// (c64run). First on the original program, which must end with the recorded
|
|
3
|
+
// state hash. Then, with --rebuild, on the rebuild: it compares the state at
|
|
4
|
+
// each checkpoint with the original's. --checkpoint and --state choose the
|
|
5
|
+
// comparison, else the record file gave it.
|
|
6
|
+
// Exit status: 0 PASS, 1 FAIL, 3 INCONCLUSIVE, 2 a bad call.
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
|
|
11
|
+
import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
|
|
12
|
+
import { C64RunSession } from "#src/native/c64run.ts";
|
|
13
|
+
import { localToolContext } from "#src/native/local.ts";
|
|
14
|
+
import { Workspace } from "#src/native/staging.ts";
|
|
15
|
+
import { resolveProjectPath } from "#src/project.ts";
|
|
16
|
+
import { runScript, UsageError } from "#src/script.ts";
|
|
17
|
+
import { codeLabels, firstDifference, parseStateItem, pinLines, readRecording, replaySide, siteMap, type Comparison, type ReplayResult, type ReplaySubject } from "./lib/recording.ts";
|
|
18
|
+
import { ScenarioError } from "./lib/scenario.ts";
|
|
19
|
+
|
|
20
|
+
const USAGE = `replay.ts <recording folder> [--checkpoint <routine> --state <item> ... [--pin <item> ...]] [--rebuild <prg> [--symbols <file>]] [--out <dir>]
|
|
21
|
+
|
|
22
|
+
<recording folder> is a folder that record.ts wrote, relative to the project
|
|
23
|
+
folder. --checkpoint is the name or $ address of a routine; each entry is a
|
|
24
|
+
checkpoint. --state is a name or $ address with :size, one for each item.
|
|
25
|
+
--pin is random state in RAM in the same form: at each checkpoint the rebuild
|
|
26
|
+
gets the original's values there.
|
|
27
|
+
Without them, the replay uses the comparison of the record file. --symbols
|
|
28
|
+
gives the names of the rebuild: the c64-assembler output (default: the
|
|
29
|
+
symbols of the recording). A recording without names takes the names of the
|
|
30
|
+
knowledge for the original. A $ address is an address of the original: on the
|
|
31
|
+
rebuild it moves with the name before it. With --out, a FAIL writes the RAM
|
|
32
|
+
of both sides at the first different checkpoint into <dir>. The result is one
|
|
33
|
+
JSON object.`;
|
|
34
|
+
|
|
35
|
+
const EXIT = { PASS: 0, FAIL: 1, INCONCLUSIVE: 3 } as const;
|
|
36
|
+
const MAX_STATE_ITEMS = 16;
|
|
37
|
+
|
|
38
|
+
async function run(argv: string[]): Promise<ReplayResult> {
|
|
39
|
+
const { values, positionals } = parseArgs({
|
|
40
|
+
args: argv,
|
|
41
|
+
strict: true,
|
|
42
|
+
allowPositionals: true,
|
|
43
|
+
options: {
|
|
44
|
+
checkpoint: { type: "string" },
|
|
45
|
+
state: { type: "string", multiple: true },
|
|
46
|
+
pin: { type: "string", multiple: true },
|
|
47
|
+
rebuild: { type: "string" },
|
|
48
|
+
symbols: { type: "string" },
|
|
49
|
+
out: { type: "string" },
|
|
50
|
+
help: { type: "boolean" },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
if (values.help) throw new UsageError("");
|
|
54
|
+
if (positionals.length !== 1) throw new UsageError("give one recording folder");
|
|
55
|
+
if (values.symbols !== undefined && values.rebuild === undefined) throw new UsageError("--symbols is for the rebuild: give --rebuild too");
|
|
56
|
+
if ((values.checkpoint === undefined) !== (values.state === undefined)) throw new UsageError("give --checkpoint and --state together");
|
|
57
|
+
if ((values.state?.length ?? 0) + (values.pin?.length ?? 0) > MAX_STATE_ITEMS) throw new UsageError(`give at most ${MAX_STATE_ITEMS} --state and --pin items`);
|
|
58
|
+
if (values.pin !== undefined && values.checkpoint === undefined) throw new UsageError("--pin needs --checkpoint and --state");
|
|
59
|
+
if (values.out !== undefined) {
|
|
60
|
+
try {
|
|
61
|
+
resolveProjectPath(values.out);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new UsageError(`--out must be a path relative to the project folder that stays inside it, not ${values.out}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const folder = positionals[0]!.replace(/\/+$/, "").replace(/\/recording\.json$/, "");
|
|
67
|
+
let recording;
|
|
68
|
+
let given: Comparison | undefined;
|
|
69
|
+
try {
|
|
70
|
+
recording = readRecording(folder);
|
|
71
|
+
given =
|
|
72
|
+
values.checkpoint === undefined
|
|
73
|
+
? undefined
|
|
74
|
+
: { checkpoint: values.checkpoint, state: values.state!.map(parseStateItem), ...(values.pin === undefined ? {} : { pin: values.pin.map(parseStateItem) }) };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof ScenarioError) throw new UsageError(error.message);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const compare = given ?? recording.compare;
|
|
80
|
+
if (values.rebuild !== undefined && compare === undefined) throw new UsageError("the recording has no checkpoint and state: give --checkpoint and --state");
|
|
81
|
+
const result = (outcome: ReplayResult["result"], side: ReplayResult["side"], fields: Partial<ReplayResult> = {}): ReplayResult => ({
|
|
82
|
+
result: outcome,
|
|
83
|
+
recording: folder,
|
|
84
|
+
side,
|
|
85
|
+
...(compare === undefined ? {} : { checkpoint: compare.checkpoint }),
|
|
86
|
+
checkpoints: 0,
|
|
87
|
+
compared: 0,
|
|
88
|
+
...fields,
|
|
89
|
+
});
|
|
90
|
+
const sha256 = createHash("sha256").update(readProjectFile(recording.program).bytes).digest("hex");
|
|
91
|
+
if (sha256 !== recording.sha256) return result("INCONCLUSIVE", "original", { reason: `${recording.program} is not the program of the recording (its SHA-256 changed); record the session again` });
|
|
92
|
+
|
|
93
|
+
const context = localToolContext();
|
|
94
|
+
const core = await C64RunSession.open(context);
|
|
95
|
+
const workspace = Workspace.create(context.supervisor, "c64-re-tools-replay-");
|
|
96
|
+
try {
|
|
97
|
+
// A recording made without names takes the names that the knowledge has now.
|
|
98
|
+
const originalSymbols = recording.symbols === "none" ? "knowledge" : recording.symbols;
|
|
99
|
+
const original: ReplaySubject = { side: "original", program: recording.program, symbols: originalSymbols };
|
|
100
|
+
const side = async (subject: ReplaySubject, options: Parameters<typeof replaySide>[5]) => {
|
|
101
|
+
try {
|
|
102
|
+
return await replaySide(core, recording, folder, subject, workspace, options);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof ScenarioError) throw new UsageError(error.message);
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
// The self-check: the original ends the session with the recorded state, to the cycle.
|
|
109
|
+
const self = await side(original, { ...(compare === undefined ? {} : { compare }) });
|
|
110
|
+
if (self.problem !== undefined) return result("INCONCLUSIVE", "original", { reason: `the recording does not reproduce on the original: ${self.problem}` });
|
|
111
|
+
if (self.unknown > 0 || self.misses > 0) {
|
|
112
|
+
return result("INCONCLUSIVE", "original", { reason: `the recording does not reproduce on the original: ${self.unknown + self.misses} input reads had no recorded value; record the session again with this c64run` });
|
|
113
|
+
}
|
|
114
|
+
if (self.end.tick !== recording.ticks || self.end.hash !== recording.hash) {
|
|
115
|
+
return result("INCONCLUSIVE", "original", { reason: "the recording does not reproduce on the original: the state at its end is different; record the session again with this c64run" });
|
|
116
|
+
}
|
|
117
|
+
const expected = self.samples;
|
|
118
|
+
if (compare !== undefined && expected.length === 0) return result("INCONCLUSIVE", "original", { reason: `the original never came to the checkpoint ${compare.checkpoint} in the session` });
|
|
119
|
+
if (values.rebuild === undefined) return result("PASS", "original", { checkpoints: expected.length });
|
|
120
|
+
|
|
121
|
+
const rebuild: ReplaySubject = { side: "rebuild", program: values.rebuild, symbols: values.symbols ?? originalSymbols };
|
|
122
|
+
let movedSites: number;
|
|
123
|
+
try {
|
|
124
|
+
movedSites = siteMap(recording.sites, codeLabels(originalSymbols), codeLabels(rebuild.symbols)).length;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new UsageError(`--symbols ${rebuild.symbols}: ${(error as Error).message}`);
|
|
127
|
+
}
|
|
128
|
+
const pins = pinLines(expected, compare!.state);
|
|
129
|
+
const replayed = await side(rebuild, { compare: compare!, stop: expected.length, pins });
|
|
130
|
+
const difference = firstDifference(expected, replayed.samples, compare!.state);
|
|
131
|
+
// A read that the recording has no value for gets the idle value: it is in the result, never silent.
|
|
132
|
+
const base = { checkpoints: expected.length, movedSites, ...(replayed.unknown > 0 ? { idleReads: replayed.unknown } : {}) };
|
|
133
|
+
if (difference === undefined && replayed.samples.length >= expected.length) return result("PASS", "rebuild", { ...base, compared: expected.length });
|
|
134
|
+
const reason =
|
|
135
|
+
difference !== undefined
|
|
136
|
+
? `the state differs at checkpoint ${difference.checkpoint} of ${expected.length}${replayed.unknown > 0 ? `; the rebuild read input ${replayed.unknown} times at sites that the recording does not have` : ""}`
|
|
137
|
+
: `the rebuild came to ${replayed.samples.length} of ${expected.length} checkpoints${replayed.problem === undefined ? "" : `: ${replayed.problem}`}`;
|
|
138
|
+
const failed = result("FAIL", "rebuild", { ...base, compared: (difference?.checkpoint ?? replayed.samples.length + 1) - 1, reason, ...(difference === undefined ? {} : { firstDifference: difference }) });
|
|
139
|
+
if (values.out !== undefined && difference !== undefined) {
|
|
140
|
+
const files: string[] = [];
|
|
141
|
+
for (const subject of [original, rebuild]) {
|
|
142
|
+
const dumped = await side(subject, { compare: compare!, dump: difference.checkpoint, ...(subject.side === "rebuild" ? { pins } : {}) });
|
|
143
|
+
const name = `${subject.side}-${difference.checkpoint}.bin`;
|
|
144
|
+
writeProjectFile(`${values.out}/${name}`, dumped.ram!, { what: "--out" });
|
|
145
|
+
files.push(name);
|
|
146
|
+
}
|
|
147
|
+
failed.files = files;
|
|
148
|
+
}
|
|
149
|
+
return failed;
|
|
150
|
+
} finally {
|
|
151
|
+
await core.close();
|
|
152
|
+
workspace.remove();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await runScript(
|
|
157
|
+
async (argv) => {
|
|
158
|
+
const result = await run(argv);
|
|
159
|
+
process.exitCode = EXIT[result.result];
|
|
160
|
+
return result;
|
|
161
|
+
},
|
|
162
|
+
{ usage: USAGE },
|
|
163
|
+
);
|
package/src/cli/main.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { dirname, join } from "node:path";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
|
|
10
|
-
import { PACKAGE } from "../package.ts";
|
|
10
|
+
import { oldNodeRefusal, PACKAGE } from "../package.ts";
|
|
11
11
|
import { localToolContext } from "../native/local.ts";
|
|
12
12
|
import { localToolStatus } from "../native/status.ts";
|
|
13
13
|
import { WireFailure } from "../protocol/messages.ts";
|
|
@@ -112,6 +112,12 @@ async function main(argv: string[]): Promise<number> {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
const oldNode = oldNodeRefusal();
|
|
116
|
+
if (oldNode !== undefined) {
|
|
117
|
+
console.error(oldNode);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
try {
|
|
116
122
|
process.exitCode = await main(process.argv.slice(2));
|
|
117
123
|
} catch (error) {
|
package/src/host/main.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { inspect } from "./tools/c1541.ts";
|
|
|
11
11
|
import { createToolDispatcher } from "./tools/index.ts";
|
|
12
12
|
import { checkViceStarts, findVice } from "./vice/process.ts";
|
|
13
13
|
import { viceSessionFactory } from "./vice/session.ts";
|
|
14
|
-
import { PACKAGE } from "../package.ts";
|
|
14
|
+
import { oldNodeRefusal, PACKAGE } from "../package.ts";
|
|
15
15
|
|
|
16
16
|
const HELP = `c64-re-tools-host
|
|
17
17
|
|
|
@@ -144,4 +144,10 @@ async function main(): Promise<number> {
|
|
|
144
144
|
return 0;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
const oldNode = oldNodeRefusal();
|
|
148
|
+
if (oldNode !== undefined) {
|
|
149
|
+
log(oldNode);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
147
153
|
process.exitCode = await main();
|
package/src/mcp/main.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { machineTools } from "./tools/machine.ts";
|
|
|
13
13
|
import { mediaTools } from "./tools/media.ts";
|
|
14
14
|
import { memoryTools } from "./tools/memory.ts";
|
|
15
15
|
import { videoTools } from "./tools/video.ts";
|
|
16
|
-
import { PACKAGE } from "../package.ts";
|
|
16
|
+
import { oldNodeRefusal, PACKAGE } from "../package.ts";
|
|
17
17
|
|
|
18
18
|
const HELP = `c64-re-tools-mcp
|
|
19
19
|
|
|
@@ -131,4 +131,10 @@ async function main(): Promise<void> {
|
|
|
131
131
|
await server.connect(new StdioServerTransport());
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
const oldNode = oldNodeRefusal();
|
|
135
|
+
if (oldNode !== undefined) {
|
|
136
|
+
log(oldNode);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
134
140
|
await main();
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// The c64run session: one replay core (a C64 emulator, see
|
|
2
|
+
// devcontainer/src/c64-re-tools/c64run.c) for the life of a skill script,
|
|
3
|
+
// driven by one command line and one answer line at a time over its stdin and
|
|
4
|
+
// stdout. It is started through the supervisor like every native tool, so it
|
|
5
|
+
// stops with the script.
|
|
6
|
+
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
|
|
9
|
+
import { WireFailure } from "../protocol/messages.ts";
|
|
10
|
+
import { C64RUN, findTool, INSTALL_STEPS } from "./discover.ts";
|
|
11
|
+
import type { SupervisedProcess } from "./processes.ts";
|
|
12
|
+
import type { ToolContext } from "./run.ts";
|
|
13
|
+
|
|
14
|
+
/** The command protocol that this module speaks; c64run --version and version name it. */
|
|
15
|
+
export const C64RUN_PROTOCOL = 1;
|
|
16
|
+
|
|
17
|
+
/** CPU cycles in one PAL frame: 312 lines of 63 cycles. */
|
|
18
|
+
export const PAL_FRAME_CYCLES = 19_656;
|
|
19
|
+
|
|
20
|
+
const STOP_MS = 5_000;
|
|
21
|
+
|
|
22
|
+
/** The machine state that most commands answer with. */
|
|
23
|
+
export interface CoreStatus {
|
|
24
|
+
/** CPU cycles since the opcode fetch at the reset vector. */
|
|
25
|
+
tick: number;
|
|
26
|
+
/** At an opcode fetch: pc is the address of the next instruction. */
|
|
27
|
+
sync: boolean;
|
|
28
|
+
pc: number;
|
|
29
|
+
a: number;
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
s: number;
|
|
33
|
+
p: number;
|
|
34
|
+
line: number;
|
|
35
|
+
cycle: number;
|
|
36
|
+
/** FNV-1a of RAM from $0002 and of colour RAM. */
|
|
37
|
+
hash: string;
|
|
38
|
+
/** The word after "ok", if any: "reached" or "not-reached" for run-pc and run-reads. */
|
|
39
|
+
word?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseStatus(answer: string): CoreStatus {
|
|
43
|
+
const parts = answer.split(" ").slice(1);
|
|
44
|
+
const fields = new Map<string, string>();
|
|
45
|
+
let word: string | undefined;
|
|
46
|
+
for (const part of parts) {
|
|
47
|
+
const at = part.indexOf("=");
|
|
48
|
+
if (at < 0) word ??= part;
|
|
49
|
+
else fields.set(part.slice(0, at), part.slice(at + 1));
|
|
50
|
+
}
|
|
51
|
+
const hex = (key: string) => Number.parseInt(fields.get(key) ?? "", 16);
|
|
52
|
+
if (!fields.has("tick")) throw new WireFailure("operation-failed", `c64run gave no machine state: ${answer}`);
|
|
53
|
+
return {
|
|
54
|
+
tick: Number(fields.get("tick")),
|
|
55
|
+
sync: fields.get("sync") === "1",
|
|
56
|
+
pc: hex("pc"),
|
|
57
|
+
a: hex("a"),
|
|
58
|
+
x: hex("x"),
|
|
59
|
+
y: hex("y"),
|
|
60
|
+
s: hex("s"),
|
|
61
|
+
p: hex("p"),
|
|
62
|
+
line: Number(fields.get("line")),
|
|
63
|
+
cycle: Number(fields.get("cycle")),
|
|
64
|
+
hash: fields.get("hash") ?? "",
|
|
65
|
+
...(word === undefined ? {} : { word }),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class C64RunSession {
|
|
70
|
+
readonly #process: SupervisedProcess;
|
|
71
|
+
readonly #waiting: Array<{ resolve: (line: string) => void; reject: (error: Error) => void }> = [];
|
|
72
|
+
#stderr = "";
|
|
73
|
+
#ended: WireFailure | undefined;
|
|
74
|
+
|
|
75
|
+
private constructor(process: SupervisedProcess) {
|
|
76
|
+
this.#process = process;
|
|
77
|
+
const child = process.child;
|
|
78
|
+
// A core that ended (or never started) closes its stdin: the exit handler reports it.
|
|
79
|
+
child.stdin!.on("error", () => {});
|
|
80
|
+
child.stderr!.setEncoding("utf8");
|
|
81
|
+
child.stderr!.on("data", (chunk: string) => (this.#stderr = (this.#stderr + chunk).slice(-4000)));
|
|
82
|
+
createInterface({ input: child.stdout! }).on("line", (line) => this.#waiting.shift()?.resolve(line));
|
|
83
|
+
void process.exited.then((status) => {
|
|
84
|
+
const how = status.spawnError !== undefined ? `did not start (${status.spawnError})` : `ended (exit ${status.code ?? status.signal})`;
|
|
85
|
+
this.#ended = new WireFailure(status.spawnError !== undefined ? "installation-incomplete" : "operation-failed", `c64run ${how}.${this.#stderr === "" ? "" : ` ${this.#stderr.trim()}`}`);
|
|
86
|
+
for (const waiting of this.#waiting.splice(0)) waiting.reject(this.#ended);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Starts c64run (C64RT_C64RUN, else c64run on PATH) and checks its protocol. Refuses by name when it is missing. */
|
|
91
|
+
static async open(context: ToolContext): Promise<C64RunSession> {
|
|
92
|
+
const env = context.env ?? process.env;
|
|
93
|
+
const path = findTool(C64RUN, env);
|
|
94
|
+
const session = new C64RunSession(context.supervisor.spawn([path], { stdio: ["pipe", "pipe", "pipe"], env }));
|
|
95
|
+
const onAbort = () => void session.close();
|
|
96
|
+
context.signal.addEventListener("abort", onAbort, { once: true });
|
|
97
|
+
const version = await session.command("version");
|
|
98
|
+
const protocol = Number(/protocol=(\d+)/.exec(version)?.[1]);
|
|
99
|
+
if (protocol !== C64RUN_PROTOCOL) {
|
|
100
|
+
await session.close();
|
|
101
|
+
throw new WireFailure("installation-incomplete", `${path} speaks c64run protocol ${Number.isNaN(protocol) ? "(none)" : protocol}; c64-re-tools needs protocol ${C64RUN_PROTOCOL}. ${C64RUN.remedy}`);
|
|
102
|
+
}
|
|
103
|
+
return session;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Sends one command and gives its "ok ..." answer. An "err ..." answer throws operation-failed. */
|
|
107
|
+
command(text: string): Promise<string> {
|
|
108
|
+
if (this.#ended !== undefined) return Promise.reject(this.#ended);
|
|
109
|
+
const answer = new Promise<string>((resolve, reject) => this.#waiting.push({ resolve, reject }));
|
|
110
|
+
this.#process.child.stdin!.write(`${text}\n`);
|
|
111
|
+
return answer.then((line) => {
|
|
112
|
+
if (line.startsWith("ok")) return line;
|
|
113
|
+
throw new WireFailure("operation-failed", `c64run ${text.split(" ")[0]}: ${line.replace(/^err /, "")}`);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A command that answers with the machine state. */
|
|
118
|
+
async status(text: string): Promise<CoreStatus> {
|
|
119
|
+
return parseStatus(await this.command(text));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Power-on: the CPU stops at the reset vector, tick 0. Missing ROMs refuse with the remedy. */
|
|
123
|
+
async init(romFolder?: string): Promise<CoreStatus> {
|
|
124
|
+
try {
|
|
125
|
+
return await this.status(romFolder === undefined ? "init" : `init ${romFolder}`);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof WireFailure && /no ROM/.test(error.message)) {
|
|
128
|
+
throw new WireFailure(
|
|
129
|
+
"installation-incomplete",
|
|
130
|
+
`${error.message}. c64run needs the C64 ROMs kernal-901227-03.bin, basic-901226-01.bin and chargen-901225-01.bin: set C64RT_C64_ROMS to a folder with them (the C64 folder of VICE has them). ${INSTALL_STEPS}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Writes bytes into RAM (the RAM view), in parts that fit one command line. */
|
|
138
|
+
async write(address: number, bytes: Uint8Array): Promise<void> {
|
|
139
|
+
for (let at = 0; at < bytes.length; at += 4096) {
|
|
140
|
+
await this.command(`write ${((address + at) & 0xffff).toString(16)} ${Buffer.from(bytes.subarray(at, at + 4096)).toString("hex")}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** RAM bytes (the RAM view). */
|
|
145
|
+
async peek(address: number, size: number): Promise<Uint8Array> {
|
|
146
|
+
const parts: Buffer[] = [];
|
|
147
|
+
for (let at = 0; at < size; at += 4096) {
|
|
148
|
+
const answer = await this.command(`peek ${((address + at) & 0xffff).toString(16)} ${Math.min(4096, size - at)}`);
|
|
149
|
+
parts.push(Buffer.from(answer.slice(3), "hex"));
|
|
150
|
+
}
|
|
151
|
+
return Buffer.concat(parts);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Ends c64run: quit, then the stop of its process group (at once when it ended, else after STOP_MS). */
|
|
155
|
+
async close(): Promise<void> {
|
|
156
|
+
if (this.#ended === undefined) {
|
|
157
|
+
this.#process.child.stdin!.end("quit\n");
|
|
158
|
+
await Promise.race([this.#process.exited, new Promise((resolve) => setTimeout(resolve, STOP_MS).unref())]);
|
|
159
|
+
}
|
|
160
|
+
await this.#process.stop();
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/native/discover.ts
CHANGED
|
@@ -55,6 +55,14 @@ export const DXA: ToolSpec = {
|
|
|
55
55
|
remedy: `Install dxa 0.1.5 on the machine that runs the skill scripts so that dxa is on PATH, or set C64RT_DXA to its full path. ${INSTALL_STEPS}`,
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
export const C64RUN: ToolSpec = {
|
|
59
|
+
name: "c64run",
|
|
60
|
+
envVar: "C64RT_C64RUN",
|
|
61
|
+
binaries: ["c64run"],
|
|
62
|
+
where: "on this machine",
|
|
63
|
+
remedy: `Install c64run (the replay core of c64-testing, with its C64 ROMs) on the machine that runs the skill scripts so that c64run is on PATH, or set C64RT_C64RUN to its full path. The dev container has it. ${INSTALL_STEPS}`,
|
|
64
|
+
};
|
|
65
|
+
|
|
58
66
|
export const VICE: ToolSpec = {
|
|
59
67
|
name: "VICE (x64sc)",
|
|
60
68
|
envVar: "C64RT_VICE",
|
package/src/native/status.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { stripVTControlCharacters } from "node:util";
|
|
|
8
8
|
|
|
9
9
|
import { WireFailure } from "../protocol/messages.ts";
|
|
10
10
|
import { type ToolStatus } from "../protocol/tools.ts";
|
|
11
|
-
import { ACME, DXA, findTool } from "./discover.ts";
|
|
11
|
+
import { ACME, C64RUN, DXA, findTool, INSTALL_STEPS } from "./discover.ts";
|
|
12
12
|
import { findGhidra, ghidraVersion, NO_USABLE_JDK, noJdk } from "./ghidra/index.ts";
|
|
13
13
|
import { runTool, type ToolContext } from "./run.ts";
|
|
14
14
|
import { Workspace } from "./staging.ts";
|
|
@@ -101,6 +101,27 @@ export async function probeGhidra(context: ToolContext): Promise<ToolStatus> {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** c64run: --version prints its version and whether its ROM folder has the three C64 ROMs. */
|
|
105
|
+
export async function probeC64run(context: ToolContext): Promise<ToolStatus> {
|
|
106
|
+
const env = context.env ?? process.env;
|
|
107
|
+
try {
|
|
108
|
+
const path = findTool(C64RUN, env);
|
|
109
|
+
return await run(
|
|
110
|
+
"c64run",
|
|
111
|
+
path,
|
|
112
|
+
["--version"],
|
|
113
|
+
(output) => (/^roms=ok /m.test(output) ? versionLine(output, /^c64run protocol=/) : undefined),
|
|
114
|
+
context,
|
|
115
|
+
(output) => {
|
|
116
|
+
const missing = /^roms=missing (.*)$/m.exec(output)?.[1];
|
|
117
|
+
return missing === undefined ? undefined : `It has no C64 ROMs: ${missing}. Set C64RT_C64_ROMS to a folder with kernal-901227-03.bin, basic-901226-01.bin and chargen-901225-01.bin (the C64 folder of VICE has them). ${INSTALL_STEPS}`;
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return missing("c64run", error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
104
125
|
/** The tools that skill scripts run themselves, on this machine. */
|
|
105
126
|
export async function localToolStatus(context: ToolContext): Promise<ToolStatus[]> {
|
|
106
127
|
const probes: Probe[] = [
|
|
@@ -109,5 +130,5 @@ export async function localToolStatus(context: ToolContext): Promise<ToolStatus[
|
|
|
109
130
|
// directory only where that uses "/": on Windows the line starts with the full path.
|
|
110
131
|
{ name: "dxa", find: (env) => findTool(DXA, env), args: ["-V"], pattern: DXA_VERSION },
|
|
111
132
|
];
|
|
112
|
-
return [...(await probeTools(probes, context)), await probeGhidra(context)];
|
|
133
|
+
return [...(await probeTools(probes, context)), await probeGhidra(context), await probeC64run(context)];
|
|
113
134
|
}
|
package/src/package.ts
CHANGED
|
@@ -14,8 +14,18 @@ export function channelOf(version: string): "latest" | "next" {
|
|
|
14
14
|
return version.includes("-") ? "next" : "latest";
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** The name, version and dist-tag of this npm package,
|
|
17
|
+
/** The name, version and dist-tag of this npm package, and the Node.js major version that its engines field requires. */
|
|
18
18
|
export const PACKAGE = (() => {
|
|
19
|
-
const metadata = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8")) as { name: string; version: string };
|
|
20
|
-
|
|
19
|
+
const metadata = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8")) as { name: string; version: string; engines?: { node?: string } };
|
|
20
|
+
const nodeMajor = Number(/(\d+)/.exec(metadata.engines?.node ?? "")?.[1]);
|
|
21
|
+
return { name: metadata.name, version: metadata.version, tag: channelOf(metadata.version), nodeMajor: Number.isInteger(nodeMajor) ? nodeMajor : undefined };
|
|
21
22
|
})();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The refusal for a Node.js older than `required`, or undefined. An older Node.js
|
|
26
|
+
* cannot run the skill scripts: they are TypeScript and use node:sqlite.
|
|
27
|
+
*/
|
|
28
|
+
export function oldNodeRefusal(version: string = process.versions.node, required: number | undefined = PACKAGE.nodeMajor): string | undefined {
|
|
29
|
+
if (required === undefined || Number(version.split(".")[0]) >= required) return undefined;
|
|
30
|
+
return `c64-re-tools needs Node.js ${required} or newer, and this is Node.js ${version}. Install Node.js ${required} from https://nodejs.org/ and run the command again.`;
|
|
31
|
+
}
|