@henols/c64-re-tools 2.0.0-rc.6 → 2.0.0-rc.7
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/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +1 -1
- package/skills/c64-basic/SKILL.md +1 -1
- package/skills/c64-disk/SKILL.md +3 -3
- package/skills/c64-disk/scripts/disk.ts +2 -1
- package/skills/c64-emulator/SKILL.md +3 -1
- package/skills/c64-knowledge/SKILL.md +1 -1
- package/skills/c64-provenance/SKILL.md +7 -5
- package/skills/c64-provenance/scripts/compare.ts +26 -0
- package/skills/c64-provenance/scripts/provenance.ts +24 -10
- package/skills/c64-provenance/scripts/separate.ts +12 -4
- package/skills/c64-reverse-engineering/SKILL.md +2 -2
- package/skills/c64-source/SKILL.md +15 -9
- package/skills/c64-source/scripts/generate.ts +36 -10
- package/skills/c64-source/scripts/loader.ts +88 -6
- package/skills/c64-source/scripts/source.ts +40 -9
- package/skills/c64-static-analysis/SKILL.md +4 -4
- package/skills/c64-static-analysis/scripts/analyze.ts +13 -6
- package/skills/c64-static-analysis/scripts/coverage.ts +4 -4
- package/skills/c64-static-analysis/scripts/idioms.ts +14 -9
- package/skills/c64-testing/SKILL.md +1 -1
- package/skills/c64-testing/scripts/scenario.ts +2 -2
- package/skills/c64-unpacker/SKILL.md +9 -7
- package/skills/c64-unpacker/scripts/loads.ts +12 -1
- package/skills/c64-unpacker/scripts/stages.ts +66 -3
- package/skills/c64-unpacker/scripts/unpack.ts +96 -30
- package/src/c64.ts +14 -5
- package/src/cli/status.ts +26 -3
- package/src/host/tools/c1541.ts +34 -5
- package/src/host/tools/petcat.ts +5 -2
- package/src/host/tools/status.ts +6 -3
- package/src/host/vice/adapter.ts +23 -9
- package/src/host/vice/process.ts +7 -2
- package/src/host/vice/session.ts +47 -10
- package/src/host-client/connect.ts +10 -10
- package/src/host-client/coverage.ts +19 -10
- package/src/host-client/tools.ts +5 -5
- package/src/host-client/vice-session.ts +1 -1
- package/src/knowledge/schema.ts +7 -0
- package/src/knowledge/write.ts +5 -0
- package/src/mcp/tools/debug.ts +3 -2
- package/src/mcp/tools/machine.ts +4 -1
- package/src/mcp/tools/media.ts +5 -1
- package/src/mcp/tools/video.ts +2 -0
- package/src/native/discover.ts +2 -1
- package/src/platform.ts +3 -2
- package/src/protocol/tools.ts +19 -4
- package/src/protocol/vice.ts +11 -2
package/src/host/vice/adapter.ts
CHANGED
|
@@ -135,7 +135,7 @@ export function parseDisassembly(answer: string, start: number): Instruction[] {
|
|
|
135
135
|
for (const line of answer.split("\n")) {
|
|
136
136
|
const match = /^\.(?:C|\d+):([0-9a-f]{4})\s+((?:[0-9a-f]{2} )*[0-9a-f]{2})\s+(.*?)\s*$/i.exec(line);
|
|
137
137
|
if (match === null) {
|
|
138
|
-
if (line.trim() !== "") unknown.push(line.trim());
|
|
138
|
+
if (line.trim() !== "" && !CONDITION_LINE.test(line.trim())) unknown.push(line.trim());
|
|
139
139
|
continue;
|
|
140
140
|
}
|
|
141
141
|
const address = Number.parseInt(match[1]!, 16);
|
|
@@ -164,7 +164,7 @@ export function parseHistory(answer: string): Array<Omit<HistoryEntry, "rasterLi
|
|
|
164
164
|
line,
|
|
165
165
|
);
|
|
166
166
|
if (match === null) {
|
|
167
|
-
if (line.trim() !== "") unknown.push(line.trim());
|
|
167
|
+
if (line.trim() !== "" && !CONDITION_LINE.test(line.trim())) unknown.push(line.trim());
|
|
168
168
|
continue;
|
|
169
169
|
}
|
|
170
170
|
entries.push({
|
|
@@ -193,7 +193,7 @@ export function parseHunt(answer: string, limit: number): number[] {
|
|
|
193
193
|
for (const line of answer.split("\n")) {
|
|
194
194
|
const match = /^([0-9a-f]{4})$/i.exec(line.trim());
|
|
195
195
|
if (match === null) {
|
|
196
|
-
if (line.trim() !== "") unknown.push(line.trim());
|
|
196
|
+
if (line.trim() !== "" && !CONDITION_LINE.test(line.trim())) unknown.push(line.trim());
|
|
197
197
|
continue;
|
|
198
198
|
}
|
|
199
199
|
if (matches.length < limit) matches.push(Number.parseInt(match[1]!, 16));
|
|
@@ -236,14 +236,15 @@ export function parseProfile(answer: string): ProfileEntry[] {
|
|
|
236
236
|
const unknown: string[] = [];
|
|
237
237
|
for (const line of answer.split("\n")) {
|
|
238
238
|
// Counts can carry the host locale's digit grouping ("1,123,200") and the percent a decimal comma.
|
|
239
|
-
|
|
239
|
+
// A profile of zero cycles, as after a window move restarts it, prints its percents as "-nan".
|
|
240
|
+
const row = new RegExp(`^\\s*(${COUNT})\\s+(?:[\\d.,]+|-?nan)%\\s+(${COUNT})\\s+([\\d.,]+|-?nan)%\\s+(\\S+)\\s*$`, "i").exec(line);
|
|
240
241
|
if (row === null) {
|
|
241
|
-
if (line.trim() !== "" && !/^Total\s+%\s+Self\s+%$/.test(line.trim()) && !/^[-\s]+$/.test(line)) unknown.push(line.trim());
|
|
242
|
+
if (line.trim() !== "" && !CONDITION_LINE.test(line.trim()) && !/^Total\s+%\s+Self\s+%$/.test(line.trim()) && !/^[-\s]+$/.test(line)) unknown.push(line.trim());
|
|
242
243
|
continue;
|
|
243
244
|
}
|
|
244
245
|
// A row named ROOT or by a label has no routine address.
|
|
245
246
|
if (!/^[0-9a-f]{4}$/i.test(row[4]!)) continue;
|
|
246
|
-
entries.push({ address: Number.parseInt(row[4]!, 16), totalCycles: digits(row[1]!), selfCycles: digits(row[2]!), percent: Number(row[3]!.replace(",", ".")) });
|
|
247
|
+
entries.push({ address: Number.parseInt(row[4]!, 16), totalCycles: digits(row[1]!), selfCycles: digits(row[2]!), percent: /nan/i.test(row[3]!) ? 0 : Number(row[3]!.replace(",", ".")) });
|
|
247
248
|
}
|
|
248
249
|
// An answer in another form must not read as "nothing ran".
|
|
249
250
|
if (entries.length === 0 && unknown.length > 0) throw unknownForm("profile", unknown);
|
|
@@ -284,7 +285,7 @@ export function parseMemmap(answer: string, maxRanges: number): { ranges: Memmap
|
|
|
284
285
|
for (const line of answer.split("\n")) {
|
|
285
286
|
const match = /^([0-9a-f]{4}): (\S{3}) (\S{3}) (\S{3})/i.exec(line);
|
|
286
287
|
if (match === null) {
|
|
287
|
-
if (line.trim() !== "" && !/^addr:\s+IO\s+ROM\s+RAM$/.test(line.trim())) unknown.push(line.trim());
|
|
288
|
+
if (line.trim() !== "" && !CONDITION_LINE.test(line.trim()) && !/^addr:\s+IO\s+ROM\s+RAM$/.test(line.trim())) unknown.push(line.trim());
|
|
288
289
|
continue;
|
|
289
290
|
}
|
|
290
291
|
const address = Number.parseInt(match[1]!, 16);
|
|
@@ -308,12 +309,19 @@ export function parseMemmap(answer: string, maxRanges: number): { ranges: Memmap
|
|
|
308
309
|
*/
|
|
309
310
|
const STOP_LINE = /^(?:#\d+ \((?:Stop on|Trace)\s|\.(?:C|\d+):[0-9a-f]{4}\s)/i;
|
|
310
311
|
|
|
311
|
-
/**
|
|
312
|
+
/**
|
|
313
|
+
* The line VICE prints on the text monitor when the binary monitor sets a
|
|
314
|
+
* checkpoint condition. It can come late, in the answer of the next text
|
|
315
|
+
* command (seen on Windows).
|
|
316
|
+
*/
|
|
317
|
+
const CONDITION_LINE = /^Setting checkpoint \d+ condition to: /;
|
|
318
|
+
|
|
319
|
+
/** The lines of a text command's answer that are not blank, not a stop line, not a condition line and not `known`. */
|
|
312
320
|
function otherLines(answer: string, known?: RegExp): string[] {
|
|
313
321
|
return answer
|
|
314
322
|
.split("\n")
|
|
315
323
|
.map((line) => line.trim())
|
|
316
|
-
.filter((line) => line !== "" && !STOP_LINE.test(line) && known?.test(line) !== true);
|
|
324
|
+
.filter((line) => line !== "" && !STOP_LINE.test(line) && !CONDITION_LINE.test(line) && known?.test(line) !== true);
|
|
317
325
|
}
|
|
318
326
|
|
|
319
327
|
/** Refuses the answer of a text command that prints nothing, or only `known` lines, when it works. */
|
|
@@ -631,6 +639,12 @@ export class ViceAdapter {
|
|
|
631
639
|
await this.#monitor.request(Command.autostart, Buffer.concat([head, name]));
|
|
632
640
|
}
|
|
633
641
|
|
|
642
|
+
/** Detaches every cartridge. VICE powers the cartridge off; the C64 keeps running. */
|
|
643
|
+
async detachCartridge(): Promise<void> {
|
|
644
|
+
// Device 32 is the cartridge port; the monitor reads numbers as hex. VICE prints nothing when it detaches.
|
|
645
|
+
expectQuiet("cartridge detach answer", await this.#text.command("detach 20"));
|
|
646
|
+
}
|
|
647
|
+
|
|
634
648
|
/** Attaches a disk image file from the host file system to drive 8. */
|
|
635
649
|
async attachDisk(file: string): Promise<void> {
|
|
636
650
|
const answer = await this.#text.command(`attach ${quoted(file)} 8`);
|
package/src/host/vice/process.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// VICE launch, readiness and termination. The launch contract of x64sc:
|
|
2
2
|
// - `-default` must come before `-binarymonitor`, or the monitor never binds;
|
|
3
|
+
// - `-config` must come first: VICE's trunk (after 3.10) drops every argument
|
|
4
|
+
// before it, so the monitors would never start;
|
|
3
5
|
// - the monitor may accept a connection during startup and then drop it, so
|
|
4
6
|
// readiness is a connect plus a successful ping, retried until a deadline;
|
|
5
7
|
// - any monitor command stops the machine, so readiness ends with `exit`;
|
|
@@ -76,6 +78,9 @@ export function viceArguments(options: {
|
|
|
76
78
|
const headless = options.mode === "headless" ? ["-console", "-sounddev", "dummy"] : [];
|
|
77
79
|
return [
|
|
78
80
|
options.binary,
|
|
81
|
+
// VICE's trunk (after 3.10) drops every argument before -config, so it comes first.
|
|
82
|
+
"-config",
|
|
83
|
+
options.configFile,
|
|
79
84
|
"-default",
|
|
80
85
|
"-logfile",
|
|
81
86
|
options.logFile,
|
|
@@ -85,8 +90,6 @@ export function viceArguments(options: {
|
|
|
85
90
|
"-remotemonitor",
|
|
86
91
|
"-remotemonitoraddress",
|
|
87
92
|
`ip4://127.0.0.1:${options.textPort}`,
|
|
88
|
-
"-config",
|
|
89
|
-
options.configFile,
|
|
90
93
|
"-model",
|
|
91
94
|
options.videoStandard === "ntsc" ? "ntsc" : "c64",
|
|
92
95
|
"-drive8type",
|
|
@@ -94,6 +97,8 @@ export function viceArguments(options: {
|
|
|
94
97
|
// Autostart injects a PRG into RAM; the default mode copies it to a disk image first.
|
|
95
98
|
"-autostartprgmode",
|
|
96
99
|
"1",
|
|
100
|
+
// VICE's default waits a random time before an autostart types its LOAD, so two runs of one file differ (R2-12).
|
|
101
|
+
"+autostart-delay-random",
|
|
97
102
|
// The same RAM after every power-up: a hard reset is a power cycle, and VICE's default can flip
|
|
98
103
|
// random bits, so two runs from one start state could differ.
|
|
99
104
|
"-raminitstartvalue",
|
package/src/host/vice/session.ts
CHANGED
|
@@ -125,6 +125,20 @@ export interface SessionOptions {
|
|
|
125
125
|
flaws?: readonly string[];
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Where VICE keeps a staged file: an autostarted disk stays in drive 8, a tape
|
|
130
|
+
* in the datasette and a cartridge in the port, until the same place gets
|
|
131
|
+
* another file. Only a program is read once.
|
|
132
|
+
*/
|
|
133
|
+
type StagedKind = "program" | "disk" | "tape" | "cartridge";
|
|
134
|
+
|
|
135
|
+
function stagedKind(type: string): StagedKind {
|
|
136
|
+
if (type === "crt") return "cartridge";
|
|
137
|
+
if (type === "tap" || type === "t64") return "tape";
|
|
138
|
+
if (type === "prg" || type === "p00") return "program";
|
|
139
|
+
return "disk";
|
|
140
|
+
}
|
|
141
|
+
|
|
128
142
|
export class ViceSession implements ViceSessionHandle {
|
|
129
143
|
/** The current VICE. A window move replaces it. */
|
|
130
144
|
#vice: ViceProcess;
|
|
@@ -179,8 +193,10 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
179
193
|
#timingStart = 0n;
|
|
180
194
|
/** Counts staged media files, for unique names. */
|
|
181
195
|
#staged = 0;
|
|
196
|
+
/** True after an autostart of a cartridge, until an autostart of another file detaches it. */
|
|
197
|
+
#cartridge = false;
|
|
182
198
|
/** The last staged file of each kind that VICE took; VICE can still read it. */
|
|
183
|
-
readonly #stagedFiles = new Map<
|
|
199
|
+
readonly #stagedFiles = new Map<StagedKind, string>();
|
|
184
200
|
/** Session-local ids: one counter for breakpoints and watchpoints, from 1. */
|
|
185
201
|
#nextPointId = 1;
|
|
186
202
|
/**
|
|
@@ -359,9 +375,18 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
359
375
|
case "autostart": {
|
|
360
376
|
const { type, index, run } = params as ViceOperations["autostart"]["params"];
|
|
361
377
|
const file = this.#stage(onlyAttachment(attachments), type);
|
|
378
|
+
// VICE keeps a cartridge through every reset and autostart, as a real C64 does.
|
|
379
|
+
if (type !== "crt" && this.#cartridge) {
|
|
380
|
+
await this.#observe(() => this.#machine.detachCartridge());
|
|
381
|
+
this.#cartridge = false;
|
|
382
|
+
this.#replaceStaged("cartridge", undefined);
|
|
383
|
+
}
|
|
362
384
|
await this.#runningCommand(() => this.#machine.autostart(file, index, run));
|
|
363
|
-
|
|
364
|
-
|
|
385
|
+
if (type === "crt") this.#cartridge = true;
|
|
386
|
+
this.#replaceStaged(stagedKind(type), file);
|
|
387
|
+
// A reset sets the CPU clock back, so the stopwatch starts again.
|
|
388
|
+
this.#timingStart = 0n;
|
|
389
|
+
return { state: "running", ...(type === "tap" && this.#inputFlaw !== undefined ? { emulatorFlaw: this.#inputFlaw } : {}) };
|
|
365
390
|
}
|
|
366
391
|
case "diskAttach": {
|
|
367
392
|
const { type } = params as ViceOperations["diskAttach"]["params"];
|
|
@@ -474,6 +499,7 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
474
499
|
const warp = await this.#observe(() => this.#machine.warp());
|
|
475
500
|
// Read after #observe: it resumes a running machine, unless a breakpoint or a JAM stopped it meanwhile.
|
|
476
501
|
const status: MachineStatus = { state: this.#state, videoStandard: this.#videoStandard, warp, window: this.#mode === "window" };
|
|
502
|
+
if (this.#cartridge) status.cartridge = true;
|
|
477
503
|
if (status.state === "stopped") status.pc = await this.#pc();
|
|
478
504
|
return status;
|
|
479
505
|
}
|
|
@@ -531,6 +557,8 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
531
557
|
|
|
532
558
|
async #reset(params: { mode: "soft" | "hard"; run: boolean }): Promise<{ state: RunState; pc?: number }> {
|
|
533
559
|
await this.#stop();
|
|
560
|
+
// A reset sets the CPU clock back, so the stopwatch starts again.
|
|
561
|
+
this.#timingStart = 0n;
|
|
534
562
|
if (params.run) {
|
|
535
563
|
await this.#runningCommand(() => this.#machine.reset(params.mode));
|
|
536
564
|
return { state: "running" };
|
|
@@ -817,14 +845,21 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
817
845
|
}
|
|
818
846
|
|
|
819
847
|
/**
|
|
820
|
-
* Records the staged file that VICE took for `kind`,
|
|
821
|
-
* it replaces: VICE does not read that one again.
|
|
822
|
-
* take stays until the session closes, because a
|
|
848
|
+
* Records the staged file that VICE took for `kind`, or that it no longer
|
|
849
|
+
* has, and removes the file it replaces: VICE does not read that one again.
|
|
850
|
+
* A file that VICE did not take stays until the session closes, because a
|
|
851
|
+
* held VICE can still read it. So does a file that Windows does not let go.
|
|
823
852
|
*/
|
|
824
|
-
#replaceStaged(kind:
|
|
853
|
+
#replaceStaged(kind: StagedKind, file: string | undefined): void {
|
|
825
854
|
const previous = this.#stagedFiles.get(kind);
|
|
826
|
-
this.#stagedFiles.
|
|
827
|
-
|
|
855
|
+
if (file === undefined) this.#stagedFiles.delete(kind);
|
|
856
|
+
else this.#stagedFiles.set(kind, file);
|
|
857
|
+
if (previous === undefined || previous === file) return;
|
|
858
|
+
try {
|
|
859
|
+
rmSync(previous, { force: true });
|
|
860
|
+
} catch {
|
|
861
|
+
// The session directory goes when the session closes.
|
|
862
|
+
}
|
|
828
863
|
}
|
|
829
864
|
|
|
830
865
|
/** Loads a PRG without reset or start; finishes stopped. */
|
|
@@ -1074,10 +1109,12 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
1074
1109
|
if (baseline === undefined) throw new WireFailure("not-found", `There is no baseline named ${params.baseline}.`);
|
|
1075
1110
|
const current = await this.#observe(() => this.#machine.captureFrame(this.#videoStandard));
|
|
1076
1111
|
const difference = compareFrames(baseline.frame, current.frame, params.mask);
|
|
1077
|
-
|
|
1112
|
+
if (difference.comparedPixels === 0) throw new WireFailure("invalid-input", "The mask covers the whole screen: nothing to compare.");
|
|
1113
|
+
const mismatchRatio = difference.mismatchingPixels / difference.comparedPixels;
|
|
1078
1114
|
const result: ScreenComparison = {
|
|
1079
1115
|
match: mismatchRatio <= params.maxMismatchRatio,
|
|
1080
1116
|
mismatchingPixels: difference.mismatchingPixels,
|
|
1117
|
+
comparedPixels: difference.comparedPixels,
|
|
1081
1118
|
mismatchRatio,
|
|
1082
1119
|
};
|
|
1083
1120
|
if (difference.bounds !== undefined) result.bounds = difference.bounds;
|
|
@@ -4,7 +4,7 @@ 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, withStopAndAsk, type
|
|
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
8
|
import { PACKAGE } from "../package.ts";
|
|
9
9
|
|
|
10
10
|
export interface Endpoint {
|
|
@@ -37,19 +37,19 @@ export function hostEndpoints(env: NodeJS.ProcessEnv = process.env): Endpoint[]
|
|
|
37
37
|
return ["127.0.0.1", "host.docker.internal", "host.containers.internal"].map((host) => ({ host, port: DEFAULT_HOST_PORT }));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
/** A host that does not run or cannot be reached is machine-unavailable for every role (D37). */
|
|
41
|
+
function unreachable(): WireFailure {
|
|
42
42
|
return new WireFailure(
|
|
43
|
-
|
|
43
|
+
"machine-unavailable",
|
|
44
44
|
withStopAndAsk(
|
|
45
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
46
|
),
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function stopped(
|
|
50
|
+
function stopped(): WireFailure {
|
|
51
51
|
return new WireFailure(
|
|
52
|
-
|
|
52
|
+
"machine-unavailable",
|
|
53
53
|
"The connection to the c64-re-tools host runtime stopped before the host runtime was ready.",
|
|
54
54
|
);
|
|
55
55
|
}
|
|
@@ -146,9 +146,9 @@ export class HostConnection {
|
|
|
146
146
|
}
|
|
147
147
|
if (signal?.aborted) {
|
|
148
148
|
socket?.destroy();
|
|
149
|
-
throw stopped(
|
|
149
|
+
throw stopped();
|
|
150
150
|
}
|
|
151
|
-
if (socket === undefined) throw unreachable(
|
|
151
|
+
if (socket === undefined) throw unreachable();
|
|
152
152
|
|
|
153
153
|
const connection = new HostConnection(socket);
|
|
154
154
|
const hello: Hello = { type: "hello", protocol: HOST_PROTOCOL_ID, version: HOST_PROTOCOL_VERSION, role: options.role };
|
|
@@ -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
|
}
|
|
@@ -9,23 +9,32 @@ import type { ViceSessionClient } from "./vice-session.ts";
|
|
|
9
9
|
/** A full coverage file: offset n is address n (D40). */
|
|
10
10
|
export const FULL_COVERAGE = 0x10000;
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*/
|
|
16
|
-
export function readCoverageFiles(paths: string[], fail: (message: string) => never): Uint8Array | undefined {
|
|
17
|
-
if (paths.length === 0) return undefined;
|
|
18
|
-
const joined = new Uint8Array(FULL_COVERAGE);
|
|
19
|
-
for (const path of paths) {
|
|
12
|
+
/** Reads full coverage files from the project, one map per file. `fail` refuses a file of another size. */
|
|
13
|
+
export function readCoverageMaps(paths: string[], fail: (message: string) => never): Uint8Array[] {
|
|
14
|
+
return paths.map((path) => {
|
|
20
15
|
const { bytes } = readProjectFile(path);
|
|
21
16
|
if (bytes.length !== FULL_COVERAGE) {
|
|
22
17
|
fail(`--coverage ${path} has ${bytes.length} bytes; a full coverage file has 65536, one for each address. Write it with c64_memmap action read and out, without start and end.`);
|
|
23
18
|
}
|
|
24
|
-
|
|
25
|
-
}
|
|
19
|
+
return bytes;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Joins coverage maps: an address has every flag that any run gave it. */
|
|
24
|
+
export function joinCoverage(maps: readonly Uint8Array[]): Uint8Array {
|
|
25
|
+
const joined = new Uint8Array(FULL_COVERAGE);
|
|
26
|
+
for (const map of maps) for (let address = 0; address < FULL_COVERAGE; address++) joined[address]! |= map[address]!;
|
|
26
27
|
return joined;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Reads full coverage files from the project and joins them: an address has
|
|
32
|
+
* every flag that any run gave it. `fail` refuses a file of another size.
|
|
33
|
+
*/
|
|
34
|
+
export function readCoverageFiles(paths: string[], fail: (message: string) => never): Uint8Array | undefined {
|
|
35
|
+
return paths.length === 0 ? undefined : joinCoverage(readCoverageMaps(paths, fail));
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
/** The coverage bytes of start..end, read from the session in parts. */
|
|
30
39
|
export async function readCoverage(session: Pick<ViceSessionClient, "memmap">, start = 0x0000, end = 0xffff): Promise<Uint8Array> {
|
|
31
40
|
const map = new Uint8Array(end - start + 1);
|
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,9 +78,9 @@ 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. */
|
|
@@ -134,7 +134,7 @@ export class ViceSessionClient {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/** Autostarts a project program or image, as VICE's autostart does. */
|
|
137
|
-
async autostart(params: { path: string; index: number; run: boolean }): Promise<{ state: RunState }> {
|
|
137
|
+
async autostart(params: { path: string; index: number; run: boolean }): Promise<{ state: RunState; emulatorFlaw?: string }> {
|
|
138
138
|
const file = readProjectFile(params.path);
|
|
139
139
|
return this.#request("autostart", { type: file.type, index: params.index, run: params.run }, [file.bytes]);
|
|
140
140
|
}
|
package/src/knowledge/schema.ts
CHANGED
|
@@ -45,6 +45,13 @@ export type SuppressionEntity = (typeof SUPPRESSION_ENTITIES)[number];
|
|
|
45
45
|
|
|
46
46
|
/** A module file of the generated source (D48): a plain name that ends in .asm, no directory. */
|
|
47
47
|
export const MODULE_FILE = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,59}\.asm$/;
|
|
48
|
+
/** Files that c64-source writes itself: no module can have these names. */
|
|
49
|
+
export const GENERATED_FILES = ["main.asm", "loader.asm", "constants.asm"] as const;
|
|
50
|
+
|
|
51
|
+
/** True for a generated file name in any letter case: the file systems of macOS and Windows ignore case. */
|
|
52
|
+
export function isGeneratedFile(file: string): boolean {
|
|
53
|
+
return (GENERATED_FILES as readonly string[]).includes(file.toLowerCase());
|
|
54
|
+
}
|
|
48
55
|
|
|
49
56
|
/**
|
|
50
57
|
* The address space of a knowledge row (D53): the C64, the 1541 drive, or a
|
package/src/knowledge/write.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
MAX_ELEMENT,
|
|
42
42
|
MODULE_FILE,
|
|
43
43
|
DEFAULT_SPACE,
|
|
44
|
+
GENERATED_FILES,
|
|
45
|
+
isGeneratedFile,
|
|
44
46
|
SPACE,
|
|
45
47
|
NO_ELEMENT_TYPES,
|
|
46
48
|
REFERENCE_KINDS,
|
|
@@ -431,6 +433,9 @@ function checkModule(request: ModuleRequest): void {
|
|
|
431
433
|
checkSpace(request);
|
|
432
434
|
checkAddress(request.start, "start");
|
|
433
435
|
if (!MODULE_FILE.test(request.file)) throw new KnowledgeError("invalid-input", "A module file is a name of letters, digits, underscores or hyphens (at most 60) that ends in .asm, without a directory.");
|
|
436
|
+
if (isGeneratedFile(request.file)) {
|
|
437
|
+
throw new KnowledgeError("invalid-input", `c64-source writes ${GENERATED_FILES.join(", ")} itself, in any letter case, so a module cannot have the file ${request.file}. Give the module another file.`);
|
|
438
|
+
}
|
|
434
439
|
if (request.title !== undefined && (request.title.trim().length === 0 || request.title.length > MAX_COMMENT)) throw new KnowledgeError("invalid-input", `A module title has 1 to ${MAX_COMMENT} characters.`);
|
|
435
440
|
}
|
|
436
441
|
|
package/src/mcp/tools/debug.ts
CHANGED
|
@@ -129,7 +129,7 @@ export const c64CpuHistory = defineTool({
|
|
|
129
129
|
description:
|
|
130
130
|
"Get the last instructions that the CPU executed, oldest first. " +
|
|
131
131
|
`limit is the number of instructions (1 to ${MAX_HISTORY}, default 50). ` +
|
|
132
|
-
"Each entry gives the address, the bytes, the instruction and the registers a, x, y and sp. " +
|
|
132
|
+
"Each entry gives the address, the bytes, the instruction and the registers a, x, y and sp before the instruction executed. " +
|
|
133
133
|
"In space c64, it also gives the raster line and cycle when the instruction started. " +
|
|
134
134
|
"With out, it writes the entries as text lines to that project file, and the result gives the file in place of the entries. " +
|
|
135
135
|
"This does not change the run state of the machine.",
|
|
@@ -191,7 +191,8 @@ export const c64Timing = defineTool({
|
|
|
191
191
|
title: "Cycle stopwatch",
|
|
192
192
|
description:
|
|
193
193
|
"A stopwatch that counts C64 CPU cycles. Action start sets it to zero. Action read gives the cycles since the last start, " +
|
|
194
|
-
"or since the session started, as a decimal string. A
|
|
194
|
+
"or since the session started, as a decimal string. A reset and c64_autostart start it again from the reset. " +
|
|
195
|
+
"A PAL frame has 19656 cycles and an NTSC frame 17095.",
|
|
195
196
|
inputSchema: z.object({ action: z.enum(TIMING_ACTIONS) }).strict(),
|
|
196
197
|
outputSchema: z.object({ started: z.boolean().optional(), cycles: z.string().regex(/^\d+$/).optional() }),
|
|
197
198
|
readOnly: false,
|
package/src/mcp/tools/machine.ts
CHANGED
|
@@ -13,7 +13,8 @@ export const c64Status = defineTool({
|
|
|
13
13
|
description:
|
|
14
14
|
"Get the state of the C64. The result tells if the CPU runs or has stopped, the video standard (pal or ntsc), " +
|
|
15
15
|
"and if warp mode is on. It also tells if the emulator window is open (window). " +
|
|
16
|
-
"When the CPU has stopped, the result also gives the program counter (pc)."
|
|
16
|
+
"When the CPU has stopped, the result also gives the program counter (pc). " +
|
|
17
|
+
"cartridge is true while a cartridge from c64_autostart is attached: a reset keeps it, and an autostart of another file detaches it.",
|
|
17
18
|
inputSchema: z.object({}).strict(),
|
|
18
19
|
outputSchema: z.object({
|
|
19
20
|
state: z.enum(RUN_STATES),
|
|
@@ -21,6 +22,7 @@ export const c64Status = defineTool({
|
|
|
21
22
|
warp: z.boolean(),
|
|
22
23
|
window: z.boolean().describe("true when the emulator window is open (c64_window)"),
|
|
23
24
|
pc: AddressOutput.optional().describe("program counter. Only when the CPU is stopped"),
|
|
25
|
+
cartridge: z.literal(true).optional().describe("true while a cartridge is attached. Absent when none is"),
|
|
24
26
|
}),
|
|
25
27
|
readOnly: true,
|
|
26
28
|
async run(_input, session) {
|
|
@@ -31,6 +33,7 @@ export const c64Status = defineTool({
|
|
|
31
33
|
warp: status.warp,
|
|
32
34
|
window: status.window,
|
|
33
35
|
...(status.pc === undefined ? {} : { pc: formatC64Address(status.pc) }),
|
|
36
|
+
...(status.cartridge === undefined ? {} : { cartridge: status.cartridge }),
|
|
34
37
|
};
|
|
35
38
|
},
|
|
36
39
|
});
|
package/src/mcp/tools/media.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const c64Autostart = defineTool({
|
|
|
20
20
|
description:
|
|
21
21
|
`Start a program, or a disk, tape or cartridge image, from the project (${list(AUTOSTART_TYPES)}). ` +
|
|
22
22
|
"The C64 resets and loads the file. With run true (the default), it also runs the file. A disk image stays in drive 8. " +
|
|
23
|
+
"A cartridge stays in the C64 through every reset, as on a real C64. An autostart of a file that is not a cartridge detaches it first. " +
|
|
23
24
|
"Without index, a disk image loads like LOAD\"*\",8,1. index selects one directory entry by its number in the c64-disk directory list (0 is the first entry). " +
|
|
24
25
|
"The machine runs when this tool returns, and the load continues.",
|
|
25
26
|
inputSchema: z
|
|
@@ -29,7 +30,10 @@ export const c64Autostart = defineTool({
|
|
|
29
30
|
run: z.boolean().default(true).describe("true runs the program after it loads"),
|
|
30
31
|
})
|
|
31
32
|
.strict(),
|
|
32
|
-
outputSchema: z.object({
|
|
33
|
+
outputSchema: z.object({
|
|
34
|
+
state: z.enum(RUN_STATES),
|
|
35
|
+
emulatorFlaw: z.string().optional().describe("for a tape image: a known flaw of this VICE version that stops a tape load"),
|
|
36
|
+
}),
|
|
33
37
|
readOnly: false,
|
|
34
38
|
async run(input, session) {
|
|
35
39
|
// VICE numbers the directory entries from 1, and its 0 loads "*".
|
package/src/mcp/tools/video.ts
CHANGED
|
@@ -133,6 +133,7 @@ export const c64Screen = defineTool({
|
|
|
133
133
|
"With out, capture writes the PNG to that project file, and compare writes its difference image there. The result then has no image. " +
|
|
134
134
|
"With baseline, the session also keeps that frame under the name. " +
|
|
135
135
|
"Action compare compares the current frame with a baseline, pixel by pixel. mask lists rectangles to ignore. " +
|
|
136
|
+
"A mask that covers the whole frame is invalid input. comparedPixels is the number of pixels outside the mask. " +
|
|
136
137
|
"match is true when the part of compared pixels that differ is at most maxMismatchRatio (default 0). bounds holds every different pixel. " +
|
|
137
138
|
"With includeDiff, compare also returns an image with the different pixels in magenta. " +
|
|
138
139
|
"Action list gives the baseline names. Action discard removes a baseline. Baselines stay until the session ends. " +
|
|
@@ -154,6 +155,7 @@ export const c64Screen = defineTool({
|
|
|
154
155
|
baseline: z.string().optional(),
|
|
155
156
|
match: z.boolean().optional(),
|
|
156
157
|
mismatchingPixels: z.number().int().min(0).optional(),
|
|
158
|
+
comparedPixels: z.number().int().min(1).optional(),
|
|
157
159
|
mismatchRatio: z.number().min(0).max(1).optional(),
|
|
158
160
|
bounds: Rectangle.optional(),
|
|
159
161
|
baselines: z.array(z.string()).optional(),
|
package/src/native/discover.ts
CHANGED
|
@@ -96,7 +96,8 @@ const VICE_FLAWS = [
|
|
|
96
96
|
text:
|
|
97
97
|
"VICE 3.10 and older hold bits 5-7 of CIA 1 ports A and B low with the joystick device that c64-re-tools uses (VICE bug #2202). " +
|
|
98
98
|
"$DC00 and $DC01 read $1F with no input. A program that waits for one key of the keyboard matrix (for example LDA $DC01 / CMP #$EF for SPACE) waits forever. " +
|
|
99
|
-
"A program that compares the full byte of a joystick port also waits forever.
|
|
99
|
+
"A program that compares the full byte of a joystick port also waits forever. " +
|
|
100
|
+
"A KERNAL tape LOAD of a .tap image stops after FOUND, with no LOADING, and the C64 shows READY. The fix is in the next VICE release, and in VICE nightly builds now (SVN r45971 or later). " +
|
|
100
101
|
"Install the latest VICE when this matters.",
|
|
101
102
|
},
|
|
102
103
|
] as const;
|
package/src/platform.ts
CHANGED
|
@@ -345,12 +345,13 @@ export function lookup(address: number): Lookup {
|
|
|
345
345
|
* The platform name of each named address (D52): system variables, ROM
|
|
346
346
|
* entries and I/O registers, not mirrors, in address order. Only names that
|
|
347
347
|
* an assembler can use as a symbol, and each name once, at its lowest address.
|
|
348
|
+
* `end` is the last address of the entry: a vector has two bytes.
|
|
348
349
|
*/
|
|
349
|
-
export function platformNames(): Array<{ address: number; name: string; meaning: string }> {
|
|
350
|
+
export function platformNames(): Array<{ address: number; end: number; name: string; meaning: string }> {
|
|
350
351
|
const io = IO.flatMap((block) => block.registers);
|
|
351
352
|
const seen = new Set<string>();
|
|
352
353
|
return [...SYSTEM, ...ROM, ...io]
|
|
353
|
-
.map((entry) => ({ address: entry.start, name: entry.name, meaning: entry.meaning }))
|
|
354
|
+
.map((entry) => ({ address: entry.start, end: entry.end, name: entry.name, meaning: entry.meaning }))
|
|
354
355
|
.filter((entry) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.name))
|
|
355
356
|
.sort((a, b) => a.address - b.address)
|
|
356
357
|
.filter((entry) => !seen.has(entry.name) && seen.add(entry.name) !== undefined);
|
package/src/protocol/tools.ts
CHANGED
|
@@ -41,7 +41,8 @@ export interface DiskSector {
|
|
|
41
41
|
|
|
42
42
|
export type C1541Result =
|
|
43
43
|
| { action: "directory"; diskName: string; diskId: string; dosType: string; freeBlocks: number; entries: DiskFile[] }
|
|
44
|
-
|
|
44
|
+
/** unreferenced: the used sectors that no file, the header, the directory or the BAM holds. */
|
|
45
|
+
| { action: "bam"; tracks: Array<{ track: number; freeSectors: number[]; usedSectors: number[] }>; unreferenced: DiskSector[] }
|
|
45
46
|
| { action: "entry"; found: false }
|
|
46
47
|
| { action: "entry"; found: true; entry: DiskFile & { startTrack: number; startSector: number } }
|
|
47
48
|
| { action: "chain"; found: false }
|
|
@@ -83,8 +84,18 @@ export interface ToolStatus {
|
|
|
83
84
|
flaws?: string[];
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/** The Host Runtime itself: its package version and the system it runs on. */
|
|
88
|
+
export interface HostInfo {
|
|
89
|
+
version: string;
|
|
90
|
+
/** Node's name of the operating system: linux, darwin or win32. */
|
|
91
|
+
platform: string;
|
|
92
|
+
/** The release of the operating system. */
|
|
93
|
+
release: string;
|
|
94
|
+
arch: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
export interface ToolOperations {
|
|
87
|
-
"host.status": { params: Record<string, never>; result: { tools: ToolStatus[] } };
|
|
98
|
+
"host.status": { params: Record<string, never>; result: { host: HostInfo; tools: ToolStatus[] } };
|
|
88
99
|
"c1541.inspect": { params: C1541Params; result: C1541Result };
|
|
89
100
|
"petcat.decode": { params: PetcatParams; result: PetcatResult };
|
|
90
101
|
}
|
|
@@ -177,7 +188,9 @@ export function validateToolResult<O extends ToolOperation>(op: O, value: unknow
|
|
|
177
188
|
typeof tool.runs === "boolean" &&
|
|
178
189
|
[tool.path, tool.version, tool.problem].every((field) => field === undefined || typeof field === "string") &&
|
|
179
190
|
(tool.flaws === undefined || (Array.isArray(tool.flaws) && tool.flaws.every((flaw: unknown) => typeof flaw === "string")));
|
|
180
|
-
|
|
191
|
+
const host = value.host;
|
|
192
|
+
const isHost = isObject(host) && [host.version, host.platform, host.release, host.arch].every((field) => typeof field === "string");
|
|
193
|
+
if (!isHost || !Array.isArray(tools) || !tools.every(isStatus) || attachments.length !== 0) throw new ProtocolError("host.status result is malformed");
|
|
181
194
|
return value as unknown as ToolOperations[O]["result"];
|
|
182
195
|
}
|
|
183
196
|
}
|
|
@@ -214,7 +227,9 @@ function isC1541Result(value: Fields, attachments: readonly Uint8Array[]): boole
|
|
|
214
227
|
case "bam":
|
|
215
228
|
return (
|
|
216
229
|
Array.isArray(value.tracks) &&
|
|
217
|
-
value.tracks.every((track) => isObject(track) && isInteger(track.track, 1, 255) && isSectorList(track.freeSectors) && isSectorList(track.usedSectors))
|
|
230
|
+
value.tracks.every((track) => isObject(track) && isInteger(track.track, 1, 255) && isSectorList(track.freeSectors) && isSectorList(track.usedSectors)) &&
|
|
231
|
+
Array.isArray(value.unreferenced) &&
|
|
232
|
+
value.unreferenced.every(isTrackSector)
|
|
218
233
|
);
|
|
219
234
|
case "entry":
|
|
220
235
|
return (
|