@henols/c64-re-tools 2.0.0-rc.6 → 2.0.0-rc.8

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 (55) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +1 -1
  3. package/skills/c64-basic/SKILL.md +2 -2
  4. package/skills/c64-basic/scripts/basic.ts +1 -1
  5. package/skills/c64-disk/SKILL.md +3 -3
  6. package/skills/c64-disk/scripts/disk.ts +2 -1
  7. package/skills/c64-emulator/SKILL.md +4 -1
  8. package/skills/c64-knowledge/SKILL.md +2 -2
  9. package/skills/c64-knowledge/scripts/knowledge.ts +5 -2
  10. package/skills/c64-provenance/SKILL.md +10 -5
  11. package/skills/c64-provenance/scripts/compare.ts +35 -6
  12. package/skills/c64-provenance/scripts/provenance.ts +35 -15
  13. package/skills/c64-provenance/scripts/separate.ts +12 -4
  14. package/skills/c64-reverse-engineering/SKILL.md +2 -2
  15. package/skills/c64-source/SKILL.md +18 -11
  16. package/skills/c64-source/scripts/generate.ts +55 -10
  17. package/skills/c64-source/scripts/loader.ts +253 -6
  18. package/skills/c64-source/scripts/source.ts +46 -11
  19. package/skills/c64-static-analysis/SKILL.md +4 -4
  20. package/skills/c64-static-analysis/scripts/analyze.ts +13 -6
  21. package/skills/c64-static-analysis/scripts/coverage.ts +4 -4
  22. package/skills/c64-static-analysis/scripts/idioms.ts +14 -9
  23. package/skills/c64-testing/SKILL.md +1 -1
  24. package/skills/c64-testing/references/scenario-format.md +1 -1
  25. package/skills/c64-testing/scripts/scenario.ts +9 -7
  26. package/skills/c64-unpacker/SKILL.md +11 -9
  27. package/skills/c64-unpacker/scripts/loads.ts +16 -4
  28. package/skills/c64-unpacker/scripts/machine.ts +13 -1
  29. package/skills/c64-unpacker/scripts/stages.ts +66 -3
  30. package/skills/c64-unpacker/scripts/unpack.ts +135 -42
  31. package/src/c64.ts +19 -5
  32. package/src/cli/status.ts +26 -3
  33. package/src/host/tools/c1541.ts +34 -5
  34. package/src/host/tools/petcat.ts +5 -2
  35. package/src/host/tools/status.ts +6 -3
  36. package/src/host/vice/adapter.ts +37 -9
  37. package/src/host/vice/chips.ts +11 -1
  38. package/src/host/vice/process.ts +7 -2
  39. package/src/host/vice/screen.ts +3 -2
  40. package/src/host/vice/session.ts +68 -15
  41. package/src/host-client/autostart.ts +11 -1
  42. package/src/host-client/connect.ts +10 -10
  43. package/src/host-client/coverage.ts +19 -10
  44. package/src/host-client/tools.ts +6 -6
  45. package/src/host-client/vice-session.ts +1 -1
  46. package/src/knowledge/schema.ts +7 -0
  47. package/src/knowledge/write.ts +5 -0
  48. package/src/mcp/tools/debug.ts +9 -5
  49. package/src/mcp/tools/machine.ts +4 -1
  50. package/src/mcp/tools/media.ts +7 -1
  51. package/src/mcp/tools/video.ts +7 -2
  52. package/src/native/discover.ts +2 -1
  53. package/src/platform.ts +3 -2
  54. package/src/protocol/tools.ts +19 -4
  55. package/src/protocol/vice.ts +56 -5
