@henols/c64-re-tools 2.1.1 → 2.1.3

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 (67) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +6 -3
  3. package/skills/c64-assembler/scripts/assembler.ts +19 -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 +1 -0
  7. package/skills/c64-disk/scripts/disk.ts +2 -2
  8. package/skills/c64-disk/scripts/lib/containers.ts +4 -4
  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 +2 -0
  12. package/skills/c64-emulator/SKILL.md +4 -1
  13. package/skills/c64-knowledge/SKILL.md +12 -6
  14. package/skills/c64-knowledge/scripts/knowledge.ts +41 -25
  15. package/skills/c64-knowledge/scripts/lib/listing.ts +16 -31
  16. package/skills/c64-memory-map/SKILL.md +3 -0
  17. package/skills/c64-memory-map/scripts/memory-map.ts +23 -4
  18. package/skills/c64-provenance/SKILL.md +5 -3
  19. package/skills/c64-provenance/scripts/lib/library.ts +28 -5
  20. package/skills/c64-provenance/scripts/lib/separate.ts +34 -6
  21. package/skills/c64-provenance/scripts/provenance.ts +22 -10
  22. package/skills/c64-source/SKILL.md +2 -1
  23. package/skills/c64-source/scripts/lib/source.ts +25 -33
  24. package/skills/c64-source/scripts/source.ts +13 -17
  25. package/skills/c64-static-analysis/SKILL.md +6 -3
  26. package/skills/c64-static-analysis/scripts/lib/findings.ts +23 -8
  27. package/skills/c64-static-analysis/scripts/lib/pointers.ts +38 -25
  28. package/skills/c64-static-analysis/scripts/static-analysis.ts +31 -19
  29. package/skills/c64-testing/SKILL.md +9 -5
  30. package/skills/c64-testing/references/scenario-format.md +5 -3
  31. package/skills/c64-testing/scripts/lib/diff-files.ts +41 -10
  32. package/skills/c64-testing/scripts/lib/recording.ts +3 -3
  33. package/skills/c64-testing/scripts/lib/replay.ts +1 -1
  34. package/skills/c64-testing/scripts/lib/runs.ts +45 -9
  35. package/skills/c64-testing/scripts/lib/scenario.ts +130 -81
  36. package/skills/c64-testing/scripts/testing.ts +9 -6
  37. package/skills/c64-unpacker/SKILL.md +10 -7
  38. package/skills/c64-unpacker/scripts/lib/loads.ts +15 -6
  39. package/skills/c64-unpacker/scripts/lib/machine.ts +61 -3
  40. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  41. package/skills/c64-unpacker/scripts/unpacker.ts +21 -17
  42. package/src/host/vice/adapter.ts +4 -1
  43. package/src/host/vice/session.ts +144 -11
  44. package/src/host-client/image.ts +27 -13
  45. package/src/host-client/steps.ts +41 -5
  46. package/src/host-client/tools.ts +4 -3
  47. package/src/host-client/transfer.ts +22 -29
  48. package/src/knowledge/history.ts +55 -2
  49. package/src/knowledge/import.ts +79 -7
  50. package/src/knowledge/program.ts +31 -2
  51. package/src/knowledge/read.ts +7 -4
  52. package/src/knowledge/schema.ts +3 -2
  53. package/src/knowledge/text.ts +1 -0
  54. package/src/knowledge/write.ts +86 -42
  55. package/src/mcp/inputs.ts +6 -3
  56. package/src/mcp/output.ts +1 -1
  57. package/src/mcp/server.ts +9 -1
  58. package/src/mcp/tools/debug.ts +31 -4
  59. package/src/mcp/tools/machine.ts +3 -1
  60. package/src/mcp/tools/media.ts +5 -1
  61. package/src/mcp/tools/memory.ts +4 -3
  62. package/src/media.ts +57 -0
  63. package/src/opcodes.ts +81 -18
  64. package/src/platform.ts +14 -1
  65. package/src/project.ts +118 -3
  66. package/src/protocol/vice.ts +4 -1
  67. package/src/script.ts +118 -5
@@ -2,8 +2,14 @@
2
2
  // 0-3, the chip registers read without side effects, and the vectors. The
3
3
  // capture and loads commands of the unpacker share it.
4
4
 
5
+ import { readdirSync, rmSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
5
8
  import { formatC64Address } from "#src/c64.ts";
9
+ import { resetToReady } from "#src/host-client/entry-state.ts";
10
+ import { writeRefusal } from "#src/host-client/transfer.ts";
6
11
  import type { ViceSessionClient } from "#src/host-client/vice-session.ts";
12
+ import { projectRoot, resolveProjectPath } from "#src/project.ts";
7
13
 
8
14
  type Registers = Awaited<ReturnType<ViceSessionClient["registersGet"]>>;
9
15
 
@@ -61,8 +67,34 @@ export function vectorsOf(state: MachineState, memory: Buffer) {
61
67
  };
62
68
  }
63
69
 
