@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.
Files changed (48) 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/tools/c1541.ts +34 -5
  30. package/src/host/tools/petcat.ts +5 -2
  31. package/src/host/tools/status.ts +6 -3
  32. package/src/host/vice/adapter.ts +23 -9
  33. package/src/host/vice/process.ts +7 -2
  34. package/src/host/vice/session.ts +47 -10
  35. package/src/host-client/connect.ts +10 -10
  36. package/src/host-client/coverage.ts +19 -10
  37. package/src/host-client/tools.ts +5 -5
  38. package/src/host-client/vice-session.ts +1 -1
  39. package/src/knowledge/schema.ts +7 -0
  40. package/src/knowledge/write.ts +5 -0
  41. package/src/mcp/tools/debug.ts +3 -2
  42. package/src/mcp/tools/machine.ts +4 -1
  43. package/src/mcp/tools/media.ts +5 -1
  44. package/src/mcp/tools/video.ts +2 -0
  45. package/src/native/discover.ts +2 -1
  46. package/src/platform.ts +3 -2
  47. package/src/protocol/tools.ts +19 -4
  48. package/src/protocol/vice.ts +11 -2
@@ -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",
@@ -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. */
@@ -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
- 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;
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 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(),
@@ -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. The fix is in the next VICE release, and in VICE nightly builds now (SVN r45971 or later). " +
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);
@@ -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
- | { action: "bam"; tracks: Array<{ track: number; freeSectors: number[]; usedSectors: number[] }> }
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
- if (!Array.isArray(tools) || !tools.every(isStatus) || attachments.length !== 0) throw new ProtocolError("host.status result is malformed");
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 (