@henols/c64-re-tools 2.0.0-rc.5 → 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.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +1 -1
  3. package/skills/c64-basic/SKILL.md +1 -1
  4. package/skills/c64-disk/SKILL.md +3 -3
  5. package/skills/c64-disk/scripts/disk.ts +2 -1
  6. package/skills/c64-emulator/SKILL.md +3 -1
  7. package/skills/c64-knowledge/SKILL.md +1 -1
  8. package/skills/c64-provenance/SKILL.md +7 -5
  9. package/skills/c64-provenance/scripts/compare.ts +26 -0
  10. package/skills/c64-provenance/scripts/provenance.ts +24 -10
  11. package/skills/c64-provenance/scripts/separate.ts +12 -4
  12. package/skills/c64-reverse-engineering/SKILL.md +2 -2
  13. package/skills/c64-source/SKILL.md +15 -9
  14. package/skills/c64-source/scripts/generate.ts +36 -10
  15. package/skills/c64-source/scripts/loader.ts +88 -6
  16. package/skills/c64-source/scripts/source.ts +40 -9
  17. package/skills/c64-static-analysis/SKILL.md +4 -4
  18. package/skills/c64-static-analysis/scripts/analyze.ts +13 -6
  19. package/skills/c64-static-analysis/scripts/coverage.ts +4 -4
  20. package/skills/c64-static-analysis/scripts/idioms.ts +14 -9
  21. package/skills/c64-testing/SKILL.md +1 -1
  22. package/skills/c64-testing/scripts/scenario.ts +2 -2
  23. package/skills/c64-unpacker/SKILL.md +9 -7
  24. package/skills/c64-unpacker/scripts/loads.ts +12 -1
  25. package/skills/c64-unpacker/scripts/stages.ts +66 -3
  26. package/skills/c64-unpacker/scripts/unpack.ts +96 -30
  27. package/src/c64.ts +14 -5
  28. package/src/cli/status.ts +26 -3
  29. package/src/host/main.ts +6 -5
  30. package/src/host/server.ts +12 -4
  31. package/src/host/tools/c1541.ts +34 -5
  32. package/src/host/tools/index.ts +6 -2
  33. package/src/host/tools/petcat.ts +5 -2
  34. package/src/host/tools/status.ts +17 -6
  35. package/src/host/vice/adapter.ts +23 -9
  36. package/src/host/vice/process.ts +9 -3
  37. package/src/host/vice/session.ts +49 -12
  38. package/src/host-client/connect.ts +10 -10
  39. package/src/host-client/coverage.ts +19 -10
  40. package/src/host-client/tools.ts +5 -5
  41. package/src/host-client/vice-session.ts +1 -1
  42. package/src/knowledge/schema.ts +7 -0
  43. package/src/knowledge/write.ts +5 -0
  44. package/src/mcp/tools/debug.ts +3 -2
  45. package/src/mcp/tools/machine.ts +4 -1
  46. package/src/mcp/tools/media.ts +5 -1
  47. package/src/mcp/tools/video.ts +2 -0
  48. package/src/native/discover.ts +2 -1
  49. package/src/platform.ts +3 -2
  50. package/src/protocol/tools.ts +19 -4
  51. package/src/protocol/vice.ts +11 -2
@@ -3,6 +3,7 @@
3
3
  // ACME, DXA and Ghidra themselves. One entry per operation, never a
4
4
  // generic executable route.
5
5
 
6
+ import type { ViceVersion } from "../../native/discover.ts";
6
7
  import type { ProcessSupervisor } from "../../native/processes.ts";
7
8
  import type { ToolContext } from "../../native/run.ts";
8
9
  import type { ToolOperation, ToolOperations } from "../../protocol/tools.ts";
@@ -24,8 +25,11 @@ const ADAPTERS: { [O in ToolOperation]: Adapter<O> } = {
24
25
  "petcat.decode": (_params, attachments, context) => decode(attachments[0]!, context),
25
26
  };