64
- /** The content of entry_state.json; `differsFromReset` only when the caller compared with a clean reset. */
65
- export function entryState(registers: Registers, state: MachineState, vectors: ReturnType<typeof vectorsOf>, differsFromReset?: Array<{ address: string; entry: number; reset: number }>) {
70
+ /** The state of a clean machine at the READY prompt after a hard reset: what differsFromReset compares with. It resets the machine. */
71
+ export async function readyState(client: ViceSessionClient): Promise<MachineState> {
72
+ await resetToReady(client);
73
+ return machineState(client);
74
+ }
75
+
76
+ /** The bytes that differ from a clean machine: the CPU port, pages 0-3 from $0002, and the I/O areas. */
77
+ export function differsFrom(state: MachineState, clean: MachineState): Array<{ address: string; entry: number; reset: number }> {
78
+ const differs: Array<{ address: string; entry: number; reset: number }> = [];
79
+ const compare = (base: number, entry: Buffer, reset: Buffer) => entry.forEach((value, offset) => value !== reset[offset] && differs.push({ address: formatC64Address(base + offset), entry: value, reset: reset[offset]! }));
80
+ compare(0x0000, state.port, clean.port);
81
+ compare(0x0002, state.pages.subarray(2), clean.pages.subarray(2));
82
+ for (const [name, [address]] of Object.entries(IO_AREAS)) compare(address, state.io[name as keyof typeof IO_AREAS], clean.io[name as keyof typeof IO_AREAS]);
83
+ return differs;
84
+ }
85
+
86
+ /**
87
+ * entry_state.json, the one writer of capture and loads: the state at the
88
+ * entry, and what differs from `clean` (readyState). Gives the file and the
89
+ * count of differing bytes.
90
+ */
91
+ export function entryStateFile(registers: Registers, state: MachineState, vectors: ReturnType<typeof vectorsOf>, clean: MachineState): { bytes: Buffer; differsFromReset: number } {
92
+ const differs = differsFrom(state, clean);
93
+ return { bytes: json(entryState(registers, state, vectors, differs)), differsFromReset: differs.length };
94
+ }
95
+
96
+ /** The content of entry_state.json. */
97
+ function entryState(registers: Registers, state: MachineState, vectors: ReturnType<typeof vectorsOf>, differsFromReset: Array<{ address: string; entry: number; reset: number }>) {
66
98
  return {
67
99
  registers: { pc: formatC64Address(registers.pc), a: registers.a, x: registers.x, y: registers.y, sp: registers.sp, flags: registers.flags },
68
100
  cpuPort: { $00: state.port[0], $01: state.port[1] },
@@ -71,8 +103,34 @@ export function entryState(registers: Registers, state: MachineState, vectors: R
71
103
  // The CIA latches and interrupt masks: write-only on the chip, so the I/O bytes do not hold them.
72
104
  cia: state.cia,
73
105
  vectors,
74
- ...(differsFromReset === undefined ? {} : { differsFromReset }),
106
+ differsFromReset,
75
107
  };
76
108
  }
77
109
 
110
+ /** The names of the files that a capture writes for its content: a PRG for each segment, and the drive RAM. */
111
+ const CAPTURE_FILE = /^(?:segment-[0-9a-f]{4}-[0-9a-f]{4}\.prg|drive8\.prg)$/;
112
+
113
+ /**
114
+ * Removes from the capture folder `folder` (a project path) each file that an
115
+ * earlier capture wrote and this capture did not: a segment PRG of another
116
+ * range, or the drive RAM. image.json lists only the files of this capture.
117
+ * Every other file stays. Gives the removed files as project paths.
118
+ */
119
+ export function removeStaleFiles(folder: string, written: readonly string[]): string[] {
120
+ const path = resolveProjectPath(folder, projectRoot());
121
+ const removed: string[] = [];
122
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
123
+ // A symbolic link is not a file of a capture: it stays.
124
+ if (!entry.isFile() || !CAPTURE_FILE.test(entry.name) || written.includes(entry.name)) continue;
125
+ const file = `${folder}/${entry.name}`;
126
+ try {
127
+ rmSync(join(path, entry.name));
128
+ } catch (error) {
129
+ throw writeRefusal(error, file, "--out") ?? error;
130
+ }
131
+ removed.push(file);
132
+ }
133
+ return removed.sort();
134
+ }
135
+
78
136
  export const json = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
@@ -93,25 +93,33 @@ export function executedIn(map: Uint8Array, block: AddressRange): AddressRange[]
93
93
  return ranges;
94
94
  }
95
95
 
96
+ /** Pages 0-3 ($0000-$03ff) are in entry_state.json: a written block that is all in them is no segment. */
97
+ export const FIRST_SEGMENT_ADDRESS = 0x0400;
98
+
96
99
  /**
97
100
  * The segments of a capture: the blocks that the stages wrote, without loader
98
- * leftovers, joined into ranges that do not overlap or touch. A block
101
+ * leftovers and without blocks that are all in pages 0-3 (the entry state
102
+ * holds them), joined into ranges that do not overlap or touch. A block
99
103
  * is a leftover when it holds the entry of an intermediate stage that started
100
104
  * after the block's writer, and no write from that stage on replaced the code
101
105
  * at that entry. Code that a stage ran before a later stage wrote over
102
106
  * it never drops a block, and the block that holds the capture entry is always
103
- * a segment. The filter comes before the join: a join first would drop the
104
- * program with a leftover. `writtenBy` is the last stage that wrote into the
105
- * range.
107
+ * a segment. A block that an intermediate stage wrote inside a leftover, for
108
+ * example a variable of the loader, is a leftover too. The filter comes before
109
+ * the join: a join first would drop the program with a leftover. `writtenBy` is
110
+ * the last stage that wrote into the range.
106
111
  */
