@henols/c64-re-tools 2.0.0-rc.3 → 2.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +15 -3
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +5 -2
  4. package/skills/c64-assembler/scripts/assemble.ts +8 -1
  5. package/skills/c64-assembler/scripts/compare.ts +84 -0
  6. package/skills/c64-basic/SKILL.md +1 -2
  7. package/skills/c64-disk/SKILL.md +20 -6
  8. package/skills/c64-disk/scripts/containers.ts +124 -0
  9. package/skills/c64-disk/scripts/disk.ts +59 -5
  10. package/skills/c64-emulator/SKILL.md +11 -4
  11. package/skills/c64-knowledge/SKILL.md +53 -3
  12. package/skills/c64-knowledge/scripts/knowledge.ts +258 -29
  13. package/skills/c64-memory-map/SKILL.md +4 -1
  14. package/skills/c64-memory-map/scripts/memmap.ts +14 -3
  15. package/skills/c64-provenance/SKILL.md +37 -9
  16. package/skills/c64-provenance/scripts/compare.ts +60 -29
  17. package/skills/c64-provenance/scripts/provenance.ts +248 -19
  18. package/skills/c64-provenance/scripts/runtime.ts +179 -0
  19. package/skills/c64-provenance/scripts/separate.ts +132 -0
  20. package/skills/c64-reverse-engineering/SKILL.md +65 -11
  21. package/skills/c64-source/SKILL.md +119 -0
  22. package/skills/c64-source/scripts/generate.ts +402 -0
  23. package/skills/c64-source/scripts/loader.ts +320 -0
  24. package/skills/c64-source/scripts/opcodes.ts +61 -0
  25. package/skills/c64-source/scripts/source.ts +558 -0
  26. package/skills/c64-static-analysis/SKILL.md +27 -4
  27. package/skills/c64-static-analysis/scripts/analyze.ts +102 -13
  28. package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
  29. package/skills/c64-static-analysis/scripts/findings.ts +6 -4
  30. package/skills/c64-static-analysis/scripts/idioms.ts +144 -0
  31. package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
  32. package/skills/c64-testing/SKILL.md +27 -3
  33. package/skills/c64-testing/references/scenario-format.md +43 -3
  34. package/skills/c64-testing/scripts/run.ts +189 -0
  35. package/skills/c64-testing/scripts/scenario.ts +153 -135
  36. package/skills/c64-testing/scripts/test.ts +16 -6
  37. package/skills/c64-unpacker/SKILL.md +65 -14
  38. package/skills/c64-unpacker/scripts/evidence.ts +86 -21
  39. package/skills/c64-unpacker/scripts/loads.ts +174 -0
  40. package/skills/c64-unpacker/scripts/machine.ts +68 -0
  41. package/skills/c64-unpacker/scripts/unpack.ts +302 -48
  42. package/src/c64.ts +91 -0
  43. package/src/cli/status.ts +3 -1
  44. package/src/host/server.ts +6 -5
  45. package/src/host/vice/adapter.ts +25 -10
  46. package/src/host/vice/session.ts +64 -20
  47. package/src/host-client/autostart.ts +45 -0
  48. package/src/host-client/connect.ts +4 -2
  49. package/src/host-client/coverage.ts +59 -0
  50. package/src/host-client/steps.ts +170 -0
  51. package/src/host-client/transfer.ts +29 -3
  52. package/src/host-client/vice-session.ts +42 -13
  53. package/src/knowledge/history.ts +40 -13
  54. package/src/knowledge/import.ts +72 -56
  55. package/src/knowledge/read.ts +203 -52
  56. package/src/knowledge/schema.ts +94 -12
  57. package/src/knowledge/write.ts +432 -123
  58. package/src/mcp/output.ts +32 -0
  59. package/src/mcp/server.ts +4 -2
  60. package/src/mcp/tools/debug.ts +110 -24
  61. package/src/mcp/tools/input.ts +1 -0
  62. package/src/mcp/tools/media.ts +22 -6
  63. package/src/mcp/tools/memory.ts +57 -10
  64. package/src/mcp/tools/video.ts +9 -3
  65. package/src/native/discover.ts +5 -2
  66. package/src/native/ghidra/index.ts +19 -3
  67. package/src/native/ghidra/scripts/C64Export.java +33 -1
  68. package/src/native/status.ts +15 -3
  69. package/src/native/types.ts +30 -1
  70. package/{skills/c64-memory-map/scripts → src}/platform.ts +18 -2
  71. package/src/protocol/messages.ts +17 -1
  72. package/src/protocol/vice.ts +72 -18
  73. package/src/script.ts +3 -2
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { WireFailure, type VideoStandard } from "../../protocol/messages.ts";
5
5
  import { type Comparison, type Condition, type BacktraceFrame, type HistoryEntry, type Instruction, type MemmapRange, type ProfileEntry, type JoystickState, type MemoryView, type RegisterValues, type Registers, type Space } from "../../protocol/vice.ts";
6
- import { formatC64Address, formatC64Byte } from "../../c64.ts";
6
+ import { COVERAGE, formatC64Address, formatC64Byte } from "../../c64.ts";
7
7
  import { decodeDisplay, decodePalette, visibleFrame, type IndexedFrame } from "./screen.ts";
8
8
  import type { TextMonitor } from "./text-monitor.ts";
9
9
  import {
@@ -258,12 +258,27 @@ function unknownForm(what: string, lines: string[]): WireFailure {
258
258
  return new WireFailure("operation-failed", `VICE printed the ${what} in a form that c64-re-tools does not read: ${JSON.stringify(lines.slice(0, 3))}`);
259
259
  }
260
260
 
261
+ /** The coverage byte (D40) of one memmap row's I/O, ROM and RAM columns, each "rwx" with "-" for no access. */
262
+ export function memmapFlags(io: string, rom: string, ram: string): number {
263
+ let flags = 0;
264
+ if (ram[0] === "r") flags |= COVERAGE.ramRead;
265
+ if (ram[1] === "w") flags |= COVERAGE.ramWrite;
266
+ if (ram[2] === "x") flags |= COVERAGE.ramExecute;
267
+ if (rom[0] === "r") flags |= COVERAGE.romRead;
268
+ if (rom[2] === "x") flags |= COVERAGE.romExecute;
269
+ if (io[0] === "r") flags |= COVERAGE.ioRead;
270
+ if (io[1] === "w") flags |= COVERAGE.ioWrite;
271
+ if (io[2] === "x") flags |= COVERAGE.ioExecute;
272
+ return flags;
273
+ }
274
+
261
275
  /**
262
276
  * Parses memmap rows such as "00c2: --- --- rw- (uninitialized read)": the I/O,
263
277
  * ROM and RAM access flags of one address. Adjacent addresses with the same
264
- * combined flags merge into one range; at most `maxRanges` come back.
278
+ * flags merge into one range; at most `maxRanges` come back. When more ranges
279
+ * follow, `next` is the first touched address that is not reported.
265
280
  */
266
- export function parseMemmap(answer: string, maxRanges: number): MemmapRange[] {
281
+ export function parseMemmap(answer: string, maxRanges: number): { ranges: MemmapRange[]; next?: number } {
267
282
  const ranges: MemmapRange[] = [];
268
283
  const unknown: string[] = [];
269
284
  for (const line of answer.split("\n")) {
@@ -273,18 +288,18 @@ export function parseMemmap(answer: string, maxRanges: number): MemmapRange[] {
273
288
  continue;
274
289
  }
275
290
  const address = Number.parseInt(match[1]!, 16);
276
- const flags = match[2]! + match[3]! + match[4]!;
277
- const access = { execute: flags.includes("x"), read: flags.includes("r"), write: flags.includes("w") };
291
+ const flags = memmapFlags(match[2]!, match[3]!, match[4]!);
292
+ if (flags === 0) continue;
278
293
  const last = ranges.at(-1);
279
- if (last !== undefined && last.end === address - 1 && last.execute === access.execute && last.read === access.read && last.write === access.write) {
294
+ if (last !== undefined && last.end === address - 1 && last.flags === flags) {
280
295
  last.end = address;
281
296
  continue;
282
297
  }
283
- if (ranges.length >= maxRanges) break;
284
- ranges.push({ start: address, end: address, ...access });
298
+ if (ranges.length >= maxRanges) return { ranges, next: address };
299
+ ranges.push({ start: address, end: address, flags });
285
300
  }
286
301
  if (ranges.length === 0 && unknown.length > 0) throw unknownForm("memory map", unknown);
287
- return ranges;
302
+ return { ranges };
288
303
  }
289
304
 
290
305
  /**
@@ -739,7 +754,7 @@ export class ViceAdapter {
739
754
  }
740
755
 
741
756
  /** How the C64 CPU accessed [start, end] since the memmap was last cleared, as merged ranges. */
742
- async memmap(start: number, end: number, maxRanges: number): Promise<MemmapRange[]> {
757
+ async memmap(start: number, end: number, maxRanges: number): Promise<{ ranges: MemmapRange[]; next?: number }> {
743
758
  // The first argument is the access mask ("ioRWXrwx"); ff shows every kind of access.
744
759
  return parseMemmap(await this.#computerText(`mmsh ff ${hex4(start)} ${hex4(end)}`, 60_000), maxRanges);
745
760
  }
@@ -4,8 +4,8 @@
4
4
  // and back; breakpoints, watchpoints and the rest go along.
5
5
 
6
6
  import { WireFailure, type VideoStandard } from "../../protocol/messages.ts";
7
- import { MAX_BASELINES, MAX_SNAPSHOTS, MAX_COMPARE_DIFFERENCES, RASTER, type Breakpoint, type BreakpointParams, type Condition, type ExecutionParams, type HistoryEntry, type RunTarget, type RunUntilResult, type ScreenComparison, type CiaSelection, type CiaState, type ObserveParams, type ObserveResult, type SidState, type SpriteState, type ViciiState, type JoystickState, type Watchpoint, type WatchpointParams, type ExecutionResult, type MachineStatus, type RunState, type ViceOperation, type ViceOperations, type WindowResult } from "../../protocol/vice.ts";
8
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { MAX_BASELINES, MAX_SNAPSHOTS, MAX_COMPARE_DIFFERENCES, RASTER, type Breakpoint, type BreakpointParams, type PointRemoved, type Condition, type ExecutionParams, type HistoryEntry, type RunTarget, type RunUntilResult, type ScreenComparison, type CiaSelection, type CiaState, type ObserveParams, type ObserveResult, type SidState, type SpriteState, type ViciiState, type JoystickState, type Watchpoint, type WatchpointParams, type ExecutionResult, type MachineStatus, type RunState, type ViceOperation, type ViceOperations, type WindowResult } from "../../protocol/vice.ts";
8
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { setTimeout as sleep } from "node:timers/promises";
@@ -283,9 +283,10 @@ export class ViceSession implements ViceSessionHandle {
283
283
  op: O,
284
284
  params: ViceOperations[O]["params"],
285
285
  attachments: Buffer[] = [],
286
+ reply: Buffer[] = [],
286
287
  ): Promise<ViceOperations[O]["result"]> {
287
288
  // A window request for the mode the session is in sends nothing to VICE, so a held VICE does not stop it.
288
- return this.#enqueue(() => this.#run(op, params, attachments), { whileHeld: op === "window" }) as Promise<ViceOperations[O]["result"]>;
289
+ return this.#enqueue(() => this.#run(op, params, attachments, reply), { whileHeld: op === "window" }) as Promise<ViceOperations[O]["result"]>;
289
290
  }
290
291
 
291
292
  async close(): Promise<void> {
@@ -303,10 +304,15 @@ export class ViceSession implements ViceSessionHandle {
303
304
  return this.#adapter;
304
305
  }
305
306
 
306
- async #run(op: ViceOperation, params: unknown, attachments: Buffer[]): Promise<unknown> {
307
+ async #run(op: ViceOperation, params: unknown, attachments: Buffer[], reply: Buffer[]): Promise<unknown> {
307
308
  switch (op) {
308
309
  case "status":
309
310
  return this.#status();
311
+ case "ioRead": {
312
+ const read = params as ViceOperations["ioRead"]["params"];
313
+ const bytes = await this.#observe(() => this.#machine.readIo(read.address, read.size));
314
+ return { address: read.address, data: Buffer.from(bytes).toString("hex") };
315
+ }
310
316
  case "memoryRead": {
311
317
  const read = params as ViceOperations["memoryRead"]["params"];
312
318
  const data = await this.#observe(() => this.#machine.readMemory(read));
@@ -394,6 +400,8 @@ export class ViceSession implements ViceSessionHandle {
394
400
  }
395
401
  case "snapshot":
396
402
  return this.#snapshot(params as ViceOperations["snapshot"]["params"]);
403
+ case "snapshotFile":
404
+ return this.#snapshotFile(params as ViceOperations["snapshotFile"]["params"], attachments, reply);
397
405
  case "cpuHistory":
398
406
  return this.#cpuHistory(params as ViceOperations["cpuHistory"]["params"]);
399
407
  case "backtrace": {
@@ -422,7 +430,7 @@ export class ViceSession implements ViceSessionHandle {
422
430
  await this.#observe(() => this.#machine.clearMemmap());
423
431
  return { cleared: true };
424
432
  }
425
- return { ranges: await this.#observe(() => this.#machine.memmap(request.start, request.end, request.maxRanges)) };
433
+ return await this.#observe(() => this.#machine.memmap(request.start, request.end, request.maxRanges));
426
434
  }
427
435
  case "vicii":
428
436
  return this.#observe(() => this.#vicii());
@@ -716,34 +724,44 @@ export class ViceSession implements ViceSessionHandle {
716
724
  async #changePoint<T extends { checkpoint: number; enabled: boolean }>(
717
725
  registry: Map<number, T>,
718
726
  kind: string,
719
- params: { action: "remove" | "enable" | "disable"; id: number },
727
+ params: { action: "enable" | "disable"; id: number },
720
728
  ): Promise<T> {
721
- const point = registry.get(params.id);
722
- if (point === undefined) throw new WireFailure("not-found", `There is no ${kind} with id ${params.id}.`);
723
- if (params.action === "remove") {
724
- await this.#observe(() => this.#machine.deleteCheckpoint(point.checkpoint));
725
- registry.delete(params.id);
726
- } else {
727
- const enabled = params.action === "enable";
728
- await this.#observe(() => this.#machine.toggleCheckpoint(point.checkpoint, enabled));
729
- point.enabled = enabled;
730
- }
729
+ const point = this.#point(registry, kind, params.id);
730
+ const enabled = params.action === "enable";
731
+ await this.#observe(() => this.#machine.toggleCheckpoint(point.checkpoint, enabled));
732
+ point.enabled = enabled;
731
733
  return point;
732
734
  }
733
735
 
734
- async #breakpoint(params: BreakpointParams): Promise<Breakpoint | { breakpoints: Breakpoint[] }> {
736
+ async #removePoint(registry: Map<number, { checkpoint: number }>, kind: string, id: number): Promise<PointRemoved> {
737
+ const point = this.#point(registry, kind, id);
738
+ await this.#observe(() => this.#machine.deleteCheckpoint(point.checkpoint));
739
+ registry.delete(id);
740
+ return { removed: true, id };
741
+ }
742
+
743
+ #point<T>(registry: Map<number, T>, kind: string, id: number): T {
744
+ const point = registry.get(id);
745
+ if (point === undefined) throw new WireFailure("not-found", `There is no ${kind} with id ${id}.`);
746
+ return point;
747
+ }
748
+
749
+ async #breakpoint(params: BreakpointParams): Promise<Breakpoint | PointRemoved | { breakpoints: Breakpoint[] }> {
735
750
  const publicView = ({ checkpoint: _checkpoint, expression: _expression, ...point }: Breakpoint & { checkpoint: number; expression?: string }): Breakpoint =>
736
751
  point;
737
752
  if (params.action === "list") return { breakpoints: [...this.#breakpoints.values()].map(publicView) };
753
+ if (params.action === "remove") return this.#removePoint(this.#breakpoints, "breakpoint", params.id);
738
754
  if (params.action !== "add") return publicView(await this.#changePoint(this.#breakpoints, "breakpoint", params));
755
+ // A breakpoint over a range stops at the first instruction executed in it: exec, not a watch (D43).
739
756
  const added = await this.#addPoint({
740
- ...pointRange({ address: params.address }),
757
+ ...(params.size === undefined ? pointRange({ address: params.address }) : { start: params.address, end: params.address + params.size - 1, operation: 0x04 }),
741
758
  space: params.space,
742
759
  ...(params.condition === undefined ? {} : { condition: params.condition }),
743
760
  });
744
761
  const point = {
745
762
  id: this.#nextPointId++,
746
763
  address: params.address,
764
+ ...(params.size === undefined ? {} : { size: params.size }),
747
765
  space: params.space,
748
766
  enabled: true,
749
767
  ...(params.condition === undefined ? {} : { condition: params.condition }),
@@ -753,10 +771,11 @@ export class ViceSession implements ViceSessionHandle {
753
771
  return publicView(point);
754
772
  }
755
773
 
756
- async #watchpoint(params: WatchpointParams): Promise<Watchpoint | { watchpoints: Watchpoint[] }> {
774
+ async #watchpoint(params: WatchpointParams): Promise<Watchpoint | PointRemoved | { watchpoints: Watchpoint[] }> {
757
775
  const publicView = ({ checkpoint: _checkpoint, expression: _expression, ...point }: Watchpoint & { checkpoint: number; expression?: string }): Watchpoint =>
758
776
  point;
759
777
  if (params.action === "list") return { watchpoints: [...this.#watchpoints.values()].map(publicView) };
778
+ if (params.action === "remove") return this.#removePoint(this.#watchpoints, "watchpoint", params.id);
760
779
  if (params.action !== "add") return publicView(await this.#changePoint(this.#watchpoints, "watchpoint", params));
761
780
  const added = await this.#addPoint({
762
781
  ...pointRange({ address: params.address, size: params.size, access: params.access }),
@@ -910,15 +929,40 @@ export class ViceSession implements ViceSessionHandle {
910
929
  this.#snapshots.delete(params.name);
911
930
  return { discarded: true };
912
931
  }
932
+ return this.#restoreFrom(existing);
933
+ }
934
+
935
+ /** Restores the machine from a .vsf in the session directory; the CPU stays stopped. */
936
+ async #restoreFrom(file: string): Promise<{ restored: true; state: "stopped" }> {
913
937
  await this.#stop();
914
938
  const elapsed = (await this.#machine.clock()) - this.#timingStart;
915
- await this.#machine.restoreSnapshot(existing);
939
+ await this.#machine.restoreSnapshot(file);
916
940
  this.#state = "stopped";
917
941
  // The cycle clock jumped with the restore; the stopwatch keeps its reading, as a window move does.
918
942
  this.#timingStart = (await this.#machine.clock()) - elapsed;
919
943
  return { restored: true, state: "stopped" };
920
944
  }
921
945
 
946
+ /** A snapshot as bytes (D41): export a named one into `reply`, or restore from the attached .vsf. */
947
+ async #snapshotFile(params: ViceOperations["snapshotFile"]["params"], attachments: Buffer[], reply: Buffer[]): Promise<ViceOperations["snapshotFile"]["result"]> {
948
+ if (params.action === "export") {
949
+ const file = this.#snapshots.get(params.name);
950
+ if (file === undefined) throw new WireFailure("not-found", `There is no snapshot named ${params.name}.`);
951
+ const bytes = readFileSync(file);
952
+ reply.push(bytes);
953
+ return { exported: true, bytes: bytes.length };
954
+ }
955
+ const file = this.#stage(onlyAttachment(attachments), "vsf");
956
+ try {
957
+ return await this.#restoreFrom(file);
958
+ } catch (error) {
959
+ if (error instanceof WireFailure) throw error;
960
+ 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.");
961
+ } finally {
962
+ rmSync(file, { force: true });
963
+ }
964
+ }
965
+
922
966
  /**
923
967
  * Moves the machine into a VICE of the other mode: a snapshot of this
924
968
  * one, restored in a new one, with the user points, joysticks, warp and the
@@ -0,0 +1,45 @@
1
+ // Autostart without the autostart's own RUN: the caller decides when the
2
+ // program starts, so keys it types later never mix with VICE's typing.
3
+
4
+ import { textToPetscii } from "../c64.ts";
5
+ import type { ViceSessionClient } from "./vice-session.ts";
6
+
7
+ /** How often, and for how long, a load is checked. */
8
+ export const LOAD_POLL_FRAMES = 25;
9
+ export const LOAD_LIMIT_FRAMES = 1500;
10
+
11
+ /**
12
+ * Autostarts the PRG at `path` with run false and advances frames until its
13
+ * first bytes are at its load address. Returns false when they do not arrive
14
+ * within LOAD_LIMIT_FRAMES.
15
+ */
16
+ export async function autostartLoaded(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<boolean> {
17
+ const load = prg[0]! | (prg[1]! << 8);
18
+ const head = Buffer.from(prg.subarray(2, Math.min(prg.length, 2 + 64))).toString("hex");
19
+ await client.autostart({ path, index: 0, run: false });
20
+ for (let frames = 0; frames < LOAD_LIMIT_FRAMES; frames += LOAD_POLL_FRAMES) {
21
+ await client.execution({ action: "advance-frames", count: LOAD_POLL_FRAMES, space: "c64" });
22
+ if ((await client.memoryRead({ address: load, size: head.length / 2, space: "c64", view: "ram" })).data === head) return true;
23
+ }
24
+ return false;
25
+ }
26
+
27
+ /** CURLIN high byte: $ff while BASIC waits in direct mode, a line number's high byte while a program runs. */
28
+ const CURLIN_HIGH = 0x003a;
29
+ export const RUN_LIMIT_FRAMES = 300;
30
+
31
+ /**
32
+ * Autostart that returns only when the program runs: load without VICE's
33
+ * own RUN, type RUN, and wait until BASIC runs a program line. A key that a
34
+ * later step types then reaches the program, never the READY prompt. Returns
35
+ * why it did not start, or undefined when it runs.
36
+ */
37
+ export async function runProgram(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<"not-loaded" | "not-started" | undefined> {
38
+ if (!(await autostartLoaded(client, path, prg))) return "not-loaded";
39
+ await client.keyboard(textToPetscii("RUN\n"));
40
+ const started = await client.runUntil({
41
+ target: { kind: "memory", address: CURLIN_HIGH, operator: "ne", value: 0xff, space: "c64", view: "ram" },
42
+ timeoutFrames: RUN_LIMIT_FRAMES,
43
+ });
44
+ return started.reached ? undefined : "not-started";
45
+ }
@@ -4,7 +4,7 @@ import { connect as connectTcp, type Socket } from "node:net";
4
4
 
5
5
  import { encodeFrame, MessageReader } from "../protocol/framing.ts";
6
6
  import { elapsedMs, trace } from "../trace.ts";
7
- import { DEFAULT_HOST_PORT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HOST_PROTOCOL_ID, HOST_PROTOCOL_VERSION, parseHostMessage, ProtocolError, WireFailure, type ErrorCode, type Hello, type HostMessage, type Ping, type Request, type Role, type VideoStandard } from "../protocol/messages.ts";
7
+ import { DEFAULT_HOST_PORT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HOST_PROTOCOL_ID, HOST_PROTOCOL_VERSION, parseHostMessage, ProtocolError, WireFailure, withStopAndAsk, type ErrorCode, type Hello, type HostMessage, type Ping, type Request, type Role, type VideoStandard } from "../protocol/messages.ts";
8
8
 
9
9
  export interface Endpoint {
10
10
  host: string;
@@ -40,7 +40,9 @@ function unreachable(role: Role): WireFailure {
40
40
  const code: ErrorCode = role === "vice-session" ? "machine-unavailable" : "operation-failed";
41
41
  return new WireFailure(
42
42
  code,
43
- "The c64-re-tools host runtime is not running or cannot be reached. Start it on the host (npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host) and try again.",
43
+ withStopAndAsk(
44
+ "The c64-re-tools host runtime is not running or cannot be reached. Start it on the host (npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host) and try again.",
45
+ ),
44
46
  );
45
47
  }
46
48
 
@@ -0,0 +1,59 @@
1
+ // Reads the emulator's memory access map as coverage bytes (D40): one byte
2
+ // per address, RAM, ROM and I/O apart. The MCP tool and the skill scripts share it.
3
+
4
+ import { COVERAGE, type CodeRange } from "../c64.ts";
5
+ import { MAX_MEMMAP_RANGES, type MemmapRange } from "../protocol/vice.ts";
6
+ import { readProjectFile } from "./transfer.ts";
7
+ import type { ViceSessionClient } from "./vice-session.ts";
8
+
9
+ /** A full coverage file: offset n is address n (D40). */
10
+ export const FULL_COVERAGE = 0x10000;
11
+
12
+ /**
13
+ * Reads full coverage files from the project and joins them: an address has
14
+ * every flag that any run gave it. `fail` refuses a file of another size.
15
+ */
16
+ export function readCoverageFiles(paths: string[], fail: (message: string) => never): Uint8Array | undefined {
17
+ if (paths.length === 0) return undefined;
18
+ const joined = new Uint8Array(FULL_COVERAGE);
19
+ for (const path of paths) {
20
+ const { bytes } = readProjectFile(path);
21
+ if (bytes.length !== FULL_COVERAGE) {
22
+ fail(`--coverage ${path} has ${bytes.length} bytes; a full coverage file has 65536, one for each address. Write it with c64_memmap action read and out, without start and end.`);
23
+ }
24
+ for (let address = 0; address < FULL_COVERAGE; address++) joined[address]! |= bytes[address]!;
25
+ }
26
+ return joined;
27
+ }
28
+
29
+ /** The coverage bytes of start..end, read from the session in parts. */
30
+ export async function readCoverage(session: Pick<ViceSessionClient, "memmap">, start = 0x0000, end = 0xffff): Promise<Uint8Array> {
31
+ const map = new Uint8Array(end - start + 1);
32
+ for (let from: number | undefined = start; from !== undefined; ) {
33
+ const part: { ranges: MemmapRange[]; next?: number } = await session.memmap({ action: "read", start: from, end, maxRanges: MAX_MEMMAP_RANGES });
34
+ for (const range of part.ranges) map.fill(range.flags, range.start - start, range.end - start + 1);
35
+ from = part.next;
36
+ }
37
+ return map;
38
+ }
39
+
40
+ /** Runs of RAM that a program wrote, at least `minimum` bytes each, in a map from $0000. */
41
+ export function writtenBlocks(map: Uint8Array, minimum: number): CodeRange[] {
42
+ const blocks: CodeRange[] = [];
43
+ let start = -1;
44
+ for (let address = 0; address <= map.length; address++) {
45
+ const written = address < map.length && (map[address]! & COVERAGE.ramWrite) !== 0;
46
+ if (written && start < 0) start = address;
47
+ if (!written && start >= 0) {
48
+ if (address - start >= minimum) blocks.push({ start, end: address - 1 });
49
+ start = -1;
50
+ }
51
+ }
52
+ return blocks;
53
+ }
54
+
55
+ /** True when the CPU executed an instruction in RAM somewhere in the range. */
56
+ export function executedIn(map: Uint8Array, range: CodeRange): boolean {
57
+ for (let address = range.start; address <= range.end; address++) if ((map[address]! & COVERAGE.ramExecute) !== 0) return true;
58
+ return false;
59
+ }
@@ -0,0 +1,170 @@
1
+ // The action steps of the step language (D43): write, registers, joystick,
2
+ // type, frames and runUntil. c64-testing scenarios and the c64-unpacker
3
+ // steps file share them; each caller adds its own steps around them.
4
+
5
+ import { parseC64Address, textToPetscii } from "../c64.ts";
6
+ import { JOYSTICK_DIRECTIONS, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES } from "../protocol/vice.ts";
7
+ import type { RunTarget, RunUntilResult } from "../protocol/vice.ts";
8
+ import type { ViceSessionClient } from "./vice-session.ts";
9
+
10
+ /** A "$xxxx" address, or a symbol name where the caller resolves names. */
11
+ export type Location = string;
12
+
13
+ type JoystickDirection = (typeof JOYSTICK_DIRECTIONS)[number];
14
+
15
+ export type ActionStep =
16
+ | { write: { at: Location; bytes: number[] } }
17
+ | { registers: Partial<Record<"pc" | "a" | "x" | "y" | "sp", number | Location>> }
18
+ | { joystick: { port: 1 | 2; direction: JoystickDirection; fire?: boolean } }
19
+ | { type: string }
20
+ | { frames: number }
21
+ | { runUntil: { at: Location; raster?: number } | { raster: number } | { memory: { at: Location; equals: number } }; timeoutFrames: number };
22
+
23
+ /** The highest raster line of a PAL frame; an NTSC frame has fewer, which the emulator checks. */
24
+ const MAX_RASTER_LINE = 311;
25
+
26
+ export const ACTION_STEP_KINDS = ["write", "registers", "joystick", "type", "frames", "runUntil"] as const;
27
+
28
+ /** The keys a step of each kind can have, when it is more than the kind itself. */
29
+ export const ACTION_STEP_KEYS: Record<string, readonly string[]> = { runUntil: ["runUntil", "timeoutFrames"] };
30
+
31
+ /** The registers a registers step can set. */
32
+ export const SET_REGISTERS = ["pc", "a", "x", "y", "sp"];
33
+
34
+ export const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
35
+ export const isInteger = (value: unknown, min: number, max: number): value is number => Number.isInteger(value) && (value as number) >= min && (value as number) <= max;
36
+
37
+ /** Checks a "$xxxx" address, or also a symbol name when `names` allows it. */
38
+ export function checkLocation(value: unknown, what: string, fail: (message: string) => never, names = true): void {
39
+ if (typeof value !== "string" || value === "") fail(`${what} must be a $ address${names ? " or a symbol name" : ""}`);
40
+ if ((value as string).startsWith("$")) {
41
+ try {
42
+ parseC64Address(value as string);
43
+ } catch {
44
+ fail(`${what} must be $ followed by four hex digits, not ${value as string}`);
45
+ }
46
+ } else if (!names) {
47
+ fail(`${what} must be $ followed by four hex digits, not ${value as string}`);
48
+ }
49
+ }
50
+
51
+ /** Checks one action step of the given kind. `names` says whether symbol names may stand for addresses. */
52
+ export function checkActionStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never, names = true): void {
53
+ switch (kind) {
54
+ case "write": {
55
+ const write = step.write;
56
+ if (!isObject(write) || Object.keys(write).some((key) => key !== "at" && key !== "bytes")) return fail(`${at}: write needs at and bytes, and nothing else`);
57
+ checkLocation(write.at, `${at}: write.at`, fail, names);
58
+ if (!Array.isArray(write.bytes) || write.bytes.length === 0 || write.bytes.length > MAX_MEMORY_WRITE || !write.bytes.every((byte) => isInteger(byte, 0, 0xff))) {
59
+ fail(`${at}: write.bytes must be a list of 1 to ${MAX_MEMORY_WRITE} numbers from 0 to 255`);
60
+ }
61
+ return;
62
+ }
63
+ case "registers": {
64
+ const registers = step.registers;
65
+ if (!isObject(registers) || Object.keys(registers).length === 0) return fail(`${at}: registers must name at least one of ${SET_REGISTERS.join(", ")}`);
66
+ for (const [name, value] of Object.entries(registers)) {
67
+ if (!SET_REGISTERS.includes(name)) fail(`${at}: registers can set only ${SET_REGISTERS.join(", ")}, not ${name}`);
68
+ if (typeof value === "string") checkLocation(value, `${at}: registers.${name}`, fail, names);
69
+ else if (!isInteger(value, 0, name === "pc" ? 0xffff : 0xff)) fail(`${at}: registers.${name} must be ${names ? "a symbol name, " : ""}a $ address or a number from 0 to ${name === "pc" ? 65535 : 255}`);
70
+ }
71
+ return;
72
+ }
73
+ case "joystick": {
74
+ const joystick = step.joystick;
75
+ if (!isObject(joystick) || Object.keys(joystick).some((key) => !["port", "direction", "fire"].includes(key))) return fail(`${at}: joystick needs port and direction, and fire is optional`);
76
+ if (joystick.port !== 1 && joystick.port !== 2) fail(`${at}: joystick.port must be 1 or 2`);
77
+ if (!(JOYSTICK_DIRECTIONS as readonly unknown[]).includes(joystick.direction)) fail(`${at}: joystick.direction must be one of ${JOYSTICK_DIRECTIONS.join(", ")}`);
78
+ if (joystick.fire !== undefined && typeof joystick.fire !== "boolean") fail(`${at}: joystick.fire must be true or false`);
79
+ return;
80
+ }
81
+ case "type": {
82
+ if (typeof step.type !== "string" || step.type === "") return fail(`${at}: type needs text`);
83
+ let bytes: Uint8Array;
84
+ try {
85
+ bytes = textToPetscii(step.type);
86
+ } catch (error) {
87
+ return fail(`${at}: ${(error as Error).message}`);
88
+ }
89
+ if (bytes.length > MAX_KEYBOARD_BYTES) fail(`${at}: type can have at most ${MAX_KEYBOARD_BYTES} keys`);
90
+ return;
91
+ }
92
+ case "frames":
93
+ if (!isInteger(step.frames, 1, MAX_EXECUTION_COUNT)) fail(`${at} needs a frame count from 1 to ${MAX_EXECUTION_COUNT}`);
94
+ return;
95
+ case "runUntil": {
96
+ const target = step.runUntil;
97
+ const keys = isObject(target) ? Object.keys(target).sort().join(",") : "";
98
+ // at, raster, at with raster (the address only on that line, D46), or memory.
99
+ if (!["at", "raster", "at,raster", "memory"].includes(keys)) return fail(`${at}: runUntil needs at, raster, at and raster, or memory`);
100
+ const until = target as Record<string, unknown>;
101
+ if ("raster" in until && !isInteger(until.raster, 0, MAX_RASTER_LINE)) fail(`${at}: runUntil.raster must be a raster line from 0 to ${MAX_RASTER_LINE}`);
102
+ if ("at" in until) checkLocation(until.at, `${at}: runUntil.at`, fail, names);
103
+ else if ("memory" in until) {
104
+ const memory = until.memory;
105
+ if (!isObject(memory) || Object.keys(memory).some((key) => key !== "at" && key !== "equals")) return fail(`${at}: runUntil.memory needs at and equals, and nothing else`);
106
+ checkLocation(memory.at, `${at}: runUntil.memory.at`, fail, names);
107
+ if (!isInteger(memory.equals, 0, 0xff)) fail(`${at}: runUntil.memory.equals must be a number from 0 to 255`);
108
+ }
109
+ if (!isInteger(step.timeoutFrames, 1, MAX_TIMEOUT_FRAMES)) fail(`${at} needs timeoutFrames from 1 to ${MAX_TIMEOUT_FRAMES}`);
110
+ return;
111
+ }
112
+ default:
113
+ fail(`${at}: ${kind} is not an action step`);
114
+ }
115
+ }
116
+
117
+ /** Checks a list of action steps only, with $ addresses only: the steps file of c64-unpacker. */
118
+ export function checkActionSteps(value: unknown, fail: (message: string) => never): ActionStep[] {
119
+ if (!Array.isArray(value) || value.length === 0) return fail("the steps are a JSON list with at least one step");
120
+ value.forEach((step: unknown, index) => {
121
+ const at = `step ${index + 1}`;
122
+ if (!isObject(step)) return fail(`${at} must be an object`);
123
+ const kinds = Object.keys(step).filter((key) => (ACTION_STEP_KINDS as readonly string[]).includes(key));
124
+ if (kinds.length !== 1) return fail(`${at} must have exactly one of ${ACTION_STEP_KINDS.join(", ")}`);
125
+ const kind = kinds[0]!;
126
+ const allowed = ACTION_STEP_KEYS[kind] ?? [kind];
127
+ for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
128
+ checkActionStep(kind, step, at, fail, false);
129
+ });
130
+ return value as ActionStep[];
131
+ }
132
+
133
+ /** Stops the CPU when it runs: a write, a register change or an exact frame count needs it stopped. */
134
+ async function stop(client: ViceSessionClient): Promise<void> {
135
+ if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
136
+ }
137
+
138
+ /**
139
+ * Runs one action step. `at` turns a location into an address. A runUntil
140
+ * step that does not reach its target returns the stop, for the caller to judge.
141
+ */
142
+ export async function runActionStep(client: ViceSessionClient, step: ActionStep, at: (location: Location) => number): Promise<RunUntilResult | undefined> {
143
+ if ("write" in step) {
144
+ await stop(client);
145
+ await client.memoryWrite({ address: at(step.write.at), data: Buffer.from(step.write.bytes).toString("hex"), space: "c64", view: "cpu" });
146
+ } else if ("registers" in step) {
147
+ await stop(client);
148
+ const values: Record<string, number> = {};
149
+ for (const [name, value] of Object.entries(step.registers)) values[name] = typeof value === "number" ? value : at(value as Location);
150
+ await client.registersSet("c64", values);
151
+ } else if ("joystick" in step) {
152
+ await client.joystick({ port: step.joystick.port, direction: step.joystick.direction, fire: step.joystick.fire ?? false });
153
+ } else if ("type" in step) {
154
+ await client.keyboard(textToPetscii(step.type));
155
+ } else if ("frames" in step) {
156
+ await stop(client);
157
+ await client.execution({ action: "advance-frames", count: step.frames, space: "c64" });
158
+ } else {
159
+ const until = step.runUntil;
160
+ const target: RunTarget =
161
+ "at" in until
162
+ ? { kind: "address", address: at(until.at), space: "c64", ...(until.raster === undefined ? {} : { condition: { kind: "raster" as const, line: until.raster } }) }
163
+ : "raster" in until
164
+ ? { kind: "raster", line: until.raster }
165
+ : { kind: "memory", address: at(until.memory.at), operator: "eq", value: until.memory.equals, space: "c64", view: "cpu" };
166
+ const reached = await client.runUntil({ target, timeoutFrames: step.timeoutFrames });
167
+ if (!reached.reached) return reached;
168
+ }
169
+ return undefined;
170
+ }
@@ -1,10 +1,10 @@
1
1
  // Reads project files on the client side for transfer to the Host Runtime.
2
2
  // The host never sees a client path: only bytes and the file's type.
3
3
 
4
- import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
5
- import { extname, join, relative, sep } from "node:path";
4
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
5
+ import { dirname, extname, join, relative, sep } from "node:path";
6
6
 
7
- import { isInside, projectRoot, resolveProjectFile } from "../project.ts";
7
+ import { isInside, projectRoot, resolveProjectFile, resolveProjectPath } from "../project.ts";
8
8
  import { MAX_ATTACHMENT_BYTES } from "../protocol/framing.ts";
9
9
  import { WireFailure } from "../protocol/messages.ts";
10
10
  import { MAX_TREE_FILES, type SourceTree } from "../protocol/tools.ts";
@@ -42,6 +42,32 @@ export function readProjectFile(path: string, options: { root?: string } = {}):
42
42
  return { bytes: readFileSync(real), type: extname(path).slice(1).toLowerCase() };
43
43
  }
44
44
 
45
+ /**
46
+ * Writes bytes to a project file on the client side, making its directories
47
+ * and overwriting it. Throws WireFailure(invalid-input) for a path outside the
48
+ * project, also one that leaves it through a symbolic link.
49
+ */
50
+ export function writeProjectFile(path: string, bytes: Uint8Array, options: { root?: string; what?: string } = {}): void {
51
+ const root = options.root ?? projectRoot();
52
+ const refuse = () => new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project directory that stays inside it, not ${path}.`);
53
+ let file: string;
54
+ try {
55
+ file = resolveProjectPath(path, root);
56
+ } catch {
57
+ throw refuse();
58
+ }
59
+ mkdirSync(dirname(file), { recursive: true });
60
+ if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
61
+ let link = false;
62
+ try {
63
+ link = lstatSync(file).isSymbolicLink();
64
+ } catch (error) {
65
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
66
+ }
67
+ if (link) throw refuse();
68
+ writeFileSync(file, bytes);
69
+ }
70
+
45
71
  /** Directories never staged: version control and the toolkit's own project state. */
46
72
  const SKIPPED_DIRECTORIES = new Set([".git", ".hg", ".svn", ".c64-re-tools"]);
47
73