@henols/c64-re-tools 2.1.0 → 2.1.1

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 (58) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +1 -0
  3. package/skills/c64-assembler/scripts/assembler.ts +10 -0
  4. package/skills/c64-disk/SKILL.md +2 -2
  5. package/skills/c64-disk/scripts/disk.ts +24 -10
  6. package/skills/c64-disk/scripts/lib/containers.ts +9 -9
  7. package/skills/c64-docs/scripts/lib/links.ts +3 -3
  8. package/skills/c64-docs/scripts/lib/reference.ts +2 -2
  9. package/skills/c64-emulator/SKILL.md +8 -2
  10. package/skills/c64-knowledge/SKILL.md +3 -1
  11. package/skills/c64-knowledge/scripts/knowledge.ts +59 -43
  12. package/skills/c64-memory-map/SKILL.md +1 -1
  13. package/skills/c64-memory-map/scripts/memory-map.ts +3 -2
  14. package/skills/c64-provenance/SKILL.md +5 -4
  15. package/skills/c64-provenance/scripts/lib/library.ts +18 -6
  16. package/skills/c64-provenance/scripts/lib/separate.ts +45 -6
  17. package/skills/c64-provenance/scripts/provenance.ts +5 -4
  18. package/skills/c64-source/SKILL.md +2 -0
  19. package/skills/c64-source/scripts/lib/source.ts +2 -2
  20. package/skills/c64-source/scripts/source.ts +10 -6
  21. package/skills/c64-static-analysis/scripts/lib/idioms.ts +6 -2
  22. package/skills/c64-static-analysis/scripts/static-analysis.ts +4 -3
  23. package/skills/c64-testing/SKILL.md +11 -1
  24. package/skills/c64-testing/references/recording-format.md +2 -0
  25. package/skills/c64-testing/references/scenario-format.md +6 -0
  26. package/skills/c64-testing/scripts/lib/diff-files.ts +63 -13
  27. package/skills/c64-testing/scripts/lib/diff.ts +3 -3
  28. package/skills/c64-testing/scripts/lib/recording.ts +47 -12
  29. package/skills/c64-testing/scripts/lib/replay.ts +11 -0
  30. package/skills/c64-testing/scripts/lib/runs.ts +64 -4
  31. package/skills/c64-testing/scripts/lib/scenario.ts +43 -6
  32. package/skills/c64-testing/scripts/testing.ts +15 -2
  33. package/skills/c64-unpacker/SKILL.md +9 -5
  34. package/skills/c64-unpacker/scripts/lib/evidence.ts +4 -5
  35. package/skills/c64-unpacker/scripts/lib/loads.ts +3 -2
  36. package/skills/c64-unpacker/scripts/unpacker.ts +75 -25
  37. package/src/host/vice/session.ts +99 -10
  38. package/src/host-client/image.ts +32 -0
  39. package/src/host-client/steps.ts +106 -6
  40. package/src/host-client/tools.ts +1 -1
  41. package/src/host-client/transfer.ts +35 -8
  42. package/src/knowledge/expression.ts +1 -1
  43. package/src/knowledge/history.ts +19 -2
  44. package/src/knowledge/names.ts +2 -1
  45. package/src/knowledge/program.ts +1 -16
  46. package/src/knowledge/read.ts +43 -0
  47. package/src/knowledge/schema.ts +3 -2
  48. package/src/knowledge/text.ts +1 -0
  49. package/src/knowledge/write.ts +40 -6
  50. package/src/mcp/output.ts +5 -0
  51. package/src/mcp/server.ts +19 -4
  52. package/src/mcp/tools/debug.ts +7 -6
  53. package/src/mcp/tools/execution.ts +18 -5
  54. package/src/mcp/tools/media.ts +14 -2
  55. package/src/mcp/tools/memory.ts +7 -3
  56. package/src/mcp/tools/video.ts +5 -3
  57. package/src/project.ts +1 -1
  58. package/src/protocol/vice.ts +4 -1
@@ -12,10 +12,11 @@ import { readCoverage, writtenBlocks } from "#src/host-client/coverage.ts";
12
12
  import { resetToReady } from "#src/host-client/entry-state.ts";
13
13
  import { readActionSteps, runActionStep, type ActionStep } from "#src/host-client/steps.ts";
14
14
  import { WireFailure } from "#src/host-client/tools.ts";
15
- import { readProjectFile, writeReported, type Written } from "#src/host-client/transfer.ts";
15
+ import { readPrg } from "#src/host-client/image.ts";
16
+ import { writeReported, type Written } from "#src/host-client/transfer.ts";
16
17
  import { ViceSessionClient } from "#src/host-client/vice-session.ts";
17
18
  import { knowledgeAt, knowledgeNames } from "#src/knowledge/names.ts";
18
- import { OPTIONS, runScript, UsageError, type Call, type CommandSpec, type OptionSpec } from "#src/script.ts";
19
+ import { listed, OPTIONS, runScript, UsageError, type Call, type CommandSpec, type OptionSpec } from "#src/script.ts";
19
20
  import { basicStart, inspect, readMemoryMap, underRom, writtenThenExecuted } from "./lib/evidence.ts";