107
112
  export function captureSegments(stages: ReadonlyArray<{ entry: number; wrote: readonly AddressRange[] }>, entry: number): Array<AddressRange & { writtenBy: number }> {
108
113
  const last = stages.length - 1;
109
114
  const replaced = (from: number, address: number) => stages.slice(from).some((stage) => inRanges(stage.wrote, address));
110
115
  const leftover = (block: AddressRange, writer: number) =>
111
116
  !contains(block, entry) && stages.some((stage, index) => index > writer && index < last && contains(block, stage.entry) && !replaced(index, stage.entry));
112
- const blocks = stages
113
- .flatMap((stage, index) => stage.wrote.map((range) => ({ start: range.start, end: range.end, writtenBy: index })))
114
- .filter((block) => !leftover(block, block.writtenBy))
117
+ const written = stages.flatMap((stage, index) => stage.wrote.map((range) => ({ start: range.start, end: range.end, writtenBy: index })));
118
+ const leftovers = written.filter((block) => leftover(block, block.writtenBy));
119
+ const inLeftover = (block: AddressRange & { writtenBy: number }) =>
120
+ block.writtenBy < last && !contains(block, entry) && leftovers.some((left) => block.start >= left.start && block.end <= left.end);
121
+ const blocks = written
122
+ .filter((block) => !leftover(block, block.writtenBy) && !inLeftover(block) && (block.end >= FIRST_SEGMENT_ADDRESS || contains(block, entry)))
115
123
  .sort((a, b) => a.start - b.start);
116
124
  const segments: Array<AddressRange & { writtenBy: number }> = [];
117
125
  for (const block of blocks) {
@@ -9,7 +9,6 @@
9
9
  import { type AddressRange, COVERAGE, EXECUTE, formatC64Address, READ, WRITE } from "#src/c64.ts";
10
10
  import { autostartLoaded, LOAD_LIMIT_FRAMES, RUN_LIMIT_FRAMES, typeRun } from "#src/host-client/autostart.ts";
11
11
  import { readCoverage, writtenBlocks } from "#src/host-client/coverage.ts";
12
- import { resetToReady } from "#src/host-client/entry-state.ts";
13
12
  import { readActionSteps, runActionStep, type ActionStep } from "#src/host-client/steps.ts";
14
13
  import { WireFailure } from "#src/host-client/tools.ts";
15
14
  import { readPrg } from "#src/host-client/image.ts";
@@ -20,7 +19,7 @@ import { listed, OPTIONS, runScript, UsageError, type Call, type CommandSpec, ty
20
19
  import { basicStart, inspect, readMemoryMap, underRom, writtenThenExecuted } from "./lib/evidence.ts";
21
20
  import { loads, readLoadSteps } from "./lib/loads.ts";
22
21
  import { callReturn, captureSegments, executedIn, inRanges, mergeMaps, stageCandidates, stagePoints, withoutRanges } from "./lib/stages.ts";
23
- import { entryState, IO_AREAS, json, machineState, readRam, vectorsOf } from "./lib/machine.ts";
22
+ import { entryStateFile, machineState, readRam, readyState, removeStaleFiles, vectorsOf } from "./lib/machine.ts";
24
23
 
25
24
  /** Written-then-executed bytes from this many on are unpacking, decrypting or relocating, not self-changing code. */
26
25
  const UNPACKED_BYTES = 256;
@@ -165,6 +164,13 @@ async function firstPcAfterBasic(client: ViceSessionClient, from: number, to: nu
165
164
  return from > to ? undefined : firstPcIn(client, [{ start: from, end: to }]);
166
165
  }
167
166
 
167
+ /**
168
+ * What a stage wrote: every written block, also a small one. --min-stage
169
+ * decides only where a stage starts; the wrote list and the capture segments
170
+ * keep each byte that a stage wrote.
171
+ */
172
+ const stageWrote = (map: Uint8Array) => writtenBlocks(map, 1);
173
+
168
174
  /** Frames between two looks at the memory map while stages are followed. */
169
175
  const STAGE_CHUNK_FRAMES = 25;
170
176
  const MAX_STAGES = 16;
@@ -238,7 +244,7 @@ async function follow(
238
244
  let stage = { map: new Uint8Array(0x10000), own: new Set<number>(), called: [] as AddressRange[] };
239
245
  const startStage = async (next: number, last: Uint8Array) => {
240
246
  mergeMaps(stage.map, last);
241
- found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(stage.map, options.minStage), next });
247
+ found.push({ entry, firstFrame, frames: total - firstFrame, wrote: stageWrote(stage.map), next });
242
248
  entry = next;
243
249
  firstFrame = total;
244
250
  stage = { map: new Uint8Array(0x10000), own: new Set<number>(), called: [] };
@@ -428,7 +434,7 @@ async function follow(
428
434
  else reached = await trackTo(options.until, left);
429
435
  await client.snapshot({ action: "remove", name: STAGE_SNAPSHOT }).catch(() => undefined);
430
436
  mergeMaps(stage.map, await readCoverage(client));
431
- found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(stage.map, options.minStage) });
437
+ found.push({ entry, firstFrame, frames: total - firstFrame, wrote: stageWrote(stage.map) });
432
438
  return { stages: found, frames: total, reached, ...(halted === undefined ? {} : { halted }) };
433
439
  }
434
440
 
@@ -529,24 +535,21 @@ async function capture(
529
535
  ),
530
536
  );
531
537
  // A clean machine at the READY prompt shows what the loader and the stages changed.
