@henols/c64-re-tools 2.1.1 → 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 (57) 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 +3 -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 +2 -1
  13. package/skills/c64-knowledge/SKILL.md +6 -4
  14. package/skills/c64-knowledge/scripts/knowledge.ts +35 -13
  15. package/skills/c64-knowledge/scripts/lib/listing.ts +8 -30
  16. package/skills/c64-memory-map/SKILL.md +1 -0
  17. package/skills/c64-memory-map/scripts/memory-map.ts +1 -1
  18. package/skills/c64-provenance/SKILL.md +1 -0
  19. package/skills/c64-provenance/scripts/provenance.ts +9 -6
  20. package/skills/c64-source/SKILL.md +1 -0
  21. package/skills/c64-source/scripts/lib/source.ts +7 -29
  22. package/skills/c64-source/scripts/source.ts +4 -4
  23. package/skills/c64-static-analysis/SKILL.md +2 -0
  24. package/skills/c64-static-analysis/scripts/static-analysis.ts +14 -5
  25. package/skills/c64-testing/SKILL.md +3 -2
  26. package/skills/c64-testing/scripts/lib/diff-files.ts +14 -2
  27. package/skills/c64-testing/scripts/lib/recording.ts +3 -3
  28. package/skills/c64-testing/scripts/lib/replay.ts +1 -1
  29. package/skills/c64-testing/scripts/lib/runs.ts +27 -7
  30. package/skills/c64-testing/scripts/testing.ts +5 -5
  31. package/skills/c64-unpacker/SKILL.md +8 -5
  32. package/skills/c64-unpacker/scripts/lib/loads.ts +12 -6
  33. package/skills/c64-unpacker/scripts/lib/machine.ts +30 -3
  34. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  35. package/skills/c64-unpacker/scripts/unpacker.ts +18 -17
  36. package/src/host/vice/adapter.ts +4 -1
  37. package/src/host/vice/session.ts +49 -3
  38. package/src/host-client/image.ts +10 -10
  39. package/src/host-client/steps.ts +39 -3
  40. package/src/host-client/transfer.ts +14 -26
  41. package/src/knowledge/history.ts +55 -2
  42. package/src/knowledge/import.ts +79 -7
  43. package/src/knowledge/program.ts +31 -2
  44. package/src/knowledge/read.ts +7 -4
  45. package/src/knowledge/schema.ts +3 -2
  46. package/src/knowledge/text.ts +1 -0
  47. package/src/knowledge/write.ts +22 -14
  48. package/src/mcp/inputs.ts +6 -3
  49. package/src/mcp/server.ts +2 -1
  50. package/src/mcp/tools/debug.ts +29 -3
  51. package/src/mcp/tools/machine.ts +3 -1
  52. package/src/mcp/tools/memory.ts +4 -3
  53. package/src/media.ts +42 -0
  54. package/src/opcodes.ts +51 -18
  55. package/src/project.ts +110 -2
  56. package/src/protocol/vice.ts +4 -1
  57. package/src/script.ts +55 -3