26
27
 
27
- export function createToolDispatcher(options: { supervisor: ProcessSupervisor; env?: NodeJS.ProcessEnv; log?: (line: string) => void }): ToolDispatcher {
28
+ /** `vice`: the version that the host read from the emulator at start, for host.status. */
29
+ export function createToolDispatcher(options: { supervisor: ProcessSupervisor; env?: NodeJS.ProcessEnv; log?: (line: string) => void; vice?: ViceVersion }): ToolDispatcher {
28
30
  return (op, params, attachments, signal) => {
29
- return ADAPTERS[op](params, attachments, { supervisor: options.supervisor, signal, env: options.env, log: options.log });
31
+ const context = { supervisor: options.supervisor, signal, env: options.env, log: options.log };
32
+ if (op === "host.status") return hostStatus(context, options.vice) as ReturnType<ToolDispatcher>;
33
+ return ADAPTERS[op](params, attachments, context);
30
34
  };
31
35
  }
@@ -16,14 +16,17 @@ import { runToolOrFail, type ToolContext } from "../../native/run.ts";
16
16
 
17
17
  const TIMEOUT_MS = 30_000;
18
18
  const LISTING = "listing.txt";
19
- /** petcat's listing lines (" 10 sys2061"), as number and text. */
19
+ /**
20
+ * petcat's listing lines (" 10 sys2061"), as number and text. petcat
21
+ * prints π ($ff) as `~`, which is no PETSCII character: the text has π (F-13).
22
+ */
20
23
  export function parseListing(listing: string): Array<{ number: number; text: string }> {
21
24
  const lines: Array<{ number: number; text: string }> = [];
22
25
  for (const line of listing.split(/\r?\n/)) {
23
26
  if (line.trim() === "") continue;
24
27
  const match = /^\s*(\d+) ?(.*)$/.exec(line);
25
28
  if (match === null) throw new WireFailure("operation-failed", "petcat printed a listing line without a line number.");
26
- lines.push({ number: Number(match[1]), text: match[2]! });
29
+ lines.push({ number: Number(match[1]), text: match[2]!.replaceAll("~", "π") });
27
30
  }
28
31
  return lines;
29
32
  }
@@ -1,9 +1,15 @@
1
1
  // host.status: VICE and the tools that come with it, each found the way its
2
2
  // operations find it and run once with a version option. A tool from a
3
3
  // VICE older than MIN_VICE is reported as not working, as its operations refuse it.
4
+ // VICE itself is reported with the version that the host read from the
5
+ // running emulator at start: the Homebrew x64sc of VICE 3.10 on macOS stops
6
+ // on -version ("argv[0] is NULL") although the emulator runs.
4
7
 
5
- import type { ToolStatus } from "../../protocol/tools.ts";
6
- import { C1541, findTool, isOlderThanMinimum, MIN_VICE, PETCAT, VICE, viceFlaws, viceVersionOf, type ToolSpec } from "../../native/discover.ts";
8
+ import { arch, platform, release } from "node:os";
9
+
10
+ import { PACKAGE } from "../../package.ts";
11
+ import type { HostInfo, ToolStatus } from "../../protocol/tools.ts";
12
+ import { C1541, findTool, isOlderThanMinimum, MIN_VICE, PETCAT, VICE, viceFlaws, viceVersionOf, type ToolSpec, type ViceVersion } from "../../native/discover.ts";
7
13
  import type { ToolContext } from "../../native/run.ts";
8
14
  import { probeTools } from "../../native/status.ts";
9
15
  import { findVice } from "../vice/process.ts";
@@ -16,21 +22,26 @@ function requireMinimum(status: ToolStatus, tool: ToolSpec): ToolStatus {
16
22
  return { ...status, runs: false, problem: `${found}, and c64-re-tools needs VICE ${MIN_VICE.major}.${MIN_VICE.minor} or later. ${tool.remedy}` };
17
23
  }
18
24
 
19
- export async function hostStatus(context: ToolContext): Promise<{ result: { tools: ToolStatus[] } }> {
25
+ /** The version text of a VICE version, as x64sc -version prints it. */
26
+ const versionText = (version: ViceVersion) => `x64sc (VICE ${version.major}.${version.minor}${version.revision === undefined ? "" : ` SVN r${version.revision}`})`;
27
+
28
+ /** `started`: the version that the host read from the emulator at start; without it, VICE is probed with -version too. */
29
+ export async function hostStatus(context: ToolContext, started?: ViceVersion): Promise<{ result: { host: HostInfo; tools: ToolStatus[] } }> {
20
30
  const specs = [VICE, C1541, PETCAT];
21
- const tools = await probeTools(
31
+ const probed = await probeTools(
22
32
  [
23
- { name: VICE.name, find: findVice, args: ["-version"], pattern: /^x64sc \(VICE \d/ },
33
+ ...(started === undefined ? [{ name: VICE.name, find: findVice, args: ["-version"], pattern: /^x64sc \(VICE \d/ }] : []),
24
34
  { name: C1541.name, find: (env) => findTool(C1541, env), args: ["-version"], pattern: /^c1541 \(VICE \d/ },
25
35
  { name: PETCAT.name, find: (env) => findTool(PETCAT, env), args: ["-version"], pattern: /^petcat \(VICE \d/ },
26
36
  ],
27
37
  context,
28
38
  );
39
+ const tools = started === undefined ? probed : [{ name: VICE.name, found: true, path: findVice(context.env ?? process.env), version: versionText(started), runs: true }, ...probed];
29
40
  const checked = tools.map((status, index) => requireMinimum(status, specs[index]!));
30
41
  // VICE runs with its known flaws; status names them (D56).
31
42
  const vice = checked[0]!;
32
43
  const version = vice.runs ? viceVersionOf(vice.version ?? "") : undefined;
33
44
  const flaws = version === undefined ? [] : viceFlaws(version);
34
45
  if (flaws.length > 0) checked[0] = { ...vice, flaws };
35
- return { result: { tools: checked } };
46
+ return { result: { host: { version: PACKAGE.version, platform: platform(), release: release(), arch: arch() }, tools: checked } };
36
47
  }
@@ -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
- const row = new RegExp(`^\\s*(${COUNT})\\s+[\\d.,]+%\\s+(${COUNT})\\s+([\\d.,]+)%\\s+(\\S+)\\s*$`).exec(line);
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
- /** The lines of a text command's answer that are not blank, not a stop line and not `known`. */
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`);
@@ -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",
@@ -307,6 +312,7 @@ async function launchOnce(options: LaunchOptions): Promise<ViceProcess> {
307
312
  clearInterval(slow);
308
313
  }
309
314
  if (failure !== undefined) throw failure;
315
+ log(`VICE stopped (pid ${child.pid}, in ${Date.now() - started} ms)`);
310
316
  })();
311
317
  return stopping;
312
318
  };
@@ -325,7 +331,7 @@ async function launchOnce(options: LaunchOptions): Promise<ViceProcess> {
325
331
  if (error instanceof WireFailure) throw error;
326
332
  throw new WireFailure("machine-unavailable", "The emulator could not be started on the host.");
327
333
  }
328
- log(`VICE started (pid ${child.pid}, monitor ports ${port} and ${textPort})`);
334
+ log(`VICE started (${mode}, pid ${child.pid}, monitor ports ${port} and ${textPort})`);
329
335
  trace().event("vice.ready", { pid: child.pid, ms: elapsedMs(launched) });
330
336
  return { pid: child.pid, monitor, text, exited: child.exited, outputTail, stop };
331
337
  }
@@ -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<"autostart" | "disk", string>();
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
- this.#replaceStaged("autostart", file);
364
- return { state: "running" };
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`, and removes the file
821
- * it replaces: VICE does not read that one again. A file that VICE did not
822
- * take stays until the session closes, because a held VICE can still read it.
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: "autostart" | "disk", file: string): void {
853
+ #replaceStaged(kind: StagedKind, file: string | undefined): void {
825
854
  const previous = this.#stagedFiles.get(kind);
826
- this.#stagedFiles.set(kind, file);
827
- if (previous !== undefined) rmSync(previous, { force: true });
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. */
@@ -988,6 +1023,7 @@ export class ViceSession implements ViceSessionHandle {
988
1023
  if (this.#held !== undefined) throw new WireFailure("machine-unavailable", this.#heldWhy);
989
1024
  if (this.#launch === undefined) throw new WireFailure("operation-failed", "This session cannot start another emulator.");
990
1025
  const running = this.#state === "running";
1026
+ this.#log(mode === "window" ? "moving the machine to a VICE with a window" : "moving the machine back to a headless VICE");
991
1027
  await this.#stop();
992
1028
  const handover: Handover = {
993
1029
  file: join(this.#workDir, mode === "window" ? "window-open.vsf" : "window-close.vsf"),
@@ -1073,10 +1109,12 @@ export class ViceSession implements ViceSessionHandle {
1073
1109
  if (baseline === undefined) throw new WireFailure("not-found", `There is no baseline named ${params.baseline}.`);
1074
1110
  const current = await this.#observe(() => this.#machine.captureFrame(this.#videoStandard));
1075
1111
  const difference = compareFrames(baseline.frame, current.frame, params.mask);
1076
- const mismatchRatio = difference.comparedPixels === 0 ? 0 : difference.mismatchingPixels / difference.comparedPixels;
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;
1077
1114
  const result: ScreenComparison = {
1078
1115
  match: mismatchRatio <= params.maxMismatchRatio,
1079
1116
  mismatchingPixels: difference.mismatchingPixels,
1117
+ comparedPixels: difference.comparedPixels,
1080
1118
  mismatchRatio,
1081
1119
  };
1082
1120
  if (difference.bounds !== undefined) result.bounds = difference.bounds;
@@ -1354,8 +1392,7 @@ export function viceSessionFactory(options: {
1354
1392
  /** The known flaws of the VICE version that the host checked at start. */
1355
1393
  flaws?: readonly string[];
1356
1394
  }): ViceSessionFactory {
1357
- const log = options.log ?? (() => {});
1358
- return async ({ videoStandard }) => {
1395
+ return async ({ videoStandard, log = options.log ?? (() => {}) }) => {
1359
1396
  const launch: ViceLauncher = (mode, launchLog) =>
1360
1397
  launchVice({
1361
1398
  videoStandard,
@@ -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 ErrorCode, type Hello, type HostMessage, type Ping, type Request, type Role, type VideoStandard } from "../protocol/messages.ts";
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
- function unreachable(role: Role): WireFailure {
41
- const code: ErrorCode = role === "vice-session" ? "machine-unavailable" : "operation-failed";
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
- code,
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(role: Role): WireFailure {
50
+ function stopped(): WireFailure {
51
51
  return new WireFailure(
52
- role === "vice-session" ? "machine-unavailable" : "operation-failed",
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(options.role);
149
+ throw stopped();
150
150
  }
151
- if (socket === undefined) throw unreachable(options.role);
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(options.role);
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
- options.role === "vice-session" ? "machine-unavailable" : "operation-failed",
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
- * Reads full coverage files from the project and joins them: an address has
14
- * every flag that any run gave it. `fail` refuses a file of another size.
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
- for (let address = 0; address < FULL_COVERAGE; address++) joined[address]! |= bytes[address]!;
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);
@@ -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("operation-failed", "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.")),
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.tools;
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
  }
@@ -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
@@ -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
 
@@ -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 PAL frame has 19656 cycles and an NTSC frame 17095.",
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,
@@ -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
  });
@@ -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({ state: z.enum(RUN_STATES) }),
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 "*".
@@ -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(),