532
- await resetToReady(client);
533
- const clean = await machineState(client);
534
- const differs: Array<{ address: string; entry: number; reset: number }> = [];
535
- const compare = (base: number, entry: Buffer, reset: Buffer) => entry.forEach((value, offset) => value !== reset[offset] && differs.push({ address: formatC64Address(base + offset), entry: value, reset: reset[offset]! }));
536
- compare(0x0000, state.port, clean.port);
537
- compare(0x0002, state.pages.subarray(2), clean.pages.subarray(2));
538
- for (const [name, [address]] of Object.entries(IO_AREAS)) compare(address, state.io[name as keyof typeof IO_AREAS], clean.io[name as keyof typeof IO_AREAS]);
539
- write("entry_state.json", json(entryState(registers, state, vectors, differs)));
538
+ const entry = entryStateFile(registers, state, vectors, await readyState(client));
539
+ write("entry_state.json", entry.bytes);
540
+ // A capture again into the folder: the segment PRGs of the earlier capture that image.json no longer lists go.
541
+ const removed = removeStaleFiles(options.out, files);
540
542
  return {
541
543
  output: {
542
544
  captured: true,
543
545
  pc: formatC64Address(registers.pc),
544
546
  out: options.out,
545
547
  files: written,
548
+ ...(removed.length === 0 ? {} : { removed }),
546
549
  segments: segments.map(showRange),
547
550
  ...(options.exclude.length === 0 ? {} : { excluded: options.exclude.map(showRange) }),
548
551
  stages: followed.stages.length,
549
- differsFromReset: differs.length,
552
+ differsFromReset: entry.differsFromReset,
550
553
  },
551
554
  failed: false,
552
555
  };
@@ -569,7 +572,7 @@ const entryOf = (call: Call) => {
569
572
  };
570
573
  const stepsOf = (call: Call) => {
571
574
  const path = call.string("steps");
572
- return path === undefined ? [] : readActionSteps(path, "--steps");
575
+ return path === undefined ? [] : readActionSteps(path, "--steps", knowledgeAt());
573
576
  };
574
577
 