@@ -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, 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,14 +535,8 @@ 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
540
  return {
541
541
  output: {
542
542
  captured: true,
@@ -546,7 +546,7 @@ async function capture(
546
546
  segments: segments.map(showRange),
547
547
  ...(options.exclude.length === 0 ? {} : { excluded: options.exclude.map(showRange) }),
548
548
  stages: followed.stages.length,
549
- differsFromReset: differs.length,
549
+ differsFromReset: entry.differsFromReset,
550
550
  },
551
551
  failed: false,
552
552
  };
@@ -569,7 +569,7 @@ const entryOf = (call: Call) => {
569
569
  };
570
570
  const stepsOf = (call: Call) => {
571
571
  const path = call.string("steps");
572
- return path === undefined ? [] : readActionSteps(path, "--steps");
572
+ return path === undefined ? [] : readActionSteps(path, "--steps", knowledgeAt());
573
573
  };
574
574
 
575
575
  const COMMANDS: CommandSpec[] = [
@@ -595,7 +595,8 @@ const COMMANDS: CommandSpec[] = [
595
595
  run: async (call) => {
596
596
  const steps = stepsOf(call);
597
597
  // 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");
598
+ const untracked = steps.findIndex((step) => "runUntil" in step && !trackedRunUntil(step));
599
+ 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
600
  return outcome(await stages(call.subject(0), { frames: call.count("frames")!, steps, minStage: call.count("min-stage")!, ...entryOf(call) }));
600
601
  },
601
602
  },
@@ -605,7 +606,7 @@ const COMMANDS: CommandSpec[] = [
605
606
  subjects: [PROGRAM],
606
607
  options: {
607
608
  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" },
609
+ out: { ...OPTIONS.out, output: "folder", value: "<dir>", required: true, help: "the folder for the capture, relative to the project folder" },
609
610
  range: { ...OPTIONS.range, help: "one segment in place of the found ones" },
610
611
  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
612
  "timeout-frames": { ...OPTIONS["timeout-frames"], max: 30_000, default: 3000, help: "stop the wait for --until after n frames (default 3000)" },
@@ -632,7 +633,7 @@ const COMMANDS: CommandSpec[] = [
632
633
  subjects: [{ name: "<program>", help: "a PRG, or a disk, tape or cartridge image, relative to the project folder" }],
633
634
  options: {
634
635
  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" },
636
+ out: { ...OPTIONS.out, output: "folder", value: "<dir>", required: true, help: "the folder for the captures, relative to the project folder" },
636
637
  drive: { kind: "flag", help: "also write the 2 KiB drive RAM as drive8.prg, for knowledge space drive8" },
637
638
  "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
639
  },
@@ -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
  }
@@ -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
 
@@ -193,6 +195,13 @@ export class ViceSession implements ViceSessionHandle {
193
195
  * pc write, and reports the JAM only once.
194
196
  */
195
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;
196
205
  /** VICE records no CPU history after a JAM until the next reset. */
197
206
  #historyStopped = false;
198
207
  /** The CPU ran again after a JAM without a reset: the CPU history that VICE gives is old. */
@@ -396,6 +405,7 @@ export class ViceSession implements ViceSessionHandle {
396
405
  case "autostart": {
397
406
  const { type, index, run } = params as ViceOperations["autostart"]["params"];
398
407
  const bytes = onlyAttachment(attachments);
408
+ checkMedia(type, bytes);
399
409
  if (index > 0 && this.#directory !== undefined && isOneOf(DISK_IMAGE_TYPES, type)) checkDirectoryEntry(await this.#directory(type, bytes), index);
400
410
  const file = this.#stage(bytes, type);
401
411
  // VICE keeps a cartridge through every reset and autostart, as a real C64 does.
@@ -415,6 +425,7 @@ export class ViceSession implements ViceSessionHandle {
415
425
  }
416
426
  await this.#runningCommand(() => this.#machine.autostart(file, index, run));
417
427
  this.#clearJam();
428
+ this.#autostarting = true;
418
429
  if (type === "crt") this.#cartridge = true;
419
430
  this.#replaceStaged(stagedKind(type), file);
420
431
  // A reset sets the CPU clock back, so the stopwatch starts again.
@@ -423,7 +434,9 @@ export class ViceSession implements ViceSessionHandle {
423
434
  }
424
435
  case "diskAttach": {
425
436
  const { type } = params as ViceOperations["diskAttach"]["params"];
426
- const file = this.#stage(onlyAttachment(attachments), type);
437
+ const bytes = onlyAttachment(attachments);
438
+ checkMedia(type, bytes);
439
+ const file = this.#stage(bytes, type);
427
440
  await this.#observe(() => this.#machine.attachDisk(file));
428
441
  this.#replaceStaged("disk", file);
429
442
  return { attached: true };
@@ -543,11 +556,18 @@ export class ViceSession implements ViceSessionHandle {
543
556
  // VICE 3.9 sends no JAM event: the opcode at the pc tells.
544
557
  const stop = this.#lastStop ?? ((await this.#jamAt(status.pc)) ? { stopReason: "jam" as const } : undefined);
545
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;
546
563
  }
547
564
  return status;
548
565
  }
549
566
 
550
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
+ }
551
571
  if (params.action === "resume") {
552
572
  if (this.#state === "stopped") {
553
573
  await this.#refuseJammed("resume");
@@ -896,6 +916,9 @@ export class ViceSession implements ViceSessionHandle {
896
916
 
897
917
  /** A reset or an autostart starts a jammed CPU and the CPU history again. */
898
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;
899
922
  this.#jammed = undefined;
900
923
  this.#historyStopped = false;
901
924
  this.#historyOld = false;
@@ -1121,9 +1144,10 @@ export class ViceSession implements ViceSessionHandle {
1121
1144
  );
1122
1145
  }
1123
1146
  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) };
1147
+ const all = await this.#machine.history(params.limit, params.space);
1148
+ if (params.space === "drive8") return { entries: all.map(({ clock: _clock, ...entry }) => entry) };
1126
1149
  const now = await this.#machine.clock();
1150
+ const raw = entriesSince(all, this.#historySince, now);
1127
1151
  const position = await this.#machine.rasterPosition();
1128
1152
  const { lines, cycles } = RASTER[this.#videoStandard];
1129
1153
  const frame = BigInt(lines * cycles);
@@ -1218,7 +1242,10 @@ export class ViceSession implements ViceSessionHandle {
1218
1242
  async #restoreFrom(file: string): Promise<{ restored: true; state: "stopped" }> {
1219
1243
  await this.#stop();
1220
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 });
1221
1247
  await this.#machine.restoreSnapshot(file);
1248
+ this.#historySince = await this.#machine.clock();
1222
1249
  // A restore starts a jammed CPU again, but VICE still records no CPU history.
1223
1250
  this.#jammed = undefined;
1224
1251
  if (this.#historyStopped) this.#historyOld = true;
@@ -1534,6 +1561,8 @@ export class ViceSession implements ViceSessionHandle {
1534
1561
 
1535
1562
  async #translate(error: unknown, runningBefore: boolean, halts: number): Promise<unknown> {
1536
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);
1537
1566
  if (this.#closed) return new WireFailure("machine-unavailable", "The emulator session is closed.");
1538
1567
  if (error instanceof MonitorConnectionError || error instanceof TextMonitorError || error instanceof StepTimeout) {
1539
1568
  this.#log(error.message);
@@ -1614,6 +1643,23 @@ export class ViceSession implements ViceSessionHandle {
1614
1643
  }
1615
1644
  }
1616
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
+
1617
1663
  /** The one attachment of an operation that takes a file (see attachmentCount); refuses any other count. */
1618
1664
  function onlyAttachment(attachments: Buffer[]): Buffer {
1619
1665
  const [bytes] = attachments;
@@ -36,6 +36,12 @@ const MEDIA_KINDS: Record<string, string> = {
36
36
  /** The remedy for a disk, tape or cartridge file where a PRG must be: get the PRG out of it. */
37
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
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
+
39
45
  /**
40
46
  * Reads a PRG of the project: a 2-byte load address, and bytes that end at
41
47
  * $ffff or before. Refuses a disk, tape or cartridge file and any other file
@@ -43,8 +49,7 @@ export const MEDIA_REMEDY = "Get the PRG out of it with disk.ts read (or disk.ts
43
49
  */
44
50
  export function readPrg(path: string, remedy = MEDIA_REMEDY): { bytes: Buffer; start: number; end: number; body: Uint8Array } {
45
51
  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}.`);
52
+ refuseMedia(path, type, remedy);
48
53
  try {
49
54
  return { bytes, ...prgRange(bytes) };
50
55
  } catch (error) {
@@ -53,18 +58,13 @@ export function readPrg(path: string, remedy = MEDIA_REMEDY): { bytes: Buffer; s
53
58
  }
54
59
  }
55
60
 
56
- /** Reads an image.json or a PRG, relative to the project folder. */
61
+ /** Reads an image.json or a PRG, relative to the project folder. Refuses a disk, tape or cartridge file as readPrg does. */
57
62
  export function readImage(path: string): Image {
58
- const { bytes } = readProjectFile(path);
59
63
  if (!path.endsWith(".json")) {
60
- let range: ReturnType<typeof prgRange>;
61
- try {
62
- range = prgRange(bytes);
63
- } catch (error) {
64
- throw new Refusal("invalid-input", `${path}: ${(error as Error).message}`);
65
- }
64
+ const range = readPrg(path);
66
65
  return { segments: [{ start: range.start, end: range.end }], byteAt: (address) => range.body[address - range.start]! };
67
66
  }
67
+ const { bytes } = readProjectFile(path);
68
68
  const fail = (what: string): never => {
69
69
  throw new Refusal("invalid-input", `${path} is not an image.json of the c64-unpacker skill: ${what}`);
70
70
  };
@@ -384,8 +384,42 @@ export function checkActionSteps(value: unknown, fail: (message: string) => neve
384
384
  });
385
385
  }
386
386
 
387
- /** Reads a steps file of the project: a JSON list of action steps. `option` names the option of the file in a refusal. */
388
- 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[] {
389
423
  const fail = (message: string): never => {
390
424
  throw new Refusal("invalid-input", `${option} ${path}: ${message}`);
391
425
  };
@@ -396,7 +430,9 @@ export function readActionSteps(path: string, option: string): ActionStep[] {
396
430
  if (error instanceof SyntaxError) return fail(`it is not JSON: ${error.message}`);
397
431
  throw error;
398
432
  }
399
- return checkActionSteps(value, fail);
433
+ const steps = checkActionSteps(value, fail);
434
+ if (at !== undefined) checkStepNames(steps, at, `${option} ${path}`);
435
+ return steps;
400
436
  }
401
437
 
402
438
  /** Stops the CPU when it runs: a write, a register change or an exact frame count needs it stopped. */
@@ -2,10 +2,10 @@
2
2
  // The host never sees a client path: only bytes and the file's type.
3
3
 
4
4
  import { createHash } from "node:crypto";
5
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
5
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
6
6
  import { dirname, extname, join, relative, sep } from "node:path";
7
7
 
8
- import { isInside, projectRoot, resolveProject, resolveProjectFile, resolveProjectPath } from "../project.ts";
8
+ import { checkOutputPath, isInside, projectRoot, resolveProject, resolveProjectFile, writeProblem } from "../project.ts";
9
9
  import { isRefusal } from "../errors.ts";
10
10
  import { MAX_ATTACHMENT_BYTES } from "../protocol/framing.ts";
11
11
  import { WireFailure } from "../protocol/messages.ts";
@@ -58,49 +58,37 @@ export function readProjectFile(path: string, options: { root?: string } = {}):
58
58
  return { bytes: readFileSync(real), type: extname(path).slice(1).toLowerCase() };
59
59
  }
60
60
 
61
- /** Why a file cannot be written, by the error code of the file system; the codes that are a fault of the path. */
62
- const WRITE_PROBLEMS: Record<string, string> = {
63
- EISDIR: "is a folder. Give the path of a file",
64
- EEXIST: "has a file where a folder must be. Give a path whose folders are folders",
65
- ENOTDIR: "has a file where a folder must be. Give a path whose folders are folders",
66
- ENOENT: "is in a folder that does not exist",
67
- EACCES: "cannot be written: there is no permission. Give a path that you can write",
68
- EPERM: "cannot be written: there is no permission. Give a path that you can write",
69
- EROFS: "is on a file system that is read-only. Give another path",
70
- ENAMETOOLONG: "is too long. Give a shorter path",
71
- };
72
-
73
61
  /**
74
62
  * The refusal for a write that the file system refused because of the path
75
63
  * (a folder, a file in place of a folder, no permission): invalid-input with
76
64
  * the path. Undefined for another error, which is no fault of the path.
77
65
  */
78
66
  export function writeRefusal(error: unknown, path: string, what?: string): WireFailure | undefined {
79
- const problem = WRITE_PROBLEMS[(error as NodeJS.ErrnoException | undefined)?.code ?? ""];
80
- return problem === undefined ? undefined : new WireFailure("invalid-input", `${what === undefined ? "" : `${what}: `}${path} ${problem}.`);
67
+ const message = writeProblem(error, path, what);
68
+ return message === undefined ? undefined : new WireFailure("invalid-input", message);
81
69
  }
82
70
 
83
71
  /**
84
72
  * Writes bytes to a project file on the client side, making its directories
85
- * and overwriting it. Throws WireFailure(invalid-input) for a path outside the
86
- * project, also one that leaves it through a symbolic link, and for a path
87
- * that the file system refuses: a folder, a file in place of a folder, no permission.
73
+ * and overwriting it. First the check of checkOutputPath, which a script also
74
+ * does on its output options before any work. Throws WireFailure(invalid-input)
75
+ * for a path outside the project, also one that leaves it through a symbolic
76
+ * link, for a path in the knowledge, and for a path that the file system
77
+ * refuses: a folder, a file in place of a folder, no permission.
88
78
  */
89
79
  export function writeProjectFile(path: string, bytes: Uint8Array, options: { root?: string; what?: string } = {}): void {
90
80
  const root = options.root ?? currentProject();
91
81
  const refuse = () => new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
92
82
  let file: string;
93
83
  try {
94
- file = resolveProjectPath(path, root);
95
- } catch {
96
- throw refuse();
84
+ file = checkOutputPath(path, "file", { root, ...(options.what === undefined ? {} : { what: options.what }) });
85
+ } catch (error) {
86
+ if (isRefusal(error)) throw new WireFailure(error.code, error.message);
87
+ throw error;
97
88
  }
98
- // The nearest folder that exists must be inside the project before a folder is made below it.
99
- let existing = dirname(file);
100
- while (!existsSync(existing)) existing = dirname(existing);
101
- if (!isInside(realpathSync(root), realpathSync(existing))) throw refuse();
102
89
  try {
103
90
  mkdirSync(dirname(file), { recursive: true });
91
+ // Again after the folders are made: another process can change the folders between the check and the write.
104
92
  if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
105
93
  let link = false;
106
94
  try {
@@ -37,7 +37,7 @@ import {
37
37
  type SuppressionRow,
38
38
  type SymbolRow,
39
39
  } from "./read.ts";
40
- import { DEFAULT_SPACE, type EvidenceKind, type Origin, type Space } from "./schema.ts";
40
+ import { DEFAULT_SPACE, type CoverageKind, type EvidenceKind, type Origin, type Space } from "./schema.ts";
41
41
  import { compareText, NO_REVISION } from "./text.ts";
42
42
  import type { Evidence } from "./write.ts";
43
43
 
@@ -51,12 +51,47 @@ export interface Revision {
51
51
  toolVersion?: string;
52
52
  /** A revert: the revision whose knowledge it restored. */
53
53
  restores?: string;
54
+ /** A revert of one space: that space. A revert without it restored every space. */
55
+ restoresSpace?: Space;
54
56
  /** What the change rests on. */
55
57
  evidence?: Evidence[];
56
58
  }
57
59
 
60
+ /** A memory row of the program: the bytes from an address. */
61
+ export interface MemoryRow {
62
+ space: Space;
63
+ address: number;
64
+ /** The count of bytes in the row. */
65
+ bytes: number;
66
+ origin: Origin;
67
+ revision: string;
68
+ }
69
+
70
+ /** The entry of the program, and whether it has a machine state and stages. */
71
+ export interface EntryRow {
72
+ space: Space;
73
+ address: number;
74
+ state: boolean;
75
+ stages: number;
76
+ origin: Origin;
77
+ revision: string;
78
+ }
79
+
80
+ /** A coverage range of one kind. */
81
+ export interface CoverageRow {
82
+ space: Space;
83
+ kind: CoverageKind;
84
+ start: number;
85
+ end: number;
86
+ origin: Origin;
87
+ revision: string;
88
+ }
89
+
58
90
  /** One knowledge row with its validity: from its first revision until the revision that closed it. */
59
91
  export type HistoryEntry =
92
+ | { entity: "memory"; row: MemoryRow; from: string; to?: string }
93
+ | { entity: "entry"; row: EntryRow; from: string; to?: string }
94
+ | { entity: "coverage"; row: CoverageRow; from: string; to?: string }
60
95
  | { entity: "symbol"; row: SymbolRow; from: string; to?: string }
61
96
  | { entity: "analyzer-name"; row: SymbolRow; from: string; to?: string }
62
97
  | { entity: "region"; row: RegionRow; from: string; to?: string }
@@ -82,6 +117,7 @@ function toRevision(row: Raw): Revision {
82
117
  if (row.input_hash !== null) revision.inputHash = row.input_hash as string;
83
118
  if (row.tool_version !== null) revision.toolVersion = row.tool_version as string;
84
119
  if (row.restored_revision !== null) revision.restores = row.restored_revision as string;
120
+ if (row.restored_space !== null && row.restored_space !== undefined) revision.restoresSpace = row.restored_space as Space;
85
121
  return revision;
86
122
  }
87
123
 
@@ -183,6 +219,7 @@ export interface InitialRevision {
183
219
  inputHash?: undefined;
184
220
  toolVersion?: undefined;
185
221
  restores?: undefined;
222
+ restoresSpace?: undefined;
186
223
  evidence?: undefined;
187
224
  }
188
225
 
@@ -213,9 +250,25 @@ export function revision(db: DatabaseSync | undefined, text: string): RevisionCh
213
250
  ["macro", "macros", MACRO_COLUMNS, toMacro],
214
251
  ["macro-use", "macro_uses", MACRO_USE_COLUMNS, toMacroUse],
215
252
  ] as const;
253
+ // The program: its memory, its entry and its coverage.
254
+ const program = [
255
+ ["memory", "memory", "space, address, length(bytes) / 2 AS bytes, origin, valid_from_revision", (row: Raw): MemoryRow => ({ space: row.space as Space, address: row.address as number, bytes: row.bytes as number, origin: row.origin as Origin, revision: row.valid_from_revision as string })],
256
+ [
257
+ "entry",
258
+ "entries",
259
+ "space, address, state IS NOT NULL AS has_state, json_array_length(coalesce(stages, '[]')) AS stage_count, origin, valid_from_revision",
260
+ (row: Raw): EntryRow => ({ space: row.space as Space, address: row.address as number, state: row.has_state === 1, stages: row.stage_count as number, origin: row.origin as Origin, revision: row.valid_from_revision as string }),
261
+ ],
262
+ [
263
+ "coverage",
264
+ "coverage",
265
+ "space, kind, start_address, end_address, origin, valid_from_revision",
266
+ (row: Raw): CoverageRow => ({ space: row.space as Space, kind: row.kind as CoverageKind, start: row.start_address as number, end: row.end_address as number, origin: row.origin as Origin, revision: row.valid_from_revision as string }),
267
+ ],
268
+ ] as const;
216
269
  const added: HistoryEntry[] = [];
217
270
  const closed: HistoryEntry[] = [];
218
- for (const [entity, table, columns, map] of tables) {
271
+ for (const [entity, table, columns, map] of [...tables, ...program]) {
219
272
  for (const row of db.prepare(`SELECT ${columns}, valid_to_revision FROM ${table} WHERE valid_from_revision = ?`).all(id) as Raw[]) {
220
273
  added.push(entry(entity, map(row), row));
221
274
  }
@@ -77,7 +77,18 @@ export type ImportConflict =
77
77
  current: { address: number; name: string; kind: SymbolKind; origin: Origin };
78
78
  finding: { name: string; kind: SymbolKind };
79
79
  }
80
- | { category: "region"; start: number; end: number; current: { type: RegionType; origin: Origin }; finding: { type: RegionType } };
80
+ | { category: "region"; start: number; end: number; current: { type: RegionType; origin: Origin }; finding: { type: RegionType } }
81
+ | {
82
+ /** A data finding over code that a run executed or that the user or the LLM named: the finding is not imported. */
83
+ category: "code-evidence";
84
+ start: number;
85
+ end: number;
86
+ finding: { type: RegionType };
87
+ /** The first executed instruction start in the range. */
88
+ executed?: number;
89
+ /** The first code name (label or routine) of the user or the LLM in the range. */
90
+ symbol?: { address: number; name: string; kind: SymbolKind; origin: Origin };
91
+ };
81
92
 
82
93
  export interface ImportCounts {
83
94
  added: number;
@@ -252,16 +263,53 @@ const YIELDS_TO_HEURISTIC = new Set<Origin>(["dxa", "ghidra"]);
252
263
  /** Analyzers whose data gives way to a code finding over an executed instruction start. */
253
264
  const YIELDS_TO_EXECUTED_CODE = new Set<Origin>(["dxa", "ghidra"]);
254
265
 
266
+ /** The first address in the range that is an executed instruction start, or undefined. */
267
+ function firstExecuted(findings: NormalizedFindings, start: number, end: number): number | undefined {
268
+ if (findings.executed === undefined) return undefined;
269
+ for (let address = start; address <= end; address++) if (findings.executed(address)) return address;
270
+ return undefined;
271
+ }
272
+
255
273
  /** True when an address in the range is an executed instruction start. */
256
274
  function holdsExecuted(findings: NormalizedFindings, start: number, end: number): boolean {
257
- if (findings.executed === undefined) return false;
258
- for (let address = start; address <= end; address++) if (findings.executed(address)) return true;
259
- return false;
275
+ return firstExecuted(findings, start, end) !== undefined;
260
276
  }
261
277
 
262
- function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
263
- const { analyzer } = findings;
264
- const space = findings.space ?? DEFAULT_SPACE;
278
+ /**
279
+ * The data findings of DXA or Ghidra that code evidence contradicts: an
280
+ * executed instruction start, or a code name (label or routine) of the user or
281
+ * the LLM, in the range. Evidence wins over an analyzer guess: such a finding
282
+ * is not imported, and the analyzer's own code there stays.
283
+ */
284
+ function contradictedData(db: DatabaseSync, findings: NormalizedFindings, space: Space): Array<ImportConflict & { category: "code-evidence" }> {
285
+ if (!YIELDS_TO_EXECUTED_CODE.has(findings.analyzer)) return [];
286
+ const names = db.prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_symbols WHERE space = ? AND origin IN ('user', 'llm') AND kind IN ('label', 'routine') AND address BETWEEN ? AND ? ORDER BY address LIMIT 1`);
287
+ const found: Array<ImportConflict & { category: "code-evidence" }> = [];
288
+ for (const region of findings.regions) {
289
+ if (isCode(region.type) || region.type === "dual-use") continue;
290
+ const executed = firstExecuted(findings, region.start, region.end);
291
+ const raw = names.get(space, region.start, region.end) as Record<string, unknown> | undefined;
292
+ if (executed === undefined && raw === undefined) continue;
293
+ const symbol = raw === undefined ? undefined : toSymbol(raw);
294
+ found.push({
295
+ category: "code-evidence",
296
+ start: region.start,
297
+ end: region.end,
298
+ finding: { type: region.type },
299
+ ...(executed === undefined ? {} : { executed }),
300
+ ...(symbol === undefined ? {} : { symbol: { address: symbol.address, name: symbol.name, kind: symbol.kind, origin: symbol.origin } }),
301
+ });
302
+ }
303
+ return found;
304
+ }
305
+
306
+ function importRegions(db: DatabaseSync, change: Change, given: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
307
+ const { analyzer } = given;
308
+ const space = given.space ?? DEFAULT_SPACE;
309
+ // Data findings over code evidence are conflicts, not facts.
310
+ const contradicted = contradictedData(db, given, space);
311
+ result.conflicts.push(...contradicted);
312
+ const findings = { ...given, regions: given.regions.filter((region) => !contradicted.some((item) => item.start === region.start && item.end === region.end)) };
265
313
  if (analyzer === "heuristic") {
266
314
  // An idiom is specific evidence about its bytes: the DXA or Ghidra region there gives way, its parts outside stay.
267
315
  for (const region of findings.regions) {
@@ -333,6 +381,14 @@ function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFin
333
381
  if (suppressed.length > 0) result.regions.suppressed++;
334
382
  for (const piece of subtract(region, [...protectedRows, ...suppressed])) desired.set(`${piece.start}-${piece.end}`, { ...piece, type: region.type });
335
383
  }
384
+ // Under a contradicted data finding, the analyzer's own regions stay: they join the findings of the same type next to them.
385
+ for (const row of own) {
386
+ for (const item of contradicted) {
387
+ const inside = { start: Math.max(row.start, item.start, range.start), end: Math.min(row.end, item.end, range.end) };
388
+ if (inside.start > inside.end) continue;
389
+ for (const piece of subtract(inside, [...protectedRows, ...suppressionsOverlapping(db, "region", inside.start, inside.end, space)])) keepPiece(desired, { ...piece, type: row.type });
390
+ }
391
+ }
336
392
  const retiredRanges = new Set<string>();
337
393
  for (const row of own) {
338
394
  const inside = { start: Math.max(row.start, range.start), end: Math.min(row.end, range.end) };
@@ -357,6 +413,22 @@ function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFin
357
413
  }
358
414
  }
359
415
 
416
+ /** Puts a kept piece into the desired regions, joined with the pieces of the same type that touch or overlap it. */
417
+ function keepPiece(desired: Map<string, { start: number; end: number; type: RegionType }>, piece: { start: number; end: number; type: RegionType }): void {
418
+ let joined = { ...piece };
419
+ for (let found = true; found; ) {
420
+ found = false;
421
+ for (const [key, other] of desired) {
422
+ if (other.type !== joined.type || other.end + 1 < joined.start || joined.end + 1 < other.start) continue;
423
+ desired.delete(key);
424
+ joined = { start: Math.min(joined.start, other.start), end: Math.max(joined.end, other.end), type: joined.type };
425
+ found = true;
426
+ break;
427
+ }
428
+ }
429
+ desired.set(`${joined.start}-${joined.end}`, joined);
430
+ }
431
+
360
432
  function importReferences(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
361
433
  const { analyzer } = findings;
362
434
  const space = findings.space ?? DEFAULT_SPACE;