@henols/c64-re-tools 2.1.0 → 2.1.2

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 (73) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +7 -3
  3. package/skills/c64-assembler/scripts/assembler.ts +29 -11
  4. package/skills/c64-assembler/scripts/lib/compare.ts +4 -3
  5. package/skills/c64-basic/SKILL.md +1 -0
  6. package/skills/c64-disk/SKILL.md +3 -2
  7. package/skills/c64-disk/scripts/disk.ts +26 -12
  8. package/skills/c64-disk/scripts/lib/containers.ts +10 -11
  9. package/skills/c64-docs/SKILL.md +2 -1
  10. package/skills/c64-docs/scripts/docs.ts +1 -1
  11. package/skills/c64-docs/scripts/lib/links.ts +5 -3
  12. package/skills/c64-docs/scripts/lib/reference.ts +2 -2
  13. package/skills/c64-emulator/SKILL.md +9 -2
  14. package/skills/c64-knowledge/SKILL.md +9 -5
  15. package/skills/c64-knowledge/scripts/knowledge.ts +91 -53
  16. package/skills/c64-knowledge/scripts/lib/listing.ts +8 -30
  17. package/skills/c64-memory-map/SKILL.md +2 -1
  18. package/skills/c64-memory-map/scripts/memory-map.ts +4 -3
  19. package/skills/c64-provenance/SKILL.md +6 -4
  20. package/skills/c64-provenance/scripts/lib/library.ts +18 -6
  21. package/skills/c64-provenance/scripts/lib/separate.ts +45 -6
  22. package/skills/c64-provenance/scripts/provenance.ts +14 -10
  23. package/skills/c64-source/SKILL.md +3 -0
  24. package/skills/c64-source/scripts/lib/source.ts +9 -31
  25. package/skills/c64-source/scripts/source.ts +14 -10
  26. package/skills/c64-static-analysis/SKILL.md +2 -0
  27. package/skills/c64-static-analysis/scripts/lib/idioms.ts +6 -2
  28. package/skills/c64-static-analysis/scripts/static-analysis.ts +18 -8
  29. package/skills/c64-testing/SKILL.md +14 -3
  30. package/skills/c64-testing/references/recording-format.md +2 -0
  31. package/skills/c64-testing/references/scenario-format.md +6 -0
  32. package/skills/c64-testing/scripts/lib/diff-files.ts +76 -14
  33. package/skills/c64-testing/scripts/lib/diff.ts +3 -3
  34. package/skills/c64-testing/scripts/lib/recording.ts +50 -15
  35. package/skills/c64-testing/scripts/lib/replay.ts +12 -1
  36. package/skills/c64-testing/scripts/lib/runs.ts +84 -4
  37. package/skills/c64-testing/scripts/lib/scenario.ts +43 -6
  38. package/skills/c64-testing/scripts/testing.ts +20 -7
  39. package/skills/c64-unpacker/SKILL.md +17 -10
  40. package/skills/c64-unpacker/scripts/lib/evidence.ts +4 -5
  41. package/skills/c64-unpacker/scripts/lib/loads.ts +14 -7
  42. package/skills/c64-unpacker/scripts/lib/machine.ts +30 -3
  43. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  44. package/skills/c64-unpacker/scripts/unpacker.ts +93 -42
  45. package/src/host/vice/adapter.ts +4 -1
  46. package/src/host/vice/session.ts +148 -13
  47. package/src/host-client/image.ts +40 -8
  48. package/src/host-client/steps.ts +145 -9
  49. package/src/host-client/tools.ts +1 -1
  50. package/src/host-client/transfer.ts +33 -18
  51. package/src/knowledge/expression.ts +1 -1
  52. package/src/knowledge/history.ts +74 -4
  53. package/src/knowledge/import.ts +79 -7
  54. package/src/knowledge/names.ts +2 -1
  55. package/src/knowledge/program.ts +32 -18
  56. package/src/knowledge/read.ts +46 -0
  57. package/src/knowledge/schema.ts +4 -2
  58. package/src/knowledge/text.ts +2 -0
  59. package/src/knowledge/write.ts +58 -16
  60. package/src/mcp/inputs.ts +6 -3
  61. package/src/mcp/output.ts +5 -0
  62. package/src/mcp/server.ts +20 -4
  63. package/src/mcp/tools/debug.ts +36 -9
  64. package/src/mcp/tools/execution.ts +18 -5
  65. package/src/mcp/tools/machine.ts +3 -1
  66. package/src/mcp/tools/media.ts +14 -2
  67. package/src/mcp/tools/memory.ts +11 -6
  68. package/src/mcp/tools/video.ts +5 -3
  69. package/src/media.ts +42 -0
  70. package/src/opcodes.ts +51 -18
  71. package/src/project.ts +111 -3
  72. package/src/protocol/vice.ts +8 -2
  73. package/src/script.ts +55 -3
@@ -30,6 +30,8 @@ import {
30
30
  import { decodeProgramCounter, MonitorConnectionError, MonitorError, MonitorTimeoutError, ResponseType, type MonitorResponse } from "./binary-monitor.ts";
31
31
  import { decodeCia, decodeSid, decodeSprite, decodeVicii } from "./chips.ts";
32
32
  import { compareFrames, differenceImage } from "./screen.ts";
33
+ import { isRefusal } from "../../errors.ts";
34
+ import { checkMedia } from "../../media.ts";
33
35
  import { launchVice, startErrorLines, type ViceMode, type ViceProcess } from "./process.ts";
34
36
  import { TextMonitorError, TextMonitorTimeoutError } from "./text-monitor.ts";
35
37
 
@@ -187,6 +189,23 @@ export class ViceSession implements ViceSessionHandle {
187
189
  #haltCount = 0;
188
190
  /** What stopped the machine last, when a user breakpoint or watchpoint or a JAM did; cleared when it runs again. */
189
191
  #lastStop: { stopReason: PointStopReason; pointId?: number } | undefined;
192
+ /**
193
+ * The pc of the JAM that holds the CPU. A jammed CPU runs again only after a
194
+ * reset, a snapshot restore or an autostart: VICE 3.10 keeps it jammed after a
195
+ * pc write, and reports the JAM only once.
196
+ */
197
+ #jammed: number | undefined;
198
+ /**
199
+ * An autostart can still be loading: VICE goes on with it after a snapshot
200
+ * restore and writes the program over the restored memory. Only a reset stops it.
201
+ */
202
+ #autostarting = false;
203
+ /** The C64 clock just after the last snapshot restore: VICE keeps the history of before it. */
204
+ #historySince: bigint | undefined;
205
+ /** VICE records no CPU history after a JAM until the next reset. */
206
+ #historyStopped = false;
207
+ /** The CPU ran again after a JAM without a reset: the CPU history that VICE gives is old. */
208
+ #historyOld = false;
190
209
  #queue: Promise<unknown> = Promise.resolve();
191
210
  #closed = false;
192
211
  #lost = false;
@@ -386,6 +405,7 @@ export class ViceSession implements ViceSessionHandle {
386
405
  case "autostart": {
387
406
  const { type, index, run } = params as ViceOperations["autostart"]["params"];
388
407
  const bytes = onlyAttachment(attachments);
408
+ checkMedia(type, bytes);
389
409
  if (index > 0 && this.#directory !== undefined && isOneOf(DISK_IMAGE_TYPES, type)) checkDirectoryEntry(await this.#directory(type, bytes), index);
390
410
  const file = this.#stage(bytes, type);
391
411
  // VICE keeps a cartridge through every reset and autostart, as a real C64 does.
@@ -404,6 +424,8 @@ export class ViceSession implements ViceSessionHandle {
404
424
  centred.push(port);
405
425
  }
406
426
  await this.#runningCommand(() => this.#machine.autostart(file, index, run));
427
+ this.#clearJam();
428
+ this.#autostarting = true;
407
429
  if (type === "crt") this.#cartridge = true;
408
430
  this.#replaceStaged(stagedKind(type), file);
409
431
  // A reset sets the CPU clock back, so the stopwatch starts again.
@@ -412,7 +434,9 @@ export class ViceSession implements ViceSessionHandle {
412
434
  }
413
435
  case "diskAttach": {
414
436
  const { type } = params as ViceOperations["diskAttach"]["params"];
415
- const file = this.#stage(onlyAttachment(attachments), type);
437
+ const bytes = onlyAttachment(attachments);
438
+ checkMedia(type, bytes);
439
+ const file = this.#stage(bytes, type);
416
440
  await this.#observe(() => this.#machine.attachDisk(file));
417
441
  this.#replaceStaged("disk", file);
418
442
  return { attached: true };
@@ -431,7 +455,7 @@ export class ViceSession implements ViceSessionHandle {
431
455
  case "screenCapture": {
432
456
  const { baseline } = params as ViceOperations["screenCapture"]["params"];
433
457
  if (baseline !== undefined && !this.#baselines.has(baseline) && this.#baselines.size >= MAX_BASELINES) {
434
- throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_BASELINES} baselines. Discard one first.`);
458
+ throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_BASELINES} baselines. Remove one first with c64_screen action remove.`);
435
459
  }
436
460
  const shot = await this.#observe(() => this.#machine.captureFrame(this.#videoStandard));
437
461
  if (baseline !== undefined) this.#baselines.set(baseline, shot);
@@ -530,15 +554,25 @@ export class ViceSession implements ViceSessionHandle {
530
554
  if (status.state === "stopped") {
531
555
  status.pc = await this.#pc();
532
556
  // VICE 3.9 sends no JAM event: the opcode at the pc tells.
533
- const stop = this.#lastStop ?? ((await this.#jammedAt(status.pc)) ? { stopReason: "jam" as const } : undefined);
557
+ const stop = this.#lastStop ?? ((await this.#jamAt(status.pc)) ? { stopReason: "jam" as const } : undefined);
534
558
  if (stop !== undefined) Object.assign(status, stop);
559
+ // A point in the drive stopped it: the pc is the drive's, as advance-frames and run-until give it.
560
+ const point = status.pointId === undefined ? undefined : (status.stopReason === "watchpoint" ? this.#watchpoints : this.#breakpoints).get(status.pointId);
561
+ status.space = point?.space === "drive8" ? "drive8" : "c64";
562
+ if (status.space === "drive8") status.pc = (await this.#machine.readRegisters("drive8")).pc;
535
563
  }
536
564
  return status;
537
565
  }
538
566
 
539
567
  async #execution(params: ExecutionParams): Promise<ExecutionResult> {
568
+ if (params.space === "drive8" && (params.action === "pause" || params.action === "resume")) {
569
+ throw new WireFailure("unsupported-in-space", `${params.action} stops or runs the whole machine, the computer and the disk drive together: use space c64.`);
570
+ }
540
571
  if (params.action === "resume") {
541
- if (this.#state === "stopped") await this.#resume();
572
+ if (this.#state === "stopped") {
573
+ await this.#refuseJammed("resume");
574
+ await this.#resume();
575
+ }
542
576
  return { state: "running" };
543
577
  }
544
578
  if (params.action === "pause") {
@@ -546,6 +580,9 @@ export class ViceSession implements ViceSessionHandle {
546
580
  // A drive checkpoint can stop the machine just before the pause does.
547
581
  return { state: "stopped", pc: (await this.#completeDriveStop()) ?? (await this.#pc()) };
548
582
  }
583
+ if (params.space === "drive8" && params.action === "advance-frames") {
584
+ throw new WireFailure("unsupported-in-space", "advance-frames counts the video frames of the computer: use space c64. A breakpoint or watchpoint in space drive8 also stops it.");
585
+ }
549
586
  if (params.space === "drive8") {
550
587
  // Stock VICE runs the 1541 CPU in batches that catch up with the computer's clock;
551
588
  // neither monitor can stop it after exactly one of its instructions.
@@ -565,13 +602,16 @@ export class ViceSession implements ViceSessionHandle {
565
602
  const outcome = await this.#runFrames(params.count, undefined, params.anchor);
566
603
  return {
567
604
  state: "stopped",
568
- pc: outcome.pc,
605
+ // A stop event carries the computer's PC; a stop in the drive reports the drive's.
606
+ pc: outcome.space === "drive8" ? (await this.#machine.readRegisters("drive8")).pc : outcome.pc,
607
+ space: outcome.space,
569
608
  advancedFrames: outcome.frames,
570
609
  stopReason: outcome.end === "target" ? "frames" : outcome.end,
571
610
  ...(outcome.pointId === undefined ? {} : { pointId: outcome.pointId }),
572
611
  };
573
612
  }
574
613
  await this.#stop();
614
+ await this.#refuseJammed(params.action);
575
615
  if (params.action === "until-return") {
576
616
  const stopped = this.#nextStop(this.#untilReturnLimitMs);
577
617
  await this.#resumingCommand(() => this.#machine.untilReturn());
@@ -599,6 +639,7 @@ export class ViceSession implements ViceSessionHandle {
599
639
 
600
640
  async #reset(params: { mode: "soft" | "hard"; run: boolean }): Promise<{ state: RunState; pc?: number }> {
601
641
  await this.#stop();
642
+ this.#clearJam();
602
643
  // A reset sets the CPU clock back, so the stopwatch starts again.
603
644
  this.#timingStart = 0n;
604
645
  if (params.run) {
@@ -637,11 +678,15 @@ export class ViceSession implements ViceSessionHandle {
637
678
  const start = anchor ?? (await this.#machine.rasterPosition());
638
679
  const arming = rasterLinesExpression((start.line + Math.floor(lines / 2)) % lines, lines);
639
680
  const ending = rasterWindowExpression(start.line, start.cycle, lines);
681
+ const startPc = await this.#pc();
682
+ if (await this.#jamAt(startPc)) return { end: "jam", frames: 0, pc: startPc, space: "c64" };
640
683
  const clock = await this.#machine.addCheckpoint({ start: 0x0000, end: 0xffff, operation: 0x04, space: "c64" });
641
684
  try {
642
685
  await this.#machine.setCondition(clock, arming);
643
686
  let armingPhase = true;
644
687
  let frames = 0;
688
+ // VICE reports a JAM only once until a reset: a jammed CPU stops the clock at the same pc again and again.
689
+ let clockPc = startPc;
645
690
  for (;;) {
646
691
  const next = this.#nextStop(HALF_FRAME_LIMIT_MS);
647
692
  await this.#resume();
@@ -652,6 +697,8 @@ export class ViceSession implements ViceSessionHandle {
652
697
  }
653
698
  const end = this.#stopCause(stop, clock, target?.checkpoint);
654
699
  if (end === "clock") {
700
+ if (stop.pc === clockPc && (await this.#jamAt(stop.pc))) return { end: "jam", frames, pc: stop.pc, space: "c64" };
701
+ clockPc = stop.pc;
655
702
  if (armingPhase) {
656
703
  await this.#machine.setCondition(clock, ending);
657
704
  if (target?.deferred) {
@@ -667,7 +714,7 @@ export class ViceSession implements ViceSessionHandle {
667
714
  continue;
668
715
  }
669
716
  if (end === "unknown") {
670
- if (await this.#jammedAt(stop.pc)) return { end: "jam", frames, pc: stop.pc, space: "c64" };
717
+ if (await this.#jamAt(stop.pc)) return { end: "jam", frames, pc: stop.pc, space: "c64" };
671
718
  throw new WireFailure("operation-failed", "The emulator stopped the machine for a reason outside this run.");
672
719
  }
673
720
  // A drive stop is completed before the result names the computer's pc; `space` says which CPU stopped.
@@ -699,6 +746,9 @@ export class ViceSession implements ViceSessionHandle {
699
746
  const points: number[] = [];
700
747
  const hits: TraceHit[] = [];
701
748
  let frames = 0;
749
+ const startPc = await this.#pc();
750
+ if (await this.#jamAt(startPc)) return { hits, frames, end: "jam", pc: startPc };
751
+ let clockPc = startPc;
702
752
  const clock = await this.#machine.addCheckpoint({ start: 0x0000, end: 0xffff, operation: 0x04, space: "c64" });
703
753
  try {
704
754
  for (const point of params.points) points.push(await this.#machine.addCheckpoint({ start: point.start, end: point.end, operation: operation[point.kind], space: "c64" }));
@@ -718,6 +768,8 @@ export class ViceSession implements ViceSessionHandle {
718
768
  if (hits.length >= params.maxHits) return { hits, frames, end: "hits", pc: stop.pc };
719
769
  }
720
770
  if (stop.hits.includes(clock)) {
771
+ if (stop.pc === clockPc && (await this.#jamAt(stop.pc))) return { hits, frames, end: "jam", pc: stop.pc };
772
+ clockPc = stop.pc;
721
773
  if (armingPhase) await this.#machine.setCondition(clock, ending);
722
774
  else {
723
775
  frames++;
@@ -730,7 +782,7 @@ export class ViceSession implements ViceSessionHandle {
730
782
  if (traced.length > 0) continue;
731
783
  const point = this.#userPoint(stop.hits);
732
784
  if (point !== undefined) return { hits, frames, end: point.stopReason, pc: stop.pc };
733
- if (await this.#jammedAt(stop.pc)) return { hits, frames, end: "jam", pc: stop.pc };
785
+ if (await this.#jamAt(stop.pc)) return { hits, frames, end: "jam", pc: stop.pc };
734
786
  throw new WireFailure("operation-failed", "The emulator stopped the machine for a reason outside this trace.");
735
787
  }
736
788
  } finally {
@@ -844,9 +896,43 @@ export class ViceSession implements ViceSessionHandle {
844
896
  return undefined;
845
897
  }
846
898
 
847
- async #jammedAt(pc: number): Promise<boolean> {
899
+ /**
900
+ * True when a JAM holds the CPU at `pc`: the session saw a JAM since the last
901
+ * reset, restore or autostart, or the opcode at `pc` is a JAM. The CPU stops
902
+ * on such an opcode, so it executes nothing more. The session keeps the JAM.
903
+ */
904
+ async #jamAt(pc: number): Promise<boolean> {
905
+ if (this.#jammed !== undefined) return true;
848
906
  const opcode = await this.#machine.readMemory({ address: pc, size: 1, space: "c64", view: "cpu" });
849
- return JAM_OPCODES.has(Number.parseInt(opcode, 16));
907
+ if (!JAM_OPCODES.has(Number.parseInt(opcode, 16))) return false;
908
+ this.#jam(pc);
909
+ return true;
910
+ }
911
+
912
+ #jam(pc: number): void {
913
+ this.#jammed = pc;
914
+ this.#historyStopped = true;
915
+ }
916
+
917
+ /** A reset or an autostart starts a jammed CPU and the CPU history again. */
918
+ #clearJam(): void {
919
+ this.#autostarting = false;
920
+ // A reset sets the clock back to 0: the history from before it is of this run again.
921
+ this.#historySince = undefined;
922
+ this.#jammed = undefined;
923
+ this.#historyStopped = false;
924
+ this.#historyOld = false;
925
+ }
926
+
927
+ /** Refuses an action that needs the CPU to execute, while a JAM holds it. */
928
+ async #refuseJammed(action: string): Promise<void> {
929
+ const pc = await this.#pc();
930
+ if (!(await this.#jamAt(pc))) return;
931
+ throw new WireFailure(
932
+ "operation-failed",
933
+ `The CPU is jammed by the JAM opcode at ${formatC64Address(this.#jammed ?? pc)}, so ${action} cannot execute an instruction. ` +
934
+ "The CPU runs again only after c64_reset, a snapshot restore or c64_autostart.",
935
+ );
850
936
  }
851
937
 
852
938
  /** Runs until a target is reached or `timeoutFrames` frames pass. */
@@ -1050,10 +1136,18 @@ export class ViceSession implements ViceSessionHandle {
1050
1136
  * stop, whose position is known, mapped back through the frame.
1051
1137
  */
1052
1138
  async #cpuHistory(params: { limit: number; space: "c64" | "drive8" }): Promise<{ entries: HistoryEntry[] }> {
1139
+ if (params.space === "c64" && this.#historyOld) {
1140
+ throw new WireFailure(
1141
+ "operation-failed",
1142
+ "VICE records no CPU history after a JAM until the next reset, and the CPU ran after the JAM. The history would show old instructions. " +
1143
+ "Use c64_reset to record again, or use c64_trace.",
1144
+ );
1145
+ }
1053
1146
  return this.#observe(async () => {
1054
- const raw = await this.#machine.history(params.limit, params.space);
1055
- if (params.space === "drive8") return { entries: raw.map(({ clock: _clock, ...entry }) => entry) };
1147
+ const all = await this.#machine.history(params.limit, params.space);
1148
+ if (params.space === "drive8") return { entries: all.map(({ clock: _clock, ...entry }) => entry) };
1056
1149
  const now = await this.#machine.clock();
1150
+ const raw = entriesSince(all, this.#historySince, now);
1057
1151
  const position = await this.#machine.rasterPosition();
1058
1152
  const { lines, cycles } = RASTER[this.#videoStandard];
1059
1153
  const frame = BigInt(lines * cycles);
@@ -1128,7 +1222,7 @@ export class ViceSession implements ViceSessionHandle {
1128
1222
  const existing = this.#snapshots.get(params.name);
1129
1223
  if (params.action === "save") {
1130
1224
  if (existing === undefined && this.#snapshots.size >= MAX_SNAPSHOTS) {
1131
- throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_SNAPSHOTS} snapshots. Discard one first.`);
1225
+ throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_SNAPSHOTS} snapshots. Remove one first with action remove.`);
1132
1226
  }
1133
1227
  const file = existing ?? join(this.#workDir, `snapshot-${++this.#snapshotFiles}.vsf`);
1134
1228
  await this.#observe(() => this.#machine.saveSnapshot(file));
@@ -1148,7 +1242,13 @@ export class ViceSession implements ViceSessionHandle {
1148
1242
  async #restoreFrom(file: string): Promise<{ restored: true; state: "stopped" }> {
1149
1243
  await this.#stop();
1150
1244
  const elapsed = (await this.#machine.clock()) - this.#timingStart;
1245
+ // The restore replaces the whole machine state, so the reset that stops the autostart leaves no trace.
1246
+ if (this.#autostarting) await this.#reset({ mode: "soft", run: false });
1151
1247
  await this.#machine.restoreSnapshot(file);
1248
+ this.#historySince = await this.#machine.clock();
1249
+ // A restore starts a jammed CPU again, but VICE still records no CPU history.
1250
+ this.#jammed = undefined;
1251
+ if (this.#historyStopped) this.#historyOld = true;
1152
1252
  this.#state = "stopped";
1153
1253
  // The cycle clock jumped with the restore; the stopwatch keeps its reading, as a window move does.
1154
1254
  this.#timingStart = (await this.#machine.clock()) - elapsed;
@@ -1168,13 +1268,26 @@ export class ViceSession implements ViceSessionHandle {
1168
1268
  // Checked before the machine stops: VICE gives no reason for a file that is no snapshot.
1169
1269
  if (!bytes.subarray(0, VSF_MAGIC.length).equals(VSF_MAGIC)) throw new WireFailure("invalid-input", "The file is not a VICE snapshot file (.vsf). Give a file that c64_snapshot save wrote.");
1170
1270
  const file = this.#stage(bytes, "vsf");
1271
+ // VICE resets the machine after a snapshot file that it cannot read: a backup keeps the state before the restore.
1272
+ const backup = join(this.#workDir, "before-restore.vsf");
1273
+ await this.#stop();
1274
+ await this.#machine.saveSnapshot(backup);
1171
1275
  try {
1172
1276
  return await this.#restoreFrom(file);
1173
1277
  } catch (error) {
1278
+ const kept = await this.#restoreFrom(backup).then(
1279
+ () => true,
1280
+ () => false,
1281
+ );
1282
+ if (!kept) throw new WireFailure("machine-state-lost", "The emulator did not restore this snapshot file, and the machine state from before the restore is lost. Load the program again.");
1174
1283
  if (error instanceof WireFailure) throw error;
1175
- throw new WireFailure("invalid-input", "The emulator did not restore this snapshot file. A snapshot file restores only in the emulator version and with the machine settings that saved it.");
1284
+ throw new WireFailure(
1285
+ "invalid-input",
1286
+ "The emulator did not restore this snapshot file. The machine state is as before. A snapshot file restores only in the emulator version and with the machine settings that saved it.",
1287
+ );
1176
1288
  } finally {
1177
1289
  rmSync(file, { force: true });
1290
+ rmSync(backup, { force: true });
1178
1291
  }
1179
1292
  }
1180
1293
 
@@ -1255,6 +1368,8 @@ export class ViceSession implements ViceSessionHandle {
1255
1368
  if (this.#closed) throw new WireFailure("machine-unavailable", "The emulator session is closed.");
1256
1369
  this.#vice = vice;
1257
1370
  this.#adapter = adapter;
1371
+ // A new emulator: no JAM holds its CPU, and it records CPU history.
1372
+ this.#clearJam();
1258
1373
  this.#mode = mode;
1259
1374
  for (const [point, checkpoint] of points) point.checkpoint = checkpoint;
1260
1375
  this.#timingStart = clock - handover.elapsed;
@@ -1446,6 +1561,8 @@ export class ViceSession implements ViceSessionHandle {
1446
1561
 
1447
1562
  async #translate(error: unknown, runningBefore: boolean, halts: number): Promise<unknown> {
1448
1563
  if (error instanceof WireFailure) return error;
1564
+ // A refusal of a shared module (for example the media check) goes on the wire as the host's own.
1565
+ if (isRefusal(error)) return new WireFailure(error.code, error.message);
1449
1566
  if (this.#closed) return new WireFailure("machine-unavailable", "The emulator session is closed.");
1450
1567
  if (error instanceof MonitorConnectionError || error instanceof TextMonitorError || error instanceof StepTimeout) {
1451
1568
  this.#log(error.message);
@@ -1515,6 +1632,7 @@ export class ViceSession implements ViceSessionHandle {
1515
1632
  if (stop.hits.length > 0 || event.type === ResponseType.jam) this.#haltCount++;
1516
1633
  if (stop.hits.some((hit) => this.#adapter?.isDriveCheckpoint(hit))) this.#driveStop = true;
1517
1634
  this.#lastStop = event.type === ResponseType.jam ? { stopReason: "jam" } : this.#userPoint(stop.hits);
1635
+ if (event.type === ResponseType.jam) this.#jam(stop.pc);
1518
1636
  this.#checkpointHits = [];
1519
1637
  this.#state = "stopped";
1520
1638
  this.#stopWaiters = this.#stopWaiters.filter((waiter) => !waiter(stop));
@@ -1525,6 +1643,23 @@ export class ViceSession implements ViceSessionHandle {
1525
1643
  }
1526
1644
  }
1527
1645
 
1646
+ /**
1647
+ * The history entries (oldest first) that the CPU executed after a restore at
1648
+ * clock `since`, with the clock at `now`: the last entries, back to the first
1649
+ * one outside since to now or the first one whose clock does not go up to the
1650
+ * next. The others are of before the restore.
1651
+ */
1652
+ export function entriesSince<T extends { clock: bigint }>(entries: readonly T[], since: bigint | undefined, now: bigint): T[] {
1653
+ if (since === undefined) return [...entries];
1654
+ let first = entries.length;
1655
+ while (first > 0) {
1656
+ const entry = entries[first - 1]!;
1657
+ if (entry.clock < since || entry.clock > now || (first < entries.length && entry.clock >= entries[first]!.clock)) break;
1658
+ first--;
1659
+ }
1660
+ return entries.slice(first);
1661
+ }
1662
+
1528
1663
  /** The one attachment of an operation that takes a file (see attachmentCount); refuses any other count. */
1529
1664
  function onlyAttachment(attachments: Buffer[]): Buffer {
1530
1665
  const [bytes] = attachments;
@@ -21,18 +21,50 @@ export interface Image {
21
21
  stages?: Array<Record<string, unknown>>;
22
22
  }
23
23
 
24
- /** Reads an image.json or a PRG, relative to the project folder. */
24
+ /** The kinds of media files that are no PRG, by extension. */
25
+ const MEDIA_KINDS: Record<string, string> = {
26
+ d64: "a disk image",
27
+ d71: "a disk image",
28
+ d81: "a disk image",
29
+ g64: "a disk image",
30
+ x64: "a disk image",
31
+ t64: "a tape file",
32
+ tap: "a tape file",
33
+ crt: "a cartridge file",
34
+ };
35
+
36
+ /** The remedy for a disk, tape or cartridge file where a PRG must be: get the PRG out of it. */
37
+ export const MEDIA_REMEDY = "Get the PRG out of it with disk.ts read (or disk.ts crt for a cartridge) of the c64-disk skill";
38
+
39
+ /** Refuses a disk, tape or cartridge file by its extension (`type`, lowercase, without the dot): invalid-input with `remedy`. */
40
+ function refuseMedia(path: string, type: string, remedy: string): void {
41
+ const media = MEDIA_KINDS[type];
42
+ if (media !== undefined) throw new Refusal("invalid-input", `${path} is ${media} (.${type}), not a PRG. ${remedy}.`);
43
+ }
44
+
45
+ /**
46
+ * Reads a PRG of the project: a 2-byte load address, and bytes that end at
47
+ * $ffff or before. Refuses a disk, tape or cartridge file and any other file
48
+ * that is no PRG with invalid-input; `remedy` tells what to do in its place.
49
+ */
50
+ export function readPrg(path: string, remedy = MEDIA_REMEDY): { bytes: Buffer; start: number; end: number; body: Uint8Array } {
51
+ const { bytes, type } = readProjectFile(path);
52
+ refuseMedia(path, type, remedy);
53
+ try {
54
+ return { bytes, ...prgRange(bytes) };
55
+ } catch (error) {
56
+ if (isRefusal(error)) throw new Refusal("invalid-input", `${path} is not a PRG: ${error.message} ${remedy}.`);
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ /** Reads an image.json or a PRG, relative to the project folder. Refuses a disk, tape or cartridge file as readPrg does. */
25
62
  export function readImage(path: string): Image {
26
- const { bytes } = readProjectFile(path);
27
63
  if (!path.endsWith(".json")) {
28
- let range: ReturnType<typeof prgRange>;
29
- try {
30
- range = prgRange(bytes);
31
- } catch (error) {
32
- throw new Refusal("invalid-input", `${path}: ${(error as Error).message}`);
33
- }
64
+ const range = readPrg(path);
34
65
  return { segments: [{ start: range.start, end: range.end }], byteAt: (address) => range.body[address - range.start]! };
35
66
  }
67
+ const { bytes } = readProjectFile(path);
36
68
  const fail = (what: string): never => {
37
69
  throw new Refusal("invalid-input", `${path} is not an image.json of the c64-unpacker skill: ${what}`);
38
70
  };
@@ -63,6 +63,8 @@ export type ActionStep =
63
63
 
64
64
  export const ACTION_STEP_KINDS = ["write", "registers", "joystick", "type", "press", "frames", "runUntil"] as const;
65
65
  export type ActionStepKind = (typeof ACTION_STEP_KINDS)[number];
66
+ /** The step kinds that VICE runs: all but press, which only the replay core has. */
67
+ export const VICE_STEP_KINDS = ACTION_STEP_KINDS.filter((kind) => kind !== "press");
66
68
 
67
69
  /** The fields of each object of the steps: the same as the inputs of the MCP tools. */
68
70
  export const STEP_FIELDS = {
@@ -110,6 +112,32 @@ export function keyboardBytes(input: { mode: "text" | "petscii"; text?: string |
110
112
  }
111
113
  }
112
114
 
115
+ /**
116
+ * One line of JSON with a space after each colon and comma: the form that a
117
+ * refusal tells the caller to write.
118
+ */
119
+ export function jsonLine(value: unknown): string {
120
+ if (Array.isArray(value)) return `[${value.map(jsonLine).join(", ")}]`;
121
+ if (isObject(value)) return `{${Object.entries(value).filter(([, item]) => item !== undefined).map(([key, item]) => `${JSON.stringify(key)}: ${jsonLine(item)}`).join(", ")}}`;
122
+ return JSON.stringify(value) ?? "null";
123
+ }
124
+
125
+ /** Field names of the 2.0 files and what a 2.1 object has in their place, when it has that field. */
126
+ const RENAMED: Record<string, Array<{ field: string; write: string }>> = {
127
+ at: [
128
+ { field: "address", write: "address" },
129
+ { field: "start", write: "start" },
130
+ ],
131
+ bytes: [{ field: "data", write: 'data, the bytes as hex text, for example "data": "a900",' }],
132
+ equals: [{ field: "operator", write: '"operator": "eq" and "value"' }],
133
+ };
134
+
135
+ /** What to write in place of a field of the 2.0 files, for an object with these fields; undefined when the field has no 2.1 name there. */
136
+ export function renamedField(key: string, fields: readonly string[]): string | undefined {
137
+ const renamed = RENAMED[key]?.find((item) => fields.includes(item.field));
138
+ return renamed === undefined ? undefined : `${key} is a 2.0 field. Write ${renamed.write} in place of ${key}`;
139
+ }
140
+
113
141
  /** The checks of one input: `fail` names the field of the problem. */
114
142
  class Fields {
115
143
  readonly #value: Record<string, unknown>;
@@ -121,7 +149,11 @@ class Fields {
121
149
  this.#fail = fail;
122
150
  if (!isObject(value)) fail(`${where} must be an object with ${fields.join(", ")}`);
123
151
  this.#value = value as Record<string, unknown>;
124
- for (const key of Object.keys(this.#value)) if (!fields.includes(key)) fail(`${where} has the field ${key}, which it does not take. Its fields are ${fields.join(", ")}`);
152
+ for (const key of Object.keys(this.#value)) {
153
+ if (fields.includes(key)) continue;
154
+ const renamed = renamedField(key, fields);
155
+ fail(`${where} has the field ${key}, which it does not take. ${renamed === undefined ? "" : `${renamed}. `}Its fields are ${fields.join(", ")}`);
156
+ }
125
157
  }
126
158
 
127
159
  fail(field: string, message: string): never {
@@ -256,22 +288,90 @@ function checkInput(kind: ActionStepKind, value: unknown, at: string, fail: (mes
256
288
  }
257
289
  }
258
290
 
291
+ /** A "$xxxx" text of a number, or the value as it is. */
292
+ const asAddress = (value: unknown) => (isInteger(value, 0, 0xffff) ? `$${value.toString(16).padStart(4, "0")}` : value);
293
+
294
+ /** The hex text of a list of bytes, or the value as it is. */
295
+ const asHex = (value: unknown) => (Array.isArray(value) && value.every((byte) => isInteger(byte, 0, 0xff)) ? Buffer.from(value as number[]).toString("hex") : value);
296
+
297
+ /** A runUntil target from the 2.0 input: at (with raster), raster, or memory with at and equals. */
298
+ function targetOf(input: Record<string, unknown>): unknown {
299
+ if ("target" in input) return input.target;
300
+ if (isObject(input.memory)) return { kind: "memory", address: input.memory.at ?? input.memory.address, operator: "eq", value: input.memory.equals };
301
+ if ("at" in input) return { kind: "address", address: input.at, ...(input.raster === undefined ? {} : { condition: { kind: "raster", line: input.raster } }) };
302
+ return { kind: "raster", line: input.raster };
303
+ }
304
+
305
+ /**
306
+ * The 2.1 form of a step that has a form of the 2.0 files: the 2.0 fields
307
+ * that it has and the step to write. Undefined when the step has no 2.0 form.
308
+ * Keys of the step other than its kind (for example side) stay.
309
+ */
310
+ export function newStepForm(kind: string, step: Record<string, unknown>): { fields: string[]; step: Record<string, unknown> } | undefined {
311
+ const input = step[kind];
312
+ const { [kind]: _input, ...rest } = step;
313
+ const form = (fields: string[], value: unknown, others = rest) => ({ fields, step: { [kind]: value, ...others } });
314
+ switch (kind) {
315
+ case "runUntil": {
316
+ const { timeoutFrames: beside, ...others } = rest;
317
+ const old = isObject(input) && !("target" in input) && ("at" in input || "raster" in input || "memory" in input);
318
+ if (!isObject(input) || (!old && beside === undefined)) return undefined;
319
+ const fields = [...(old ? ["at", "raster", "memory"].filter((key) => key in input).map((key) => `runUntil.${key}`) : []), ...(beside === undefined ? [] : ["timeoutFrames beside runUntil"])];
320
+ const timeoutFrames = beside ?? input.timeoutFrames;
321
+ return form(fields, { target: targetOf(input), ...(timeoutFrames === undefined ? {} : { timeoutFrames }) }, others);
322
+ }
323
+ case "type":
324
+ return typeof input === "string" ? form(["type as text"], { mode: "text", text: input }) : undefined;
325
+ case "write": {
326
+ if (!isObject(input) || !("at" in input || "bytes" in input || Array.isArray(input.data))) return undefined;
327
+ const { at: old, bytes, address, data, ...others } = input;
328
+ return form(
329
+ ["at", "bytes"].filter((key) => key in input).map((key) => `write.${key}`).concat(Array.isArray(data) ? ["write.data as a list"] : []),
330
+ { address: address ?? old, data: asHex(data ?? bytes), ...others },
331
+ );
332
+ }
333
+ case "registers": {
334
+ if (!isObject(input)) return undefined;
335
+ const fields = Object.entries(input).flatMap(([name, value]) => ((name === "pc" && typeof value === "number") || (name !== "pc" && name !== "flags" && typeof value === "string" && c64AddressOf(value) !== undefined) ? [`registers.${name} as ${typeof value === "number" ? "a number" : "text"}`] : []));
336
+ if (fields.length === 0) return undefined;
337
+ const values = Object.fromEntries(Object.entries(input).map(([name, value]) => [name, name === "pc" ? asAddress(value) : typeof value === "string" ? (c64AddressOf(value) ?? value) : value]));
338
+ return form(fields, values);
339
+ }
340
+ default:
341
+ return undefined;
342
+ }
343
+ }
344
+
345
+ /** Refuses a step in a form of the 2.0 files: the message names its 2.0 fields and gives the step to write. */
346
+ export function refuseOldStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never): void {
347
+ const found = newStepForm(kind, step);
348
+ if (found === undefined) return;
349
+ const petscii = kind === "type" ? ', or {"type": {"mode": "petscii", "bytes": [...]}} for PETSCII codes' : "";
350
+ fail(`${at} has the 2.0 form (${found.fields.join(", ")}). Write ${jsonLine(found.step)}${petscii}`);
351
+ }
352
+
259
353
  /**
260
354
  * Checks one action step of the given kind and returns it with the defaults
261
355
  * filled in. The step has no other key. `press` is refused unless the caller
262
- * runs the steps on the replay core.
356
+ * runs the steps on the replay core. A step in a form of the 2.0 files is
357
+ * refused with the step to write.
263
358
  */
264
359
  export function checkActionStep(kind: ActionStepKind, step: Record<string, unknown>, at: string, fail: (message: string) => never, options: { press?: boolean } = {}): ActionStep {
360
+ refuseOldStep(kind, step, at, fail);
265
361
  for (const key of Object.keys(step)) if (key !== kind) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
266
362
  if (kind === "press" && options.press !== true) fail(`${at}: ${PRESS_REMEDY}`);
267
363
  return checkInput(kind, step[kind], at, fail);
268
364
  }
269
365
 
270
- /** The kind of a step: its one key from `kinds`. */
271
- export function stepKind<K extends string>(step: unknown, kinds: readonly K[], at: string, fail: (message: string) => never): K {
366
+ /**
367
+ * The kind of a step: its one key from `kinds`. A refusal lists `shown`: the
368
+ * kinds that the caller runs.
369
+ */
370
+ export function stepKind<K extends string>(step: unknown, kinds: readonly K[], at: string, fail: (message: string) => never, shown: readonly string[] = kinds): K {
272
371
  if (!isObject(step)) return fail(`${at} must be an object`);
273
372
  const found = Object.keys(step).filter((key) => (kinds as readonly string[]).includes(key));
274
- if (found.length !== 1) return fail(`${at} must have exactly one of ${kinds.join(", ")}`);
373
+ if (found.length > 1) return fail(`${at} has ${found.length} step kinds: ${found.join(", ")}. Give each one its own step`);
374
+ if (found.length !== 1) return fail(`${at} must have exactly one of ${shown.join(", ")}`);
275
375
  return found[0] as K;
276
376
  }
277
377
 
@@ -280,12 +380,46 @@ export function checkActionSteps(value: unknown, fail: (message: string) => neve
280
380
  if (!Array.isArray(value) || value.length === 0) return fail("the steps are a JSON list with at least one step");
281
381
  return value.map((step: unknown, index) => {
282
382
  const at = `step ${index + 1}`;
283
- return checkActionStep(stepKind(step, ACTION_STEP_KINDS, at, fail), step as Record<string, unknown>, at, fail, options);
383
+ return checkActionStep(stepKind(step, ACTION_STEP_KINDS, at, fail, options.press === true ? ACTION_STEP_KINDS : VICE_STEP_KINDS), step as Record<string, unknown>, at, fail, options);
284
384
  });
285
385
  }
286
386
 
287
- /** Reads a steps file of the project: a JSON list of action steps. `option` names the option of the file in a refusal. */
288
- export function readActionSteps(path: string, option: string): ActionStep[] {
387
+ /** The names and addresses of an action step: each location that the step resolves when it runs. */
388
+ export function stepLocations(step: ActionStep): Location[] {
389
+ if ("write" in step) return [step.write.address];
390
+ if ("registers" in step) return step.registers.pc === undefined ? [] : [step.registers.pc];
391
+ if (!("runUntil" in step)) return [];
392
+ const { target } = step.runUntil;
393
+ if (target.kind === "raster") return [];
394
+ if (target.kind === "memory") return [target.address];
395
+ return [target.address, ...(target.condition?.kind === "memory" ? [target.condition.address] : [])];
396
+ }
397
+
398
+ /**
399
+ * Resolves every name of the steps with `at` before the steps run, so that an
400
+ * unknown name stops the call before the emulator starts. A refusal of `at` becomes
401
+ * invalid-input that names `where` (for example the option and the file) and the step.
402
+ * `first` is the number of the first of the steps in the file.
403
+ */
404
+ export function checkStepNames(steps: readonly ActionStep[], at: (location: Location) => number, where: string, first = 1): void {
405
+ steps.forEach((step, index) => {
406
+ for (const location of stepLocations(step)) {
407
+ try {
408
+ at(location);
409
+ } catch (error) {
410
+ if (isRefusal(error)) throw new Refusal("invalid-input", `${where}: step ${index + first}: ${error.message}`);
411
+ throw error;
412
+ }
413
+ }
414
+ });
415
+ }
416
+
417
+ /**
418
+ * Reads a steps file of the project: a JSON list of action steps. `option`
419
+ * names the option of the file in a refusal. With `at`, every name of the
420
+ * steps must resolve (checkStepNames).
421
+ */
422
+ export function readActionSteps(path: string, option: string, at?: (location: Location) => number): ActionStep[] {
289
423
  const fail = (message: string): never => {
290
424
  throw new Refusal("invalid-input", `${option} ${path}: ${message}`);
291
425
  };
@@ -296,7 +430,9 @@ export function readActionSteps(path: string, option: string): ActionStep[] {
296
430
  if (error instanceof SyntaxError) return fail(`it is not JSON: ${error.message}`);
297
431
  throw error;
298
432
  }
299
- return checkActionSteps(value, fail);
433
+ const steps = checkActionSteps(value, fail);
434
+ if (at !== undefined) checkStepNames(steps, at, `${option} ${path}`);
435
+ return steps;
300
436
  }
301
437
 
302
438
  /** Stops the CPU when it runs: a write, a register change or an exact frame count needs it stopped. */
@@ -85,6 +85,6 @@ export async function hostTools(options: ToolCallOptions = {}): Promise<{ host:
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
- export { DISK_ACTIONS } from "../protocol/tools.ts";
88
+ export { DISK_ACTIONS, DISK_IMAGE_TYPES } from "../protocol/tools.ts";
89
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";
90
90
  export { MAX_TRACE_FRAMES, MAX_TRACE_HITS, MAX_TRACE_POINTS, TRACE_KINDS, type TraceHit, type TraceKind } from "../protocol/vice.ts";