20
21
  import { loads, readLoadSteps } from "./lib/loads.ts";
21
22
  import { callReturn, captureSegments, executedIn, inRanges, mergeMaps, stageCandidates, stagePoints, withoutRanges } from "./lib/stages.ts";
@@ -38,10 +39,12 @@ async function runSteps(client: ViceSessionClient, steps: ActionStep[]): Promise
38
39
  return undefined;
39
40
  }
40
41
 
41
- async function trace(program: string, options: { frames: number; entry?: number; steps: ActionStep[] }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
42
- const { bytes } = readProjectFile(program);
43
- if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
44
- if (options.entry === undefined && basicStart(bytes) === undefined) throw new UsageError(NO_BASIC);
42
+ /** inspect, trace, stages and capture take a PRG: a disk, tape or cartridge file goes to loads. */
43
+ const NOT_PRG_REMEDY = "Use loads, which runs a disk, tape or cartridge image, or get the PRG out of it with disk.ts read of the c64-disk skill";
44
+ const readProgram = (path: string) => readPrg(path, NOT_PRG_REMEDY).bytes;
45
+
46
+ async function trace(program: string, options: { frames: number; entry?: number; steps: ActionStep[]; max: number }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
47
+ const bytes = startable(program, options.entry);
45
48
  const client = await ViceSessionClient.open({ videoStandard: "pal" });
46
49
  try {
47
50
  // Loaded but not started, so the memory map can start empty: the KERNAL start-up writes all of RAM.
@@ -54,7 +57,12 @@ async function trace(program: string, options: { frames: number; entry?: number;
54
57
  const step = await client.execution({ action: "advance-frames", count: Math.min(MAX_ADVANCE, options.frames - ran), space: "c64" });
55
58
  const advanced = step.advancedFrames ?? 0;
56
59
  ran += advanced;
57
- if (advanced === 0) break;
60
+ // A JAM or a point stops the run before its frames: the evidence would be of a run that did not happen.
61
+ if ((step.stopReason !== undefined && step.stopReason !== "frames") || advanced === 0) {
62
+ const stopReason = step.stopReason ?? "no-frames";
63
+ const at = step.pc ?? (await client.registersGet("c64")).pc;
64
+ return { output: { traced: false, frames: ran, stopReason, pc: formatC64Address(at), reason: stoppedEarly(stopReason, at, ran, options.frames) }, failed: true };
65
+ }
58
66
  }
59
67
  const { pc } = await client.registersGet("c64");
60
68
  const ranges = await readMemoryMap(async (start) => {
@@ -80,7 +88,7 @@ async function trace(program: string, options: { frames: number; entry?: number;
80
88
  traced: true,
81
89
  frames: ran,
82
90
  pc: formatC64Address(pc),
83
- writtenThenExecuted: written.map((range) => ({
91
+ writtenThenExecuted: listed(written, options.max, (range) => ({
84
92
  start: formatC64Address(range.start),
85
93
  end: formatC64Address(range.end),
86
94
  ...(underRom(range.start) ? { underRom: true } : {}),
@@ -107,6 +115,12 @@ async function trace(program: string, options: { frames: number; entry?: number;
107
115
 
108
116
  const NO_BASIC = "the program is no BASIC program at $0801, so RUN cannot start it. Give --entry <address>.";
109
117
 
118
+ /** Why a run stopped before its frames: what stopped the CPU, where, and after how many frames. */
119
+ function stoppedEarly(stopReason: string, pc: number, ran: number, frames: number): string {
120
+ const what = stopReason === "jam" ? "a JAM instruction" : stopReason === "no-frames" ? "the emulator, which ran no frame" : `a ${stopReason}`;
121
+ return `The CPU stopped at ${formatC64Address(pc)} by ${what} after ${ran} of ${frames} frames. Check --entry and the steps: the program must run for the frames.`;
122
+ }
123
+
110
124
  /** The first PC in one of the ranges within SYS_FRAMES frames, found with exec breakpoints over them. */
111
125
  async function firstPcIn(client: ViceSessionClient, ranges: Array<{ start: number; end: number }>): Promise<number | undefined> {
112
126
  const points = [];
@@ -175,6 +189,8 @@ interface Followed {
175
189
  reached: boolean;
176
190
  /** Why the program did not start, when it did not. */
177
191
  failed?: string;
192
+ /** What stopped the CPU before the frames ran: a JAM or a point of the program. */
193
+ halted?: { stopReason: string; pc: number };
178
194
  }
179
195
 
180
196
  type RunUntilStep = Extract<ActionStep, { runUntil: unknown }>;
@@ -197,13 +213,11 @@ function trackedRunUntil(step: ActionStep): step is RunUntilStep & { runUntil: {
197
213
  async function follow(
198
214
  client: ViceSessionClient,
199
215
  program: string,
216
+ bytes: Uint8Array,
200
217
  options: { frames: number; entry?: number; steps: ActionStep[]; minStage: number; until?: number },
201
218
  ): Promise<Followed> {
202
- const { bytes } = readProjectFile(program);
203
- if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
204
219
  const load = bytes[0]! | (bytes[1]! << 8);
205
220
  const basic = basicStart(bytes);
206
- if (options.entry === undefined && basic === undefined) throw new UsageError(NO_BASIC);
207
221
  const notStarted = await startLoaded(client, program, bytes, options.entry);
208
222
  if (notStarted !== undefined) return { stages: [], frames: 0, reached: false, failed: notStarted };
209
223
  // The frames of each step end where a scenario's do: the first step counts from RUN, a later one from where it starts.
@@ -216,6 +230,7 @@ async function follow(
216
230
  let firstFrame = 0;
217
231
  let total = 0;
218
232
  let ended = false;
233
+ let halted: { stopReason: string; pc: number } | undefined;
219
234
  /** Where the run stops: the target of a runUntil step while it runs, then until. */
220
235
  let stopAt: number | undefined;
221
236
  const atTarget = (pc: number) => stopAt !== undefined && pc === stopAt;
@@ -235,7 +250,7 @@ async function follow(
235
250
  * blocks, and stops at the first opcode that the stage wrote and the CPU
236
251
  * then executes. Own code stops the run once; a write watch arms it again.
237
252
  */
238
- const rerun = async (candidates: AddressRange[], frames: number): Promise<{ used: number; map: Uint8Array; next?: number; atTarget: boolean }> => {
253
+ const rerun = async (candidates: AddressRange[], frames: number): Promise<{ used: number; map: Uint8Array; next?: number; atTarget: boolean; halted?: { stopReason: string; pc: number } }> => {
239
254
  const points = new Map<string, { kind: "exec" | "watch"; id: number }>();
240
255
  const arm = async (wanted: { exec: AddressRange[]; watch: AddressRange[] }) => {
241
256
  const keys = new Set([...wanted.exec.map((range) => `exec:${range.start}:${range.end}`), ...wanted.watch.map((range) => `watch:${range.start}:${range.end}`)]);
@@ -295,6 +310,7 @@ async function follow(
295
310
  left -= advanced;
296
311
  const pc = run.pc ?? (await client.registersGet("c64")).pc;
297
312
  map = await readCoverage(client);
313
+ if (run.stopReason === "jam") return { used, map, atTarget: false, halted: { stopReason: "jam", pc } };
298
314
  // A frame can end in written code that the stage called before: that stop starts no stage.
299
315
  if (inRanges(candidates, pc) && !inRanges(stage.called, pc)) {
300
316
  if (written(pc)) {
@@ -341,6 +357,14 @@ async function follow(
341
357
  const first = await client.execution({ action: "advance-frames", count: chunk, space: "c64", ...framed() });
342
358
  const firstPc = first.pc ?? (await client.registersGet("c64")).pc;
343
359
  const map = await readCoverage(client);
360
+ // A JAM, or a point that is not the target, ends the run before its frames.
361
+ if (first.stopReason !== undefined && first.stopReason !== "frames" && !atTarget(firstPc)) {
362
+ total += first.advancedFrames ?? 0;
363
+ mergeMaps(stage.map, map);
364
+ halted = { stopReason: first.stopReason, pc: firstPc };
365
+ ended = true;
366
+ continue;
367
+ }
344
368
  const candidates = stageCandidates(stage.map, map, stage.own, options.minStage, stage.called);
345
369
  if (candidates.length === 0) {
346
370
  const advanced = first.advancedFrames ?? 0;
@@ -369,6 +393,10 @@ async function follow(
369
393
  if (again.next !== undefined) await startStage(again.next, again.map);
370
394
  else mergeMaps(stage.map, again.map);
371
395
  if (again.atTarget) stopped = true;
396
+ if (again.halted !== undefined) {
397
+ halted = again.halted;
398
+ ended = true;
399
+ }
372
400
  }
373
401
  return stopped;
374
402
  };
@@ -389,7 +417,8 @@ async function follow(
389
417
  else if (trackedRunUntil(step)) {
390
418
  if (!(await trackTo(knowledgeAt()(step.runUntil.target.address), step.runUntil.timeoutFrames))) {
391
419
  const { pc } = await client.registersGet("c64");
392
- return { stages: found, frames: total, reached: false, failed: `Step ${index + 1} did not reach its target in ${step.runUntil.timeoutFrames} frames. It is at ${formatC64Address(pc)}.` };
420
+ const why = halted === undefined ? "" : ` ${stoppedEarly(halted.stopReason, halted.pc, total, step.runUntil.timeoutFrames)}`;
421
+ return { stages: found, frames: total, reached: false, failed: `Step ${index + 1} did not reach its target in ${step.runUntil.timeoutFrames} frames. It is at ${formatC64Address(pc)}.${why}` };
393
422
  }
394
423
  } else await runActionStep(client, step, knowledgeAt());
395
424
  }
@@ -400,7 +429,7 @@ async function follow(
400
429
  await client.snapshot({ action: "remove", name: STAGE_SNAPSHOT }).catch(() => undefined);
401
430
  mergeMaps(stage.map, await readCoverage(client));
402
431
  found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(stage.map, options.minStage) });
403
- return { stages: found, frames: total, reached };
432
+ return { stages: found, frames: total, reached, ...(halted === undefined ? {} : { halted }) };
404
433
  }
405
434
 
406
435
  const showRange = (range: { start: number; end: number }) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) });
@@ -413,11 +442,23 @@ const showStages = (stages: Stage[]) =>
413
442
  ...(stage.next === undefined ? {} : { next: formatC64Address(stage.next) }),
414
443
  }));
415
444
 
445
+ /** The bytes of a PRG that the emulator starts: RUN needs a BASIC program at $0801, else --entry gives the start. Checked before the emulator opens. */
446
+ function startable(program: string, entry: number | undefined): Uint8Array {
447
+ const bytes = readProgram(program);
448
+ if (entry === undefined && basicStart(bytes) === undefined) throw new UsageError(NO_BASIC);
449
+ return bytes;
450
+ }
451
+
416
452
  async function stages(program: string, options: { frames: number; entry?: number; steps: ActionStep[]; minStage: number }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
453
+ const bytes = startable(program, options.entry);
417
454
  const client = await ViceSessionClient.open({ videoStandard: "pal" });
418
455
  try {
419
- const followed = await follow(client, program, options);
456
+ const followed = await follow(client, program, bytes, options);
420
457
  if (followed.failed !== undefined) return { output: { followed: false, reason: followed.failed }, failed: true };
458
+ if (followed.halted !== undefined) {
459
+ const { stopReason, pc } = followed.halted;
460
+ return { output: { followed: false, frames: followed.frames, stopReason, pc: formatC64Address(pc), reason: stoppedEarly(stopReason, pc, followed.frames, options.frames), stages: showStages(followed.stages) }, failed: true };
461
+ }
421
462
  return {
422
463
  output: {
423
464
  followed: true,
@@ -437,14 +478,20 @@ async function capture(
437
478
  program: string,
438
479
  options: { until: number; out: string; range?: AddressRange; exclude: AddressRange[]; timeoutFrames: number; entry?: number; steps: ActionStep[]; minStage: number },
439
480
  ): Promise<{ output: Record<string, unknown>; failed: boolean }> {
481
+ const bytes = startable(program, options.entry);
440
482
  const client = await ViceSessionClient.open({ videoStandard: "pal" });
441
483
  try {
442
- const followed = await follow(client, program, { frames: options.timeoutFrames, steps: options.steps, minStage: options.minStage, until: options.until, ...(options.entry === undefined ? {} : { entry: options.entry }) });
484
+ const followed = await follow(client, program, bytes, { frames: options.timeoutFrames, steps: options.steps, minStage: options.minStage, until: options.until, ...(options.entry === undefined ? {} : { entry: options.entry }) });
443
485
  if (followed.failed !== undefined) return { output: { captured: false, reason: followed.failed }, failed: true };
444
486
  const registers = await client.registersGet("c64");
445
487
  if (!followed.reached) {
446
488
  return {
447
- output: { captured: false, reason: `The program did not get to ${formatC64Address(options.until)} in ${options.timeoutFrames} frames. It is at ${formatC64Address(registers.pc)}.`, stages: showStages(followed.stages) },
489
+ output: {
490
+ captured: false,
491
+ reason: `The program did not get to ${formatC64Address(options.until)} in ${options.timeoutFrames} frames. It is at ${formatC64Address(registers.pc)}.${followed.halted === undefined ? "" : ` ${stoppedEarly(followed.halted.stopReason, followed.halted.pc, followed.frames, options.timeoutFrames)}`}`,
492
+ ...(followed.halted === undefined ? {} : { stopReason: followed.halted.stopReason }),
493
+ stages: showStages(followed.stages),
494
+ },
448
495
  failed: true,
449
496
  };
450
497
  }
@@ -453,10 +500,6 @@ async function capture(
453
500
  const state = await machineState(client);
454
501
  // The segments: what the stages wrote, without loader leftovers, with no overlap.
455
502
  const found = options.range !== undefined ? [options.range] : captureSegments(followed.stages, registers.pc);
456
- const inside = options.exclude.find((range) => registers.pc >= range.start && registers.pc <= range.end);
457
- if (inside !== undefined) {
458
- return { output: { captured: false, reason: `The entry ${formatC64Address(registers.pc)} is in --exclude ${formatC64Address(inside.start)}-${formatC64Address(inside.end)}. A segment must hold the entry.` }, failed: true };
459
- }
460
503
  const segments = withoutRanges(found, options.exclude);
461
504
  const file = (path: string) => `${options.out}/${path}`;
462
505
  const written: Written[] = [];
@@ -534,14 +577,14 @@ const COMMANDS: CommandSpec[] = [
534
577
  name: "inspect",
535
578
  summary: "Packing evidence: the BASIC start and the entropy of the bytes.",
536
579
  subjects: [PROGRAM],
537
- run: (call) => inspect(readProjectFile(call.subject(0)).bytes),
580
+ run: (call) => inspect(readProgram(call.subject(0))),
538
581
  },
539
582
  {
540
583
  name: "trace",
541
584
  summary: "Runs the program and reports the memory that it wrote and then executed.",
542
585
  subjects: [PROGRAM],
543
- options: { frames: FRAMES, entry: ENTRY, steps: STEPS },
544
- run: async (call) => outcome(await trace(call.subject(0), { frames: call.count("frames")!, steps: stepsOf(call), ...entryOf(call) })),
586
+ options: { frames: FRAMES, entry: ENTRY, steps: STEPS, "max-listed": OPTIONS["max-listed"] },
587
+ run: async (call) => outcome(await trace(call.subject(0), { frames: call.count("frames")!, steps: stepsOf(call), max: call.count("max-listed")!, ...entryOf(call) })),
545
588
  },
546
589
  {
547
590
  name: "stages",
@@ -573,7 +616,14 @@ const COMMANDS: CommandSpec[] = [
573
616
  notes: "entry_state.json has the registers, $00/$01, pages 0-3, the VIC, SID and CIA registers, the vectors, and what differs from a clean reset.",
574
617
  run: async (call) => {
575
618
  const range = call.range("range");
576
- return outcome(await capture(call.subject(0), { until: call.address("until")!, out: call.string("out")!, ...(range === undefined ? {} : { range }), exclude: call.ranges("exclude"), timeoutFrames: call.count("timeout-frames")!, steps: stepsOf(call), minStage: call.count("min-stage")!, ...entryOf(call) }));
619
+ const until = call.address("until")!;
620
+ // A segment must hold the entry: refused before the emulator runs.
621
+ const show = (item: AddressRange) => `${formatC64Address(item.start)}-${formatC64Address(item.end)}`;
622
+ const holds = (item: AddressRange) => until >= item.start && until <= item.end;
623
+ const excluded = call.ranges("exclude").find(holds);
624
+ if (excluded !== undefined) throw new UsageError(`--exclude ${show(excluded)} holds the entry --until ${formatC64Address(until)}. A segment must hold the entry: give an --exclude range without it`);
625
+ if (range !== undefined && !holds(range)) throw new UsageError(`--range ${show(range)} does not hold the entry --until ${formatC64Address(until)}. Give a --range that holds the entry`);
626
+ return outcome(await capture(call.subject(0), { until, out: call.string("out")!, ...(range === undefined ? {} : { range }), exclude: call.ranges("exclude"), timeoutFrames: call.count("timeout-frames")!, steps: stepsOf(call), minStage: call.count("min-stage")!, ...entryOf(call) }));
577
627
  },
578
628
  },
579
629
  {
@@ -187,6 +187,16 @@ export class ViceSession implements ViceSessionHandle {
187
187
  #haltCount = 0;
188
188
  /** What stopped the machine last, when a user breakpoint or watchpoint or a JAM did; cleared when it runs again. */
189
189
  #lastStop: { stopReason: PointStopReason; pointId?: number } | undefined;
190
+ /**
191
+ * The pc of the JAM that holds the CPU. A jammed CPU runs again only after a
192
+ * reset, a snapshot restore or an autostart: VICE 3.10 keeps it jammed after a
193
+ * pc write, and reports the JAM only once.
194
+ */
195
+ #jammed: number | undefined;
196
+ /** VICE records no CPU history after a JAM until the next reset. */
197
+ #historyStopped = false;
198
+ /** The CPU ran again after a JAM without a reset: the CPU history that VICE gives is old. */
199
+ #historyOld = false;
190
200
  #queue: Promise<unknown> = Promise.resolve();
191
201
  #closed = false;
192
202
  #lost = false;
@@ -404,6 +414,7 @@ export class ViceSession implements ViceSessionHandle {
404
414
  centred.push(port);
405
415
  }
406
416
  await this.#runningCommand(() => this.#machine.autostart(file, index, run));
417
+ this.#clearJam();
407
418
  if (type === "crt") this.#cartridge = true;
408
419
  this.#replaceStaged(stagedKind(type), file);
409
420
  // A reset sets the CPU clock back, so the stopwatch starts again.
@@ -431,7 +442,7 @@ export class ViceSession implements ViceSessionHandle {
431
442
  case "screenCapture": {
432
443
  const { baseline } = params as ViceOperations["screenCapture"]["params"];
433
444
  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.`);
445
+ throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_BASELINES} baselines. Remove one first with c64_screen action remove.`);
435
446
  }
436
447
  const shot = await this.#observe(() => this.#machine.captureFrame(this.#videoStandard));
437
448
  if (baseline !== undefined) this.#baselines.set(baseline, shot);
@@ -530,7 +541,7 @@ export class ViceSession implements ViceSessionHandle {
530
541
  if (status.state === "stopped") {
531
542
  status.pc = await this.#pc();
532
543
  // 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);
544
+ const stop = this.#lastStop ?? ((await this.#jamAt(status.pc)) ? { stopReason: "jam" as const } : undefined);
534
545
  if (stop !== undefined) Object.assign(status, stop);
535
546
  }
536
547
  return status;
@@ -538,7 +549,10 @@ export class ViceSession implements ViceSessionHandle {
538
549
 
539
550
  async #execution(params: ExecutionParams): Promise<ExecutionResult> {
540
551
  if (params.action === "resume") {
541
- if (this.#state === "stopped") await this.#resume();
552
+ if (this.#state === "stopped") {
553
+ await this.#refuseJammed("resume");
554
+ await this.#resume();
555
+ }
542
556
  return { state: "running" };
543
557
  }
544
558
  if (params.action === "pause") {
@@ -546,6 +560,9 @@ export class ViceSession implements ViceSessionHandle {
546
560
  // A drive checkpoint can stop the machine just before the pause does.
547
561
  return { state: "stopped", pc: (await this.#completeDriveStop()) ?? (await this.#pc()) };
548
562
  }
563
+ if (params.space === "drive8" && params.action === "advance-frames") {
564
+ 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.");
565
+ }
549
566
  if (params.space === "drive8") {
550
567
  // Stock VICE runs the 1541 CPU in batches that catch up with the computer's clock;
551
568
  // neither monitor can stop it after exactly one of its instructions.
@@ -565,13 +582,16 @@ export class ViceSession implements ViceSessionHandle {
565
582
  const outcome = await this.#runFrames(params.count, undefined, params.anchor);
566
583
  return {
567
584
  state: "stopped",
568
- pc: outcome.pc,
585
+ // A stop event carries the computer's PC; a stop in the drive reports the drive's.
586
+ pc: outcome.space === "drive8" ? (await this.#machine.readRegisters("drive8")).pc : outcome.pc,
587
+ space: outcome.space,
569
588
  advancedFrames: outcome.frames,
570
589
  stopReason: outcome.end === "target" ? "frames" : outcome.end,
571
590
  ...(outcome.pointId === undefined ? {} : { pointId: outcome.pointId }),
572
591
  };
573
592
  }
574
593
  await this.#stop();
594
+ await this.#refuseJammed(params.action);
575
595
  if (params.action === "until-return") {
576
596
  const stopped = this.#nextStop(this.#untilReturnLimitMs);
577
597
  await this.#resumingCommand(() => this.#machine.untilReturn());
@@ -599,6 +619,7 @@ export class ViceSession implements ViceSessionHandle {
599
619
 
600
620
  async #reset(params: { mode: "soft" | "hard"; run: boolean }): Promise<{ state: RunState; pc?: number }> {
601
621
  await this.#stop();
622
+ this.#clearJam();
602
623
  // A reset sets the CPU clock back, so the stopwatch starts again.
603
624
  this.#timingStart = 0n;
604
625
  if (params.run) {
@@ -637,11 +658,15 @@ export class ViceSession implements ViceSessionHandle {
637
658
  const start = anchor ?? (await this.#machine.rasterPosition());
638
659
  const arming = rasterLinesExpression((start.line + Math.floor(lines / 2)) % lines, lines);
639
660
  const ending = rasterWindowExpression(start.line, start.cycle, lines);
661
+ const startPc = await this.#pc();
662
+ if (await this.#jamAt(startPc)) return { end: "jam", frames: 0, pc: startPc, space: "c64" };
640
663
  const clock = await this.#machine.addCheckpoint({ start: 0x0000, end: 0xffff, operation: 0x04, space: "c64" });
641
664
  try {
642
665
  await this.#machine.setCondition(clock, arming);
643
666
  let armingPhase = true;
644
667
  let frames = 0;
668
+ // VICE reports a JAM only once until a reset: a jammed CPU stops the clock at the same pc again and again.
669
+ let clockPc = startPc;
645
670
  for (;;) {
646
671
  const next = this.#nextStop(HALF_FRAME_LIMIT_MS);
647
672
  await this.#resume();
@@ -652,6 +677,8 @@ export class ViceSession implements ViceSessionHandle {
652
677
  }
653
678
  const end = this.#stopCause(stop, clock, target?.checkpoint);
654
679
  if (end === "clock") {
680
+ if (stop.pc === clockPc && (await this.#jamAt(stop.pc))) return { end: "jam", frames, pc: stop.pc, space: "c64" };
681
+ clockPc = stop.pc;
655
682
  if (armingPhase) {
656
683
  await this.#machine.setCondition(clock, ending);
657
684
  if (target?.deferred) {
@@ -667,7 +694,7 @@ export class ViceSession implements ViceSessionHandle {
667
694
  continue;
668
695
  }
669
696
  if (end === "unknown") {
670
- if (await this.#jammedAt(stop.pc)) return { end: "jam", frames, pc: stop.pc, space: "c64" };
697
+ if (await this.#jamAt(stop.pc)) return { end: "jam", frames, pc: stop.pc, space: "c64" };
671
698
  throw new WireFailure("operation-failed", "The emulator stopped the machine for a reason outside this run.");
672
699
  }
673
700
  // A drive stop is completed before the result names the computer's pc; `space` says which CPU stopped.
@@ -699,6 +726,9 @@ export class ViceSession implements ViceSessionHandle {
699
726
  const points: number[] = [];
700
727
  const hits: TraceHit[] = [];
701
728
  let frames = 0;
729
+ const startPc = await this.#pc();
730
+ if (await this.#jamAt(startPc)) return { hits, frames, end: "jam", pc: startPc };
731
+ let clockPc = startPc;
702
732
  const clock = await this.#machine.addCheckpoint({ start: 0x0000, end: 0xffff, operation: 0x04, space: "c64" });
703
733
  try {
704
734
  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 +748,8 @@ export class ViceSession implements ViceSessionHandle {
718
748
  if (hits.length >= params.maxHits) return { hits, frames, end: "hits", pc: stop.pc };
719
749
  }
720
750
  if (stop.hits.includes(clock)) {
751
+ if (stop.pc === clockPc && (await this.#jamAt(stop.pc))) return { hits, frames, end: "jam", pc: stop.pc };
752
+ clockPc = stop.pc;
721
753
  if (armingPhase) await this.#machine.setCondition(clock, ending);
722
754
  else {
723
755
  frames++;
@@ -730,7 +762,7 @@ export class ViceSession implements ViceSessionHandle {
730
762
  if (traced.length > 0) continue;
731
763
  const point = this.#userPoint(stop.hits);
732
764
  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 };
765
+ if (await this.#jamAt(stop.pc)) return { hits, frames, end: "jam", pc: stop.pc };
734
766
  throw new WireFailure("operation-failed", "The emulator stopped the machine for a reason outside this trace.");
735
767
  }
736
768
  } finally {
@@ -844,9 +876,40 @@ export class ViceSession implements ViceSessionHandle {
844
876
  return undefined;
845
877
  }
846
878
 
847
- async #jammedAt(pc: number): Promise<boolean> {
879
+ /**
880
+ * True when a JAM holds the CPU at `pc`: the session saw a JAM since the last
881
+ * reset, restore or autostart, or the opcode at `pc` is a JAM. The CPU stops
882
+ * on such an opcode, so it executes nothing more. The session keeps the JAM.
883
+ */
884
+ async #jamAt(pc: number): Promise<boolean> {
885
+ if (this.#jammed !== undefined) return true;
848
886
  const opcode = await this.#machine.readMemory({ address: pc, size: 1, space: "c64", view: "cpu" });
849
- return JAM_OPCODES.has(Number.parseInt(opcode, 16));
887
+ if (!JAM_OPCODES.has(Number.parseInt(opcode, 16))) return false;
888
+ this.#jam(pc);
889
+ return true;
890
+ }
891
+
892
+ #jam(pc: number): void {
893
+ this.#jammed = pc;
894
+ this.#historyStopped = true;
895
+ }
896
+
897
+ /** A reset or an autostart starts a jammed CPU and the CPU history again. */
898
+ #clearJam(): void {
899
+ this.#jammed = undefined;
900
+ this.#historyStopped = false;
901
+ this.#historyOld = false;
902
+ }
903
+
904
+ /** Refuses an action that needs the CPU to execute, while a JAM holds it. */
905
+ async #refuseJammed(action: string): Promise<void> {
906
+ const pc = await this.#pc();
907
+ if (!(await this.#jamAt(pc))) return;
908
+ throw new WireFailure(
909
+ "operation-failed",
910
+ `The CPU is jammed by the JAM opcode at ${formatC64Address(this.#jammed ?? pc)}, so ${action} cannot execute an instruction. ` +
911
+ "The CPU runs again only after c64_reset, a snapshot restore or c64_autostart.",
912
+ );
850
913
  }
851
914
 
852
915
  /** Runs until a target is reached or `timeoutFrames` frames pass. */
@@ -1050,6 +1113,13 @@ export class ViceSession implements ViceSessionHandle {
1050
1113
  * stop, whose position is known, mapped back through the frame.
1051
1114
  */
1052
1115
  async #cpuHistory(params: { limit: number; space: "c64" | "drive8" }): Promise<{ entries: HistoryEntry[] }> {
1116
+ if (params.space === "c64" && this.#historyOld) {
1117
+ throw new WireFailure(
1118
+ "operation-failed",
1119
+ "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. " +
1120
+ "Use c64_reset to record again, or use c64_trace.",
1121
+ );
1122
+ }
1053
1123
  return this.#observe(async () => {
1054
1124
  const raw = await this.#machine.history(params.limit, params.space);
1055
1125
  if (params.space === "drive8") return { entries: raw.map(({ clock: _clock, ...entry }) => entry) };
@@ -1128,7 +1198,7 @@ export class ViceSession implements ViceSessionHandle {
1128
1198
  const existing = this.#snapshots.get(params.name);
1129
1199
  if (params.action === "save") {
1130
1200
  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.`);
1201
+ throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_SNAPSHOTS} snapshots. Remove one first with action remove.`);
1132
1202
  }
1133
1203
  const file = existing ?? join(this.#workDir, `snapshot-${++this.#snapshotFiles}.vsf`);
1134
1204
  await this.#observe(() => this.#machine.saveSnapshot(file));
@@ -1149,6 +1219,9 @@ export class ViceSession implements ViceSessionHandle {
1149
1219
  await this.#stop();
1150
1220
  const elapsed = (await this.#machine.clock()) - this.#timingStart;
1151
1221
  await this.#machine.restoreSnapshot(file);
1222
+ // A restore starts a jammed CPU again, but VICE still records no CPU history.
1223
+ this.#jammed = undefined;
1224
+ if (this.#historyStopped) this.#historyOld = true;
1152
1225
  this.#state = "stopped";
1153
1226
  // The cycle clock jumped with the restore; the stopwatch keeps its reading, as a window move does.
1154
1227
  this.#timingStart = (await this.#machine.clock()) - elapsed;
@@ -1168,13 +1241,26 @@ export class ViceSession implements ViceSessionHandle {
1168
1241
  // Checked before the machine stops: VICE gives no reason for a file that is no snapshot.
1169
1242
  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
1243
  const file = this.#stage(bytes, "vsf");
1244
+ // VICE resets the machine after a snapshot file that it cannot read: a backup keeps the state before the restore.
1245
+ const backup = join(this.#workDir, "before-restore.vsf");
1246
+ await this.#stop();
1247
+ await this.#machine.saveSnapshot(backup);
1171
1248
  try {
1172
1249
  return await this.#restoreFrom(file);
1173
1250
  } catch (error) {
1251
+ const kept = await this.#restoreFrom(backup).then(
1252
+ () => true,
1253
+ () => false,
1254
+ );
1255
+ 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
1256
  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.");
1257
+ throw new WireFailure(
1258
+ "invalid-input",
1259
+ "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.",
1260
+ );
1176
1261
  } finally {
1177
1262
  rmSync(file, { force: true });
1263
+ rmSync(backup, { force: true });
1178
1264
  }
1179
1265
  }
1180
1266
 
@@ -1255,6 +1341,8 @@ export class ViceSession implements ViceSessionHandle {
1255
1341
  if (this.#closed) throw new WireFailure("machine-unavailable", "The emulator session is closed.");
1256
1342
  this.#vice = vice;
1257
1343
  this.#adapter = adapter;
1344
+ // A new emulator: no JAM holds its CPU, and it records CPU history.
1345
+ this.#clearJam();
1258
1346
  this.#mode = mode;
1259
1347
  for (const [point, checkpoint] of points) point.checkpoint = checkpoint;
1260
1348
  this.#timingStart = clock - handover.elapsed;
@@ -1515,6 +1603,7 @@ export class ViceSession implements ViceSessionHandle {
1515
1603
  if (stop.hits.length > 0 || event.type === ResponseType.jam) this.#haltCount++;
1516
1604
  if (stop.hits.some((hit) => this.#adapter?.isDriveCheckpoint(hit))) this.#driveStop = true;
1517
1605
  this.#lastStop = event.type === ResponseType.jam ? { stopReason: "jam" } : this.#userPoint(stop.hits);
1606
+ if (event.type === ResponseType.jam) this.#jam(stop.pc);
1518
1607
  this.#checkpointHits = [];
1519
1608
  this.#state = "stopped";
1520
1609
  this.#stopWaiters = this.#stopWaiters.filter((waiter) => !waiter(stop));
@@ -21,6 +21,38 @@ export interface Image {
21
21
  stages?: Array<Record<string, unknown>>;
22
22
  }
23
23
 
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
+ /**
40
+ * Reads a PRG of the project: a 2-byte load address, and bytes that end at
41
+ * $ffff or before. Refuses a disk, tape or cartridge file and any other file
42
+ * that is no PRG with invalid-input; `remedy` tells what to do in its place.
43
+ */
44
+ export function readPrg(path: string, remedy = MEDIA_REMEDY): { bytes: Buffer; start: number; end: number; body: Uint8Array } {
45
+ const { bytes, type } = readProjectFile(path);
46
+ const media = MEDIA_KINDS[type];
47
+ if (media !== undefined) throw new Refusal("invalid-input", `${path} is ${media} (.${type}), not a PRG. ${remedy}.`);
48
+ try {
49
+ return { bytes, ...prgRange(bytes) };
50
+ } catch (error) {
51
+ if (isRefusal(error)) throw new Refusal("invalid-input", `${path} is not a PRG: ${error.message} ${remedy}.`);
52
+ throw error;
53
+ }
54
+ }
55
+
24
56
  /** Reads an image.json or a PRG, relative to the project folder. */
25
57
  export function readImage(path: string): Image {
26
58
  const { bytes } = readProjectFile(path);