575
578
  const COMMANDS: CommandSpec[] = [
@@ -595,7 +598,8 @@ const COMMANDS: CommandSpec[] = [
595
598
  run: async (call) => {
596
599
  const steps = stepsOf(call);
597
600
  // A runUntil step to a raster line or a memory value cannot be tracked in chunks of frames; capture takes it.
598
- if (steps.some((step) => "runUntil" in step && !trackedRunUntil(step))) throw new UsageError("stages takes a runUntil step only with an address target without a condition. Use frames steps, or capture");
601
+ const untracked = steps.findIndex((step) => "runUntil" in step && !trackedRunUntil(step));
602
+ if (untracked >= 0) throw new UsageError(`--steps ${call.string("steps")!}: step ${untracked + 1}: stages takes a runUntil step only with an address target without a condition. Use frames steps, or capture`);
599
603
  return outcome(await stages(call.subject(0), { frames: call.count("frames")!, steps, minStage: call.count("min-stage")!, ...entryOf(call) }));
600
604
  },
601
605
  },
@@ -605,7 +609,7 @@ const COMMANDS: CommandSpec[] = [
605
609
  subjects: [PROGRAM],
606
610
  options: {
607
611
  until: { kind: "address", value: "<address>", required: true, help: "the unpacked entry point" },
608
- out: { ...OPTIONS.out, value: "<dir>", required: true, help: "the folder for the capture, relative to the project folder" },
612
+ out: { ...OPTIONS.out, output: "folder", value: "<dir>", required: true, help: "the folder for the capture, relative to the project folder" },
609
613
  range: { ...OPTIONS.range, help: "one segment in place of the found ones" },
610
614
  exclude: { kind: "range", value: "<start>-<end>", multiple: true, help: "a range to leave out of the segments, for example bytes that a crack left and the program never reads" },
611
615
  "timeout-frames": { ...OPTIONS["timeout-frames"], max: 30_000, default: 3000, help: "stop the wait for --until after n frames (default 3000)" },
@@ -632,7 +636,7 @@ const COMMANDS: CommandSpec[] = [
632
636
  subjects: [{ name: "<program>", help: "a PRG, or a disk, tape or cartridge image, relative to the project folder" }],
633
637
  options: {
634
638
  steps: { ...STEPS, required: true, help: "action steps and capture steps (JSON)" },
635
- out: { ...OPTIONS.out, value: "<dir>", required: true, help: "the folder for the captures, relative to the project folder" },
639
+ out: { ...OPTIONS.out, output: "folder", value: "<dir>", required: true, help: "the folder for the captures, relative to the project folder" },
636
640
  drive: { kind: "flag", help: "also write the 2 KiB drive RAM as drive8.prg, for knowledge space drive8" },
637
641
  "min-block": { kind: "count", value: "<bytes>", min: 1, max: 0x10000, default: 64, help: "write a changed block of at least this many bytes (default 64)" },
638
642
  },
@@ -59,7 +59,10 @@ export function conditionExpression(condition: Condition, space: Space): string
59
59
  }
60
60
  case "memory": {
61
61
  if (condition.space !== "c64") {
62
- throw new WireFailure("unsupported-in-space", "A memory condition can test only space c64 memory.");
62
+ throw new WireFailure(
63
+ "unsupported-in-space",
64
+ "A memory target or a memory condition tests only space c64 memory. For the drive, use a watchpoint in space drive8, or a c64_run_until address target in space drive8.",
65
+ );
63
66
  }
64
67
  return `(@${condition.view}:${formatC64Address(condition.address)} ${operator(condition.operator)} ${formatC64Byte(condition.value)})`;
65
68
  }
@@ -13,7 +13,7 @@ import { setTimeout as sleep } from "node:timers/promises";
13
13
 
14
14
  import { formatC64Address, instructionLength } from "../../c64.ts";
15
15
  import { encodePng, type IndexedFrame } from "../../graphics.ts";
16
- import { instructionText, operandAddress } from "../../opcodes.ts";
16
+ import { accessedValue, instructionText, operandAddress } from "../../opcodes.ts";
17
17
  import type { ProcessSupervisor } from "../../native/processes.ts";
18
18
  import { trace } from "../../trace.ts";
19
19
  import type { ViceSessionFactory, ViceSessionHandle } from "../server.ts";
@@ -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, hasSignature } 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
 
@@ -78,7 +80,6 @@ const WINDOW_CLOSED =
78
80
 
79
81
  /** Emulator state that a move to another VICE cannot carry. */
80
82
  /** The first bytes of every VICE snapshot file. */
81
- const VSF_MAGIC = Buffer.from("VICE Snapshot File\x1a", "latin1");
82
83
  const NOT_CARRIED = ["cpu history", "memory map", "profile", "keyboard input not yet typed"];
83
84
 
84
85
  /** Starts another VICE for this session; `log` also gets that launch's lines. */
@@ -193,6 +194,28 @@ export class ViceSession implements ViceSessionHandle {
193
194
  * pc write, and reports the JAM only once.
194
195
  */
195
196
  #jammed: number | undefined;
197
+ /**
198
+ * An autostart can still be loading: VICE goes on with it after a snapshot
199
+ * restore and writes the program over the restored memory. Only a reset stops it.
200
+ */
201
+ #autostarting = false;
202
+ /** The C64 clock just after the last snapshot restore: VICE keeps the history of before it. */
203
+ #historySince: bigint | undefined;
204
+ /**
205
+ * A breakpoint or watchpoint stopped a step, next or until-return early. VICE
206
+ * keeps the rest of that step and stops the machine once more when it ends.
207
+ * No monitor command ends it without running the CPU, so the session takes
208
+ * that one stop as the end of the step and lets the machine run on.
209
+ */
210
+ #pendingStep = false;
211
+ /**
212
+ * The CPU stands at the stop of a reset without run, and nothing ran since. VICE
213
+ * (3.9, 3.10) stops answering at the restore of a snapshot saved here while the
214
+ * disk drive was busy, so a save here is refused.
215
+ */
216
+ #atResetStop = false;
217
+ /** The CPU that the last run stopped in, when it was the drive: status then gives the drive's pc. */
218
+ #stopSpace: "c64" | "drive8" = "c64";
196
219
  /** VICE records no CPU history after a JAM until the next reset. */
197
220
  #historyStopped = false;
198
221
  /** The CPU ran again after a JAM without a reset: the CPU history that VICE gives is old. */
@@ -396,6 +419,7 @@ export class ViceSession implements ViceSessionHandle {
396
419
  case "autostart": {
397
420
  const { type, index, run } = params as ViceOperations["autostart"]["params"];
398
421
  const bytes = onlyAttachment(attachments);
422
+ checkMedia(type, bytes);
399
423
  if (index > 0 && this.#directory !== undefined && isOneOf(DISK_IMAGE_TYPES, type)) checkDirectoryEntry(await this.#directory(type, bytes), index);
400
424
  const file = this.#stage(bytes, type);
401
425
  // VICE keeps a cartridge through every reset and autostart, as a real C64 does.
@@ -415,6 +439,7 @@ export class ViceSession implements ViceSessionHandle {
415
439
  }
416
440
  await this.#runningCommand(() => this.#machine.autostart(file, index, run));
417
441
  this.#clearJam();
442
+ this.#autostarting = true;
418
443
  if (type === "crt") this.#cartridge = true;
419
444
  this.#replaceStaged(stagedKind(type), file);
420
445
  // A reset sets the CPU clock back, so the stopwatch starts again.
@@ -423,7 +448,9 @@ export class ViceSession implements ViceSessionHandle {
423
448
  }
424
449
  case "diskAttach": {
425
450
  const { type } = params as ViceOperations["diskAttach"]["params"];
426
- const file = this.#stage(onlyAttachment(attachments), type);
451
+ const bytes = onlyAttachment(attachments);
452
+ checkMedia(type, bytes);
453
+ const file = this.#stage(bytes, type);
427
454
  await this.#observe(() => this.#machine.attachDisk(file));
428
455
  this.#replaceStaged("disk", file);
429
456
  return { attached: true };
@@ -543,15 +570,23 @@ export class ViceSession implements ViceSessionHandle {
543
570
  // VICE 3.9 sends no JAM event: the opcode at the pc tells.
544
571
  const stop = this.#lastStop ?? ((await this.#jamAt(status.pc)) ? { stopReason: "jam" as const } : undefined);
545
572
  if (stop !== undefined) Object.assign(status, stop);
573
+ // A point in the drive stopped it: the pc is the drive's, as advance-frames and run-until give it.
574
+ const point = status.pointId === undefined ? undefined : (status.stopReason === "watchpoint" ? this.#watchpoints : this.#breakpoints).get(status.pointId);
575
+ status.space = point?.space === "drive8" || this.#stopSpace === "drive8" ? "drive8" : "c64";
576
+ if (status.space === "drive8") status.pc = (await this.#machine.readRegisters("drive8")).pc;
546
577
  }
547
578
  return status;
548
579
  }
549
580
 
550
581
  async #execution(params: ExecutionParams): Promise<ExecutionResult> {
582
+ if (params.space === "drive8" && (params.action === "pause" || params.action === "resume")) {
583
+ throw new WireFailure("unsupported-in-space", `${params.action} stops or runs the whole machine, the computer and the disk drive together: use space c64.`);
584
+ }
551
585
  if (params.action === "resume") {
552
586
  if (this.#state === "stopped") {
553
587
  await this.#refuseJammed("resume");
554
588
  await this.#resume();
589
+ if (this.#pendingStep) await this.#endPendingStep();
555
590
  }
556
591
  return { state: "running" };
557
592
  }
@@ -580,6 +615,7 @@ export class ViceSession implements ViceSessionHandle {
580
615
  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}.`);
581
616
  }
582
617
  const outcome = await this.#runFrames(params.count, undefined, params.anchor);
618
+ this.#stopSpace = outcome.space;
583
619
  return {
584
620
  state: "stopped",
585
621
  // A stop event carries the computer's PC; a stop in the drive reports the drive's.
@@ -592,6 +628,8 @@ export class ViceSession implements ViceSessionHandle {
592
628
  }
593
629
  await this.#stop();
594
630
  await this.#refuseJammed(params.action);
631
+ // A new step replaces the rest of an earlier one in VICE.
632
+ this.#pendingStep = false;
595
633
  if (params.action === "until-return") {
596
634
  const stopped = this.#nextStop(this.#untilReturnLimitMs);
597
635
  await this.#resumingCommand(() => this.#machine.untilReturn());
@@ -603,18 +641,39 @@ export class ViceSession implements ViceSessionHandle {
603
641
  `The routine did not return within ${this.#untilReturnLimitMs / 1000} seconds. The machine is stopped at ${formatC64Address(await this.#pc())}.`,
604
642
  );
605
643
  }
606
- return { state: "stopped", pc: stop.pc };
644
+ return { state: "stopped", pc: stop.pc, ...this.#stepStoppedByPoint() };
607
645
  }
608
646
  const count = params.count ?? 1;
647
+ this.#atResetStop = false;
609
648
  try {
610
649
  await this.#machine.step(count, params.action === "next", this.#stepLimitMs);
611
650
  } catch (error) {
612
651
  if (error instanceof MonitorTimeoutError) throw new StepTimeout(stepNotEnded(params.action, count, this.#stepLimitMs), error);
613
652
  throw error;
614
653
  }
615
- // VICE answers a step only after the machine stopped again.
654
+ // VICE answers a step only after the machine stopped again; a ping reads the events of that stop (a hit point).
655
+ await this.#machine.stop();
616
656
  this.#state = "stopped";
617
- return { state: "stopped", pc: await this.#pc(), executed: count };
657
+ const point = this.#stepStoppedByPoint();
658
+ // A point stops the step early: VICE does not tell how many instructions ran.
659
+ return { state: "stopped", pc: await this.#pc(), ...(point.pointId === undefined ? { executed: count } : point) };
660
+ }
661
+
662
+ /** The breakpoint or watchpoint that stopped a step, next or until-return early; VICE then keeps the rest of the step. */
663
+ #stepStoppedByPoint(): { stopReason?: PointStopReason; pointId?: number } {
664
+ const stop = this.#lastStop;
665
+ if (stop?.pointId === undefined) return {};
666
+ this.#pendingStep = true;
667
+ return { stopReason: stop.stopReason, pointId: stop.pointId };
668
+ }
669
+
670
+ /** After a resume: waits for the stop that ends the rest of an earlier step, and runs on. */
671
+ async #endPendingStep(): Promise<void> {
672
+ const stop = await this.#nextStop(HALF_FRAME_LIMIT_MS);
673
+ if (stop === undefined) return;
674
+ this.#pendingStep = false;
675
+ // A user point or a JAM stopped the machine: it stays stopped and status tells why.
676
+ if (stop.hits.length === 0 && this.#lastStop === undefined) await this.#resume();
618
677
  }
619
678
 
620
679
  async #reset(params: { mode: "soft" | "hard"; run: boolean }): Promise<{ state: RunState; pc?: number }> {
@@ -639,6 +698,7 @@ export class ViceSession implements ViceSessionHandle {
639
698
  if (!reached) await this.#stop();
640
699
  await this.#machine.deleteCheckpoint(checkpoint);
641
700
  if (!reached) throw new WireFailure("operation-failed", "The C64 did not reach its reset vector after the reset.");
701
+ this.#atResetStop = true;
642
702
  return { state: "stopped", pc: vector };
643
703
  }
644
704
 
@@ -695,6 +755,11 @@ export class ViceSession implements ViceSessionHandle {
695
755
  }
696
756
  if (end === "unknown") {
697
757
  if (await this.#jamAt(stop.pc)) return { end: "jam", frames, pc: stop.pc, space: "c64" };
758
+ if (this.#pendingStep) {
759
+ // The rest of an earlier step ended: run on.
760
+ this.#pendingStep = false;
761
+ continue;
762
+ }
698
763
  throw new WireFailure("operation-failed", "The emulator stopped the machine for a reason outside this run.");
699
764
  }
700
765
  // A drive stop is completed before the result names the computer's pc; `space` says which CPU stopped.
@@ -763,6 +828,10 @@ export class ViceSession implements ViceSessionHandle {
763
828
  const point = this.#userPoint(stop.hits);
764
829
  if (point !== undefined) return { hits, frames, end: point.stopReason, pc: stop.pc };
765
830
  if (await this.#jamAt(stop.pc)) return { hits, frames, end: "jam", pc: stop.pc };
831
+ if (this.#pendingStep) {
832
+ this.#pendingStep = false;
833
+ continue;
834
+ }
766
835
  throw new WireFailure("operation-failed", "The emulator stopped the machine for a reason outside this trace.");
767
836
  }
768
837
  } finally {
@@ -821,10 +890,22 @@ export class ViceSession implements ViceSessionHandle {
821
890
  frame,
822
891
  rasterLine: Math.floor(at / cycles),
823
892
  rasterCycle: at % cycles,
824
- ...(address === undefined ? {} : { address, value: await byteAt(address) }),
893
+ ...(address === undefined ? {} : { address, ...(await this.#accessed(bytes[0]!, entry, address, byteAt)) }),
825
894
  };
826
895
  }
827
896
 
897
+ /**
898
+ * The byte of a read or write hit. A read of an I/O register after the
899
+ * access can give another byte than the access did (raster bits, cleared
900
+ * flags), so for $D000-$DFFF only a load or a store gives a value.
901
+ */
902
+ async #accessed(opcode: number, before: { a: number; x: number; y: number }, address: number, byteAt: (address: number) => Promise<number>): Promise<{ value?: number }> {
903
+ const after = await this.#machine.readRegisters("c64");
904
+ const io = address >= 0xd000 && address <= 0xdfff;
905
+ const value = accessedValue(opcode, before, after, io ? undefined : await byteAt(address));
906
+ return value === undefined ? {} : { value };
907
+ }
908
+
828
909
  /**
829
910
  * Completes a stop that a drive 8 checkpoint caused: the computer runs to
830
911
  * its next instruction boundary, where a computer checkpoint stops it, so
@@ -896,6 +977,9 @@ export class ViceSession implements ViceSessionHandle {
896
977
 
897
978
  /** A reset or an autostart starts a jammed CPU and the CPU history again. */
898
979
  #clearJam(): void {
980
+ this.#autostarting = false;
981
+ // A reset sets the clock back to 0: the history from before it is of this run again.
982
+ this.#historySince = undefined;
899
983
  this.#jammed = undefined;
900
984
  this.#historyStopped = false;
901
985
  this.#historyOld = false;
@@ -952,6 +1036,7 @@ export class ViceSession implements ViceSessionHandle {
952
1036
  const stopReason = outcome.end === "frames" ? "timeout" : outcome.end;
953
1037
  // A stop event carries the computer's PC; a stop in the drive reports the drive's.
954
1038
  const pc = outcome.space === "drive8" ? (await this.#machine.readRegisters("drive8")).pc : outcome.pc;
1039
+ this.#stopSpace = outcome.space;
955
1040
  return { reached: stopReason === "target", stopReason, state: "stopped", pc, space: outcome.space, ...(outcome.pointId === undefined ? {} : { pointId: outcome.pointId }) };
956
1041
  } finally {
957
1042
  await this.#machine.deleteCheckpoint(checkpoint);
@@ -1007,7 +1092,10 @@ export class ViceSession implements ViceSessionHandle {
1007
1092
 
1008
1093
  #point<T>(registry: Map<number, T>, kind: string, id: number): T {
1009
1094
  const point = registry.get(id);
1010
- if (point === undefined) throw new WireFailure("not-found", `There is no ${kind} with id ${id}.`);
1095
+ if (point === undefined) {
1096
+ const other = kind === "breakpoint" ? (this.#watchpoints.has(id) ? "watchpoint" : undefined) : this.#breakpoints.has(id) ? "breakpoint" : undefined;
1097
+ throw new WireFailure("not-found", `There is no ${kind} with id ${id}.${other === undefined ? "" : ` Id ${id} is a ${other}: use c64_${other}.`}`);
1098
+ }
1011
1099
  return point;
1012
1100
  }
1013
1101
 
@@ -1121,9 +1209,14 @@ export class ViceSession implements ViceSessionHandle {
1121
1209
  );
1122
1210
  }
1123
1211
  return this.#observe(async () => {
1124
- const raw = await this.#machine.history(params.limit, params.space);
1125
- if (params.space === "drive8") return { entries: raw.map(({ clock: _clock, ...entry }) => entry) };
1212
+ const all = await this.#machine.history(params.limit, params.space);
1213
+ if (params.space === "drive8") {
1214
+ // VICE records a wrong third byte for a 3-byte drive instruction ("JSR $00a6" for 20 a6 d5): the drive memory gives the bytes.
1215
+ const memory = await this.#machine.readAll("drive8");
1216
+ return { entries: all.map(({ clock: _clock, ...entry }) => driveEntry(entry, memory)) };
1217
+ }
1126
1218
  const now = await this.#machine.clock();
1219
+ const raw = entriesSince(all, this.#historySince, now);
1127
1220
  const position = await this.#machine.rasterPosition();
1128
1221
  const { lines, cycles } = RASTER[this.#videoStandard];
1129
1222
  const frame = BigInt(lines * cycles);
@@ -1197,6 +1290,13 @@ export class ViceSession implements ViceSessionHandle {
1197
1290
  if (params.action === "list") return { snapshots: [...this.#snapshots.keys()] };
1198
1291
  const existing = this.#snapshots.get(params.name);
1199
1292
  if (params.action === "save") {
1293
+ if (this.#atResetStop) {
1294
+ throw new WireFailure(
1295
+ "operation-failed",
1296
+ "The CPU stands at the reset stop of c64_reset with run false. The emulator stops answering at the restore of a snapshot saved here when the disk drive was busy. " +
1297
+ "Step one instruction with c64_execution, then save the snapshot.",
1298
+ );
1299
+ }
1200
1300
  if (existing === undefined && this.#snapshots.size >= MAX_SNAPSHOTS) {
1201
1301
  throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_SNAPSHOTS} snapshots. Remove one first with action remove.`);
1202
1302
  }
@@ -1218,7 +1318,10 @@ export class ViceSession implements ViceSessionHandle {
1218
1318
  async #restoreFrom(file: string): Promise<{ restored: true; state: "stopped" }> {
1219
1319
  await this.#stop();
1220
1320
  const elapsed = (await this.#machine.clock()) - this.#timingStart;
1321
+ // The restore replaces the whole machine state, so the reset that stops the autostart leaves no trace.
1322
+ if (this.#autostarting) await this.#reset({ mode: "soft", run: false });
1221
1323
  await this.#machine.restoreSnapshot(file);
1324
+ this.#historySince = await this.#machine.clock();
1222
1325
  // A restore starts a jammed CPU again, but VICE still records no CPU history.
1223
1326
  this.#jammed = undefined;
1224
1327
  if (this.#historyStopped) this.#historyOld = true;
@@ -1239,7 +1342,7 @@ export class ViceSession implements ViceSessionHandle {
1239
1342
  }
1240
1343
  const bytes = onlyAttachment(attachments);
1241
1344
  // Checked before the machine stops: VICE gives no reason for a file that is no snapshot.
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.");
1345
+ if (!hasSignature("vsf", bytes)) throw new WireFailure("invalid-input", "The file is not a VICE snapshot file (.vsf). Give a file that c64_snapshot save wrote.");
1243
1346
  const file = this.#stage(bytes, "vsf");
1244
1347
  // VICE resets the machine after a snapshot file that it cannot read: a backup keeps the state before the restore.
1245
1348
  const backup = join(this.#workDir, "before-restore.vsf");
@@ -1425,6 +1528,8 @@ export class ViceSession implements ViceSessionHandle {
1425
1528
  * arrive together, and those events must have the last word.
1426
1529
  */
1427
1530
  async #resumingCommand(send: () => Promise<void>): Promise<void> {
1531
+ this.#atResetStop = false;
1532
+ this.#stopSpace = "c64";
1428
1533
  this.#state = "running";
1429
1534
  this.#lastStop = undefined;
1430
1535
  try {
@@ -1534,6 +1639,8 @@ export class ViceSession implements ViceSessionHandle {
1534
1639
 
1535
1640
  async #translate(error: unknown, runningBefore: boolean, halts: number): Promise<unknown> {
1536
1641
  if (error instanceof WireFailure) return error;
1642
+ // A refusal of a shared module (for example the media check) goes on the wire as the host's own.
1643
+ if (isRefusal(error)) return new WireFailure(error.code, error.message);
1537
1644
  if (this.#closed) return new WireFailure("machine-unavailable", "The emulator session is closed.");
1538
1645
  if (error instanceof MonitorConnectionError || error instanceof TextMonitorError || error instanceof StepTimeout) {
1539
1646
  this.#log(error.message);
@@ -1614,6 +1721,32 @@ export class ViceSession implements ViceSessionHandle {
1614
1721
  }
1615
1722
  }
1616
1723
 
1724
+ /** A drive history entry with its bytes and text from drive memory, when the opcode there is still the one that ran. */
1725
+ export function driveEntry<T extends { address: number; bytes: string; text: string }>(entry: T, memory: Uint8Array): T {
1726
+ const opcode = Number.parseInt(entry.bytes.slice(0, 2), 16);
1727
+ if (memory[entry.address] !== opcode) return entry;
1728
+ const length = instructionLength(opcode);
1729
+ const bytes = Array.from({ length }, (_, offset) => memory[(entry.address + offset) & 0xffff]!);
1730
+ return { ...entry, bytes: Buffer.from(bytes).toString("hex"), text: instructionText(bytes, entry.address) };
1731
+ }
1732
+
1733
+ /**
1734
+ * The history entries (oldest first) that the CPU executed after a restore at
1735
+ * clock `since`, with the clock at `now`: the last entries, back to the first
1736
+ * one outside since to now or the first one whose clock does not go up to the
1737
+ * next. The others are of before the restore.
1738
+ */
1739
+ export function entriesSince<T extends { clock: bigint }>(entries: readonly T[], since: bigint | undefined, now: bigint): T[] {
1740
+ if (since === undefined) return [...entries];
1741
+ let first = entries.length;
1742
+ while (first > 0) {
1743
+ const entry = entries[first - 1]!;
1744
+ if (entry.clock < since || entry.clock > now || (first < entries.length && entry.clock >= entries[first]!.clock)) break;
1745
+ first--;
1746
+ }
1747
+ return entries.slice(first);
1748
+ }
1749
+
1617
1750
  /** The one attachment of an operation that takes a file (see attachmentCount); refuses any other count. */
1618
1751
  function onlyAttachment(attachments: Buffer[]): Buffer {
1619
1752
  const [bytes] = attachments;