@@ -6,6 +6,7 @@ import { type Comparison, type Condition, type BacktraceFrame, type HistoryEntry
6
6
  import { COVERAGE, formatC64Address, formatC64Byte } from "../../c64.ts";
7
7
  import { decodeDisplay, decodePalette, visibleFrame, type IndexedFrame } from "./screen.ts";
8
8
  import type { TextMonitor } from "./text-monitor.ts";
9
+ import type { CiaHidden } from "./chips.ts";
9
10
  import {
10
11
  BinaryMonitor,
11
12
  Command,
@@ -135,7 +136,7 @@ export function parseDisassembly(answer: string, start: number): Instruction[] {
135
136
  for (const line of answer.split("\n")) {
136
137
  const match = /^\.(?:C|\d+):([0-9a-f]{4})\s+((?:[0-9a-f]{2} )*[0-9a-f]{2})\s+(.*?)\s*$/i.exec(line);
137
138
  if (match === null) {
138
- if (line.trim() !== "") unknown.push(line.trim());
139
+ if (line.trim() !== "" && !CONDITION_LINE.test(line.trim())) unknown.push(line.trim());
139
140
  continue;
140
141
  }
141
142
  const address = Number.parseInt(match[1]!, 16);
@@ -164,7 +165,7 @@ export function parseHistory(answer: string): Array<Omit<HistoryEntry, "rasterLi
164
165
  line,
165
166
  );
166
167
  if (match === null) {
167
- if (line.trim() !== "") unknown.push(line.trim());
168
+ if (line.trim() !== "" && !CONDITION_LINE.test(line.trim())) unknown.push(line.trim());
168
169
  continue;
169
170
  }
170
171
  entries.push({
@@ -193,7 +194,7 @@ export function parseHunt(answer: string, limit: number): number[] {
193
194
  for (const line of answer.split("\n")) {
194
195
  const match = /^([0-9a-f]{4})$/i.exec(line.trim());
195
196
  if (match === null) {
196
- if (line.trim() !== "") unknown.push(line.trim());
197
+ if (line.trim() !== "" && !CONDITION_LINE.test(line.trim())) unknown.push(line.trim());
197
198
  continue;
198
199
  }
199
200
  if (matches.length < limit) matches.push(Number.parseInt(match[1]!, 16));
@@ -236,14 +237,15 @@ export function parseProfile(answer: string): ProfileEntry[] {
236
237
  const unknown: string[] = [];
237
238
  for (const line of answer.split("\n")) {
238
239
  // 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);
240
+ // A profile of zero cycles, as after a window move restarts it, prints its percents as "-nan".
241
+ const row = new RegExp(`^\\s*(${COUNT})\\s+(?:[\\d.,]+|-?nan)%\\s+(${COUNT})\\s+([\\d.,]+|-?nan)%\\s+(\\S+)\\s*$`, "i").exec(line);
240
242
  if (row === null) {
241
- if (line.trim() !== "" && !/^Total\s+%\s+Self\s+%$/.test(line.trim()) && !/^[-\s]+$/.test(line)) unknown.push(line.trim());
243
+ if (line.trim() !== "" && !CONDITION_LINE.test(line.trim()) && !/^Total\s+%\s+Self\s+%$/.test(line.trim()) && !/^[-\s]+$/.test(line)) unknown.push(line.trim());
242
244
  continue;
243
245
  }
244
246
  // A row named ROOT or by a label has no routine address.
245
247
  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(",", ".")) });
248
+ 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
249
  }
248
250
  // An answer in another form must not read as "nothing ran".
249
251
  if (entries.length === 0 && unknown.length > 0) throw unknownForm("profile", unknown);
@@ -284,7 +286,7 @@ export function parseMemmap(answer: string, maxRanges: number): { ranges: Memmap
284
286
  for (const line of answer.split("\n")) {
285
287
  const match = /^([0-9a-f]{4}): (\S{3}) (\S{3}) (\S{3})/i.exec(line);
286
288
  if (match === null) {
287
- if (line.trim() !== "" && !/^addr:\s+IO\s+ROM\s+RAM$/.test(line.trim())) unknown.push(line.trim());
289
+ if (line.trim() !== "" && !CONDITION_LINE.test(line.trim()) && !/^addr:\s+IO\s+ROM\s+RAM$/.test(line.trim())) unknown.push(line.trim());
288
290
  continue;
289
291
  }
290
292
  const address = Number.parseInt(match[1]!, 16);
@@ -308,12 +310,19 @@ export function parseMemmap(answer: string, maxRanges: number): { ranges: Memmap
308
310
  */
309
311
  const STOP_LINE = /^(?:#\d+ \((?:Stop on|Trace)\s|\.(?:C|\d+):[0-9a-f]{4}\s)/i;
310
312
 
311
- /** The lines of a text command's answer that are not blank, not a stop line and not `known`. */
313
+ /**
314
+ * The line VICE prints on the text monitor when the binary monitor sets a
315
+ * checkpoint condition. It can come late, in the answer of the next text
316
+ * command (seen on Windows).
317
+ */
318
+ const CONDITION_LINE = /^Setting checkpoint \d+ condition to: /;
319
+
320
+ /** The lines of a text command's answer that are not blank, not a stop line, not a condition line and not `known`. */
312
321
  function otherLines(answer: string, known?: RegExp): string[] {
313
322
  return answer
314
323
  .split("\n")
315
324
  .map((line) => line.trim())
316
- .filter((line) => line !== "" && !STOP_LINE.test(line) && known?.test(line) !== true);
325
+ .filter((line) => line !== "" && !STOP_LINE.test(line) && !CONDITION_LINE.test(line) && known?.test(line) !== true);
317
326
  }
318
327
 
319
328
  /** Refuses the answer of a text command that prints nothing, or only `known` lines, when it works. */
@@ -423,6 +432,19 @@ export class ViceAdapter {
423
432
  return bytes.toString("hex");
424
433
  }
425
434
 
435
+ /**
436
+ * The timer latches and the interrupt mask of the CIA at `base`, from VICE's
437
+ * text-monitor dump: no register read gives them (D63).
438
+ */
439
+ async ciaHidden(base: 0xdc00 | 0xdd00): Promise<CiaHidden> {
440
+ const answer = await this.#computerText(`io ${hex4(base)}`);
441
+ const mask = /\(mask: ([0-9a-f]{2})\)/i.exec(answer);
442
+ const latchA = /Timer A: [0-9a-f]{4} \(latched ([0-9a-f]{4})\)/i.exec(answer);
443
+ const latchB = /Timer B: [0-9a-f]{4} \(latched ([0-9a-f]{4})\)/i.exec(answer);
444
+ if (mask === null || latchA === null || latchB === null) throw unknownForm("CIA dump", answer.split("\n"));
445
+ return { latchA: Number.parseInt(latchA[1]!, 16), latchB: Number.parseInt(latchB[1]!, 16), interruptMask: Number.parseInt(mask[1]!, 16) & 0x1f };
446
+ }
447
+
426
448
  /** Reads C64 I/O registers through the I/O bank, whatever the CPU has banked in, without side effects. */
427
449
  async readIo(start: number, size: number): Promise<Uint8Array> {
428
450
  const response = await this.#monitor.request(
@@ -631,6 +653,12 @@ export class ViceAdapter {
631
653
  await this.#monitor.request(Command.autostart, Buffer.concat([head, name]));
632
654
  }
633
655
 
656
+ /** Detaches every cartridge. VICE powers the cartridge off; the C64 keeps running. */
657
+ async detachCartridge(): Promise<void> {
658
+ // Device 32 is the cartridge port; the monitor reads numbers as hex. VICE prints nothing when it detaches.
659
+ expectQuiet("cartridge detach answer", await this.#text.command("detach 20"));
660
+ }
661
+
634
662
  /** Attaches a disk image file from the host file system to drive 8. */
635
663
  async attachDisk(file: string): Promise<void> {
636
664
  const answer = await this.#text.command(`attach ${quoted(file)} 8`);
@@ -57,8 +57,15 @@ function bcd(value: number): number {
57
57
  return (value >> 4) * 10 + (value & 0x0f);
58
58
  }
59
59
 
60
+ /** The parts of a CIA that no register read gives: the timer latches and the interrupt mask. */
61
+ export interface CiaHidden {
62
+ latchA: number;
63
+ latchB: number;
64
+ interruptMask: number;
65
+ }
66
+
60
67
  /** `registers` holds $dc00-$dc0f or $dd00-$dd0f. TOD hours become 0-23. */
61
- export function decodeCia(id: 1 | 2, registers: Uint8Array): CiaState {
68
+ export function decodeCia(id: 1 | 2, registers: Uint8Array, hidden: CiaHidden): CiaState {
62
69
  const word = (low: number) => registers[low]! | (registers[low + 1]! << 8);
63
70
  const hourRegister = registers[0x0b]!;
64
71
  const pm = (hourRegister & 0x80) !== 0;
@@ -70,9 +77,12 @@ export function decodeCia(id: 1 | 2, registers: Uint8Array): CiaState {
70
77
  ddrB: registers[0x03]!,
71
78
  timerA: word(0x04),
72
79
  timerB: word(0x06),
80
+ latchA: hidden.latchA,
81
+ latchB: hidden.latchB,
73
82
  controlA: registers[0x0e]!,
74
83
  controlB: registers[0x0f]!,
75
84
  interruptStatus: registers[0x0d]!,
85
+ interruptMask: hidden.interruptMask,
76
86
  tod: {
77
87
  hours: (bcd(hourRegister & 0x1f) % 12) + (pm ? 12 : 0),
78
88
  minutes: bcd(registers[0x0a]! & 0x7f),
@@ -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",
@@ -4,6 +4,7 @@
4
4
  import { crc32, deflateSync } from "node:zlib";
5
5
 
6
6
  import type { VideoStandard } from "../../protocol/messages.ts";
7
+ import { FRAME_SIZE } from "../../protocol/vice.ts";
7
8
 
8
9
  export interface IndexedFrame {
9
10
  width: number;
@@ -44,8 +45,8 @@ export function decodeDisplay(body: Buffer): RawDisplay {
44
45
  * offsets: PAL 384×272, NTSC 384×247.
45
46
  */
46
47
  const VISIBLE: Record<VideoStandard, { dx: number; dy: number; width: number; height: number }> = {
47
- pal: { dx: -32, dy: -36, width: 384, height: 272 },
48
- ntsc: { dx: -32, dy: -23, width: 384, height: 247 },
48
+ pal: { dx: -32, dy: -36, ...FRAME_SIZE.pal },
49
+ ntsc: { dx: -32, dy: -23, ...FRAME_SIZE.ntsc },
49
50
  };
50
51
 
51
52
  /** Crops the visible bordered frame out of VICE's full raster buffer. */
@@ -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,27 @@ 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
+ }
384
+ // Port 1 shares the keyboard lines: a held direction types keys into the RUN that the autostart types (R3-05).
385
+ const centred: Array<1 | 2> = [];
386
+ for (const [port, joystick] of this.#joysticks) {
387
+ if (joystick.direction === "center" && !joystick.fire) continue;
388
+ const centre: JoystickState = { port, direction: "center", fire: false };
389
+ await this.#observe(() => this.#machine.setJoystick(centre));
390
+ this.#joysticks.set(port, centre);
391
+ centred.push(port);
392
+ }
362
393
  await this.#runningCommand(() => this.#machine.autostart(file, index, run));
363
- this.#replaceStaged("autostart", file);
364
- return { state: "running" };
394
+ if (type === "crt") this.#cartridge = true;
395
+ this.#replaceStaged(stagedKind(type), file);
396
+ // A reset sets the CPU clock back, so the stopwatch starts again.
397
+ this.#timingStart = 0n;
398
+ return { state: "running", ...(type === "tap" && this.#inputFlaw !== undefined ? { emulatorFlaw: this.#inputFlaw } : {}), ...(centred.length === 0 ? {} : { joysticksCentred: centred }) };
365
399
  }
366
400
  case "diskAttach": {
367
401
  const { type } = params as ViceOperations["diskAttach"]["params"];
@@ -473,7 +507,7 @@ export class ViceSession implements ViceSessionHandle {
473
507
  async #status(): Promise<MachineStatus> {
474
508
  const warp = await this.#observe(() => this.#machine.warp());
475
509
  // Read after #observe: it resumes a running machine, unless a breakpoint or a JAM stopped it meanwhile.
476
- const status: MachineStatus = { state: this.#state, videoStandard: this.#videoStandard, warp, window: this.#mode === "window" };
510
+ const status: MachineStatus = { state: this.#state, videoStandard: this.#videoStandard, warp, window: this.#mode === "window", cartridge: this.#cartridge };
477
511
  if (status.state === "stopped") status.pc = await this.#pc();
478
512
  return status;
479
513
  }
@@ -500,7 +534,11 @@ export class ViceSession implements ViceSessionHandle {
500
534
  }
501
535
  if (params.action === "advance-frames") {
502
536
  if (params.count === undefined) throw new WireFailure("invalid-input", "advance-frames needs count, the number of frames");
503
- const outcome = await this.#runFrames(params.count);
537
+ const raster = RASTER[this.#videoStandard];
538
+ if (params.anchor !== undefined && (params.anchor.line >= raster.lines || params.anchor.cycle >= raster.cycles)) {
539
+ throw new WireFailure("invalid-input", `The anchor is outside the frame: a ${this.#videoStandard} frame has lines 0-${raster.lines - 1} and cycles 0-${raster.cycles - 1}.`);
540
+ }
541
+ const outcome = await this.#runFrames(params.count, undefined, params.anchor);
504
542
  return { state: "stopped", pc: outcome.pc, advancedFrames: outcome.frames };
505
543
  }
506
544
  await this.#stop();
@@ -531,6 +569,8 @@ export class ViceSession implements ViceSessionHandle {
531
569
 
532
570
  async #reset(params: { mode: "soft" | "hard"; run: boolean }): Promise<{ state: RunState; pc?: number }> {
533
571
  await this.#stop();
572
+ // A reset sets the CPU clock back, so the stopwatch starts again.
573
+ this.#timingStart = 0n;
534
574
  if (params.run) {
535
575
  await this.#runningCommand(() => this.#machine.reset(params.mode));
536
576
  return { state: "running" };
@@ -560,10 +600,11 @@ export class ViceSession implements ViceSessionHandle {
560
600
  * checkpoint, a user breakpoint or watchpoint, or a JAM ends the run early.
561
601
  * The machine is stopped afterwards.
562
602
  */
563
- async #runFrames(limit: number, target?: { checkpoint: number; deferred: boolean }): Promise<{ end: RunEnd; frames: number; pc: number; space: "c64" | "drive8" }> {
603
+ async #runFrames(limit: number, target?: { checkpoint: number; deferred: boolean }, anchor?: { line: number; cycle: number }): Promise<{ end: RunEnd; frames: number; pc: number; space: "c64" | "drive8" }> {
564
604
  await this.#stop();
565
605
  const lines = RASTER[this.#videoStandard].lines;
566
- const start = await this.#machine.rasterPosition();
606
+ // A frame ends where the raster passes the anchor; without one, where this run starts.
607
+ const start = anchor ?? (await this.#machine.rasterPosition());
567
608
  const arming = rasterLinesExpression((start.line + Math.floor(lines / 2)) % lines, lines);
568
609
  const ending = rasterWindowExpression(start.line, start.cycle, lines);
569
610
  const clock = await this.#machine.addCheckpoint({ start: 0x0000, end: 0xffff, operation: 0x04, space: "c64" });
@@ -817,14 +858,21 @@ export class ViceSession implements ViceSessionHandle {
817
858
  }
818
859
 
819
860
  /**
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.
861
+ * Records the staged file that VICE took for `kind`, or that it no longer
862
+ * has, and removes the file it replaces: VICE does not read that one again.
863
+ * A file that VICE did not take stays until the session closes, because a
864
+ * held VICE can still read it. So does a file that Windows does not let go.
823
865
  */
824
- #replaceStaged(kind: "autostart" | "disk", file: string): void {
866
+ #replaceStaged(kind: StagedKind, file: string | undefined): void {
825
867
  const previous = this.#stagedFiles.get(kind);
826
- this.#stagedFiles.set(kind, file);
827
- if (previous !== undefined) rmSync(previous, { force: true });
868
+ if (file === undefined) this.#stagedFiles.delete(kind);
869
+ else this.#stagedFiles.set(kind, file);
870
+ if (previous === undefined || previous === file) return;
871
+ try {
872
+ rmSync(previous, { force: true });
873
+ } catch {
874
+ // The session directory goes when the session closes.
875
+ }
828
876
  }
829
877
 
830
878
  /** Loads a PRG without reset or start; finishes stopped. */
@@ -885,7 +933,10 @@ export class ViceSession implements ViceSessionHandle {
885
933
  async #cias(which: CiaSelection): Promise<CiaState[]> {
886
934
  const ids: Array<1 | 2> = which === "both" ? [1, 2] : [Number(which) as 1 | 2];
887
935
  const chips = [];
888
- for (const id of ids) chips.push(decodeCia(id, await this.#machine.readIo(id === 1 ? 0xdc00 : 0xdd00, 0x10)));
936
+ for (const id of ids) {
937
+ const base = id === 1 ? 0xdc00 : 0xdd00;
938
+ chips.push(decodeCia(id, await this.#machine.readIo(base, 0x10), await this.#machine.ciaHidden(base)));
939
+ }
889
940
  return chips;
890
941
  }
891
942
 
@@ -1074,10 +1125,12 @@ export class ViceSession implements ViceSessionHandle {
1074
1125
  if (baseline === undefined) throw new WireFailure("not-found", `There is no baseline named ${params.baseline}.`);
1075
1126
  const current = await this.#observe(() => this.#machine.captureFrame(this.#videoStandard));
1076
1127
  const difference = compareFrames(baseline.frame, current.frame, params.mask);
1077
- const mismatchRatio = difference.comparedPixels === 0 ? 0 : difference.mismatchingPixels / difference.comparedPixels;
1128
+ if (difference.comparedPixels === 0) throw new WireFailure("invalid-input", "The mask covers the whole screen: nothing to compare.");
1129
+ const mismatchRatio = difference.mismatchingPixels / difference.comparedPixels;
1078
1130
  const result: ScreenComparison = {
1079
1131
  match: mismatchRatio <= params.maxMismatchRatio,
1080
1132
  mismatchingPixels: difference.mismatchingPixels,
1133
+ comparedPixels: difference.comparedPixels,
1081
1134
  mismatchRatio,
1082
1135
  };
1083
1136
  if (difference.bounds !== undefined) result.bounds = difference.bounds;
@@ -36,10 +36,20 @@ export const RUN_LIMIT_FRAMES = 300;
36
36
  */
37
37
  export async function runProgram(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<"not-loaded" | "not-started" | undefined> {
38
38
  if (!(await autostartLoaded(client, path, prg))) return "not-loaded";
39
+ return (await typeRun(client)) ? undefined : "not-started";
40
+ }
41
+
42
+ /**
43
+ * Types RUN at the READY prompt and stops where BASIC starts the first line,
44
+ * before its SYS. Every tool that starts a loaded program this way reaches
45
+ * its entry at the same raster line and timer values (D62). False when BASIC
46
+ * runs no line within RUN_LIMIT_FRAMES.
47
+ */
48
+ export async function typeRun(client: ViceSessionClient): Promise<boolean> {
39
49
  await client.keyboard(textToPetscii("RUN\n"));
40
50
  const started = await client.runUntil({
41
51
  target: { kind: "memory", address: CURLIN_HIGH, operator: "ne", value: 0xff, space: "c64", view: "ram" },
42
52
  timeoutFrames: RUN_LIMIT_FRAMES,
43
53
  });
44
- return started.reached ? undefined : "not-started";
54
+ return started.reached;
45
55
  }
@@ -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,12 +78,12 @@ 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. */
87
87
  export { WireFailure } from "../protocol/messages.ts";
88
88
  export { DISK_ACTIONS } from "../protocol/tools.ts";
89
- export { JOYSTICK_DIRECTIONS, MAX_BASELINES, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES } from "../protocol/vice.ts";
89
+ export { JOYSTICK_DIRECTIONS, MAX_BASELINES, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES, maskCoversFrame } from "../protocol/vice.ts";
@@ -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
 
@@ -41,7 +41,8 @@ export const c64Breakpoint = defineTool({
41
41
  description:
42
42
  "Manage breakpoints. A breakpoint stops the CPU when it is about to execute the instruction at an address. " +
43
43
  "Action add sets one at address in space c64 or drive8. With a condition, it stops only when the condition is true. " +
44
- "add returns the new id. Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all breakpoints. Each breakpoint shows its condition, when it has one. " +
44
+ "add returns the new id. Breakpoints and watchpoints take their ids from one counter, so the ids of one kind can have gaps. " +
45
+ "Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all breakpoints. Each breakpoint shows its condition, when it has one. " +
45
46
  "Breakpoints stay through a reset.",
46
47
  inputSchema: z
47
48
  .object({
@@ -84,7 +85,8 @@ export const c64Watchpoint = defineTool({
84
85
  "Manage watchpoints. A watchpoint stops the CPU when an instruction reads or writes memory in a range. " +
85
86
  `Action add sets one at address for size bytes (1 to ${MAX_WATCH_SIZE}, default 1), with access read, write or read-write. ` +
86
87
  "With a condition, it stops only when the condition is true. For a write, a memory condition sees the memory after the write. " +
87
- "add returns the new id. Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all watchpoints. Each watchpoint shows its condition, when it has one.",
88
+ "add returns the new id. Breakpoints and watchpoints take their ids from one counter, so the ids of one kind can have gaps. " +
89
+ "Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all watchpoints. Each watchpoint shows its condition, when it has one.",
88
90
  inputSchema: z
89
91
  .object({
90
92
  action: z.enum(CHECKPOINT_ACTIONS),
@@ -129,7 +131,7 @@ export const c64CpuHistory = defineTool({
129
131
  description:
130
132
  "Get the last instructions that the CPU executed, oldest first. " +
131
133
  `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. " +
134
+ "Each entry gives the address, the bytes, the instruction and the registers a, x, y and sp before the instruction executed. " +
133
135
  "In space c64, it also gives the raster line and cycle when the instruction started. " +
134
136
  "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
137
  "This does not change the run state of the machine.",
@@ -191,7 +193,8 @@ export const c64Timing = defineTool({
191
193
  title: "Cycle stopwatch",
192
194
  description:
193
195
  "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.",
196
+ "or since the session started, as a decimal string. A reset and c64_autostart start it again from the reset. " +
197
+ "A PAL frame has 19656 cycles and an NTSC frame 17095.",
195
198
  inputSchema: z.object({ action: z.enum(TIMING_ACTIONS) }).strict(),
196
199
  outputSchema: z.object({ started: z.boolean().optional(), cycles: z.string().regex(/^\d+$/).optional() }),
197
200
  readOnly: false,
@@ -204,7 +207,8 @@ export const c64Profile = defineTool({
204
207
  name: "c64_profile",
205
208
  title: "Cycle profile",
206
209
  description:
207
- "Get the routines where the C64 CPU spent the most cycles since the session started. " +
210
+ "Get the routines where the C64 CPU spent the most cycles since the session started or since the last c64_window. " +
211
+ "After c64_window or a snapshot restore, the emulator can give the cycles to a routine that ran before the restore, for example $fd50. Do not trust the routine addresses then. " +
208
212
  `limit is the maximum number of routines (1 to ${MAX_PROFILE}, default 20). ` +
209
213
  "Each entry gives the routine address, its total cycles with the routines it calls, and its own cycles. " +
210
214
  "percent is the part of all cycles that the routine used itself. " +