@henols/c64-re-tools 2.0.0-rc.2 → 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 (75) hide show
  1. package/README.md +19 -59
  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/main.ts +10 -25
  45. package/src/host/server.ts +8 -40
  46. package/src/host/vice/adapter.ts +25 -10
  47. package/src/host/vice/session.ts +64 -20
  48. package/src/host-client/autostart.ts +45 -0
  49. package/src/host-client/connect.ts +4 -5
  50. package/src/host-client/coverage.ts +59 -0
  51. package/src/host-client/steps.ts +170 -0
  52. package/src/host-client/transfer.ts +29 -3
  53. package/src/host-client/vice-session.ts +42 -13
  54. package/src/knowledge/history.ts +40 -13
  55. package/src/knowledge/import.ts +72 -56
  56. package/src/knowledge/read.ts +203 -52
  57. package/src/knowledge/schema.ts +94 -12
  58. package/src/knowledge/write.ts +432 -123
  59. package/src/mcp/main.ts +1 -4
  60. package/src/mcp/output.ts +32 -0
  61. package/src/mcp/server.ts +4 -2
  62. package/src/mcp/tools/debug.ts +110 -24
  63. package/src/mcp/tools/input.ts +1 -0
  64. package/src/mcp/tools/media.ts +22 -6
  65. package/src/mcp/tools/memory.ts +57 -10
  66. package/src/mcp/tools/video.ts +9 -3
  67. package/src/native/discover.ts +5 -2
  68. package/src/native/ghidra/index.ts +19 -3
  69. package/src/native/ghidra/scripts/C64Export.java +33 -1
  70. package/src/native/status.ts +15 -3
  71. package/src/native/types.ts +30 -1
  72. package/{skills/c64-memory-map/scripts → src}/platform.ts +18 -2
  73. package/src/protocol/messages.ts +17 -10
  74. package/src/protocol/vice.ts +72 -18
  75. package/src/script.ts +3 -2
@@ -2,7 +2,8 @@
2
2
  //
3
3
  // Writes the structural result for the loaded image: functions with the
4
4
  // source of their names, code and defined-data regions, memory references
5
- // from instructions, and the requested decompilations within their bounds.
5
+ // from instructions, every instruction with its bytes, mnemonic, operand
6
+ // text and flow targets (D44), and the requested decompilations within their bounds.
6
7
  //@category c64-re-tools
7
8
 
8
9
  import java.io.FileReader;
@@ -53,6 +54,7 @@ public class C64Export extends GhidraScript {
53
54
  out.add("functions", functions(coverage));
54
55
  out.add("regions", regions(coverage));
55
56
  out.add("references", references(coverage));
57
+ out.add("instructions", instructions(coverage));
56
58
  JsonArray notDecompiled = new JsonArray();
57
59
  out.add("decompilations", decompile(request, notDecompiled));
58
60
  out.add("notDecompiled", notDecompiled);
@@ -156,6 +158,36 @@ public class C64Export extends GhidraScript {
156
158
  return references;
157
159
  }
158
160
 
161
+ /** Every instruction in the coverage, in address order, as Ghidra decoded it with the 6510 language. */
162
+ private JsonArray instructions(AddressSet coverage) throws Exception {
163
+ JsonArray instructions = new JsonArray();
164
+ for (Instruction instruction : currentProgram.getListing().getInstructions(coverage, true)) {
165
+ JsonObject item = new JsonObject();
166
+ item.addProperty("address", instruction.getMinAddress().getOffset());
167
+ StringBuilder bytes = new StringBuilder();
168
+ for (byte value : instruction.getBytes()) {
169
+ bytes.append(String.format("%02x", value & 0xff));
170
+ }
171
+ item.addProperty("bytes", bytes.toString());
172
+ item.addProperty("mnemonic", instruction.getMnemonicString().toLowerCase());
173
+ StringBuilder operands = new StringBuilder();
174
+ for (int index = 0; index < instruction.getNumOperands(); index++) {
175
+ if (index > 0) {
176
+ operands.append(",");
177
+ }
178
+ operands.append(instruction.getDefaultOperandRepresentation(index));
179
+ }
180
+ item.addProperty("operands", operands.toString());
181
+ JsonArray flows = new JsonArray();
182
+ for (Address flow : instruction.getFlows()) {
183
+ flows.add(flow.getOffset());
184
+ }
185
+ item.add("flows", flows);
186
+ instructions.add(item);
187
+ }
188
+ return instructions;
189
+ }
190
+
159
191
  private static void add(JsonArray references, Set<String> seen, long from, long to, String type) {
160
192
  if (!seen.add(from + ":" + to + ":" + type)) {
161
193
  return;
@@ -9,7 +9,7 @@ import { stripVTControlCharacters } from "node:util";
9
9
  import { WireFailure } from "../protocol/messages.ts";
10
10
  import { type ToolStatus } from "../protocol/tools.ts";
11
11
  import { ACME, DXA, findTool } from "./discover.ts";
12
- import { findGhidra, ghidraVersion } from "./ghidra/index.ts";
12
+ import { findGhidra, ghidraVersion, NO_USABLE_JDK, noJdk } from "./ghidra/index.ts";
13
13
  import { runTool, type ToolContext } from "./run.ts";
14
14
  import { Workspace } from "./staging.ts";
15
15
 
@@ -35,10 +35,21 @@ export function versionLine(output: string, pattern: RegExp): string | undefined
35
35
  .find((line) => pattern.test(line));
36
36
  }
37
37
 
38
- async function run(name: string, path: string, args: string[], version: (output: string) => string | undefined, context: ToolContext): Promise<ToolStatus> {
38
+ async function run(
39
+ name: string,
40
+ path: string,
41
+ args: string[],
42
+ version: (output: string) => string | undefined,
43
+ context: ToolContext,
44
+ /** A known cause in the output, said in place of the exit status. */
45
+ cause?: (output: string) => string | undefined,
46
+ ): Promise<ToolStatus> {
39
47
  const result = await runTool({ argv: [path, ...args], cwd: tmpdir(), supervisor: context.supervisor, signal: context.signal, timeoutMs: PROBE_TIMEOUT_MS, env: context.env });
40
- const found = version(`${result.stdout}\n${result.stderr}`);
48
+ const output = `${result.stdout}\n${result.stderr}`;
49
+ const found = version(output);
41
50
  if (found !== undefined) return { name, found: true, path, version: found, runs: true };
51
+ const known = cause?.(output);
52
+ if (known !== undefined) return { name, found: true, path, runs: false, problem: known };
42
53
  const problem = result.timedOut ? "It did not answer in time." : result.spawnError !== undefined ? `It did not start (${result.spawnError}).` : `It did not run correctly (exit ${result.code ?? result.signal}).`;
43
54
  return { name, found: true, path, runs: false, problem };
44
55
  }
@@ -79,6 +90,7 @@ export async function probeGhidra(context: ToolContext): Promise<ToolStatus> {
79
90
  [],
80
91
  (output) => (/Headless Analyzer Usage/.test(output) ? ghidraVersion(ghidra) : undefined),
81
92
  { ...context, env: { ...env, XDG_CONFIG_HOME: settings, XDG_CACHE_HOME: settings } },
93
+ (output) => (NO_USABLE_JDK.test(output) ? noJdk(ghidra).message : undefined),
82
94
  );
83
95
  return { ...status, path: ghidra.root };
84
96
  } finally {
@@ -52,6 +52,17 @@ export interface GhidraParams {
52
52
  decompile: number[];
53
53
  }
54
54
 
55
+ export interface GhidraInstruction {
56
+ address: number;
57
+ /** Lowercase hex, 1 to 3 bytes. */
58
+ bytes: string;
59
+ mnemonic: string;
60
+ /** Ghidra's text of the operands, empty for an implied operand. */
61
+ operands: string;
62
+ /** Where the flow goes besides the next instruction: a branch, jump or call target. */
63
+ flows: number[];
64
+ }
65
+
55
66
  export interface GhidraResult {
56
67
  coverage: Array<{ start: number; end: number }>;
57
68
  functions: Array<{ entry: number; name: string; nameSource: "seed" | "generated" | "native" }>;
@@ -60,6 +71,8 @@ export interface GhidraResult {
60
71
  decompilations: Array<{ entry: number; text: string; truncated: boolean }>;
61
72
  /** Requested routines without decompiled code, with the decompiler's reason. */
62
73
  notDecompiled?: Array<{ entry: number; reason: string }>;
74
+ /** Every instruction in the coverage, as Ghidra decoded it (D44). */
75
+ instructions: GhidraInstruction[];
63
76
  /** Which categories are complete inside the coverage; private to the importer. */
64
77
  completeness: { functions: boolean; regions: boolean; references: boolean };
65
78
  /** The version of the Ghidra that made the result, for example "Ghidra 12.1.3". */
@@ -115,7 +128,7 @@ const isList = (value: unknown): value is unknown[] => Array.isArray(value);
115
128
  */
116
129
  export function checkGhidraResult(value: unknown): GhidraResult {
117
130
  if (!isObject(value)) fail("not an object");
118
- const { coverage, functions, regions, references, decompilations, notDecompiled, completeness, toolVersion } = value;
131
+ const { coverage, functions, regions, references, decompilations, notDecompiled, completeness, toolVersion, instructions } = value;
119
132
  if (!isList(coverage) || coverage.length === 0) fail("no coverage");
120
133
  const ranges: Array<{ start: number; end: number }> = [];
121
134
  for (const range of coverage) {
@@ -138,6 +151,22 @@ export function checkGhidraResult(value: unknown): GhidraResult {
138
151
  if (!ranges.some((range) => range.start <= start && end <= range.end)) fail("a region lies outside the coverage");
139
152
  previousEnd = end;
140
153
  }
154
+ if (!isList(instructions)) fail("no instructions");
155
+ for (const instruction of instructions) {
156
+ if (
157
+ !isObject(instruction) ||
158
+ !isInteger(instruction.address, 0, 0xffff) ||
159
+ typeof instruction.bytes !== "string" ||
160
+ !/^(?:[0-9a-f]{2}){1,3}$/.test(instruction.bytes) ||
161
+ typeof instruction.mnemonic !== "string" ||
162
+ typeof instruction.operands !== "string" ||
163
+ !isList(instruction.flows) ||
164
+ !instruction.flows.every((flow) => isInteger(flow, 0, 0xffff))
165
+ ) {
166
+ fail("bad instruction");
167
+ }
168
+ if (!covered(instruction.address)) fail("an instruction lies outside the coverage");
169
+ }
141
170
  if (!isList(references)) fail("no references");
142
171
  for (const reference of references) {
143
172
  if (!isObject(reference) || !isInteger(reference.from, 0, 0xffff) || !isInteger(reference.to, 0, 0xffff) || !isOneOf(REFERENCE_KINDS, reference.type)) fail("bad reference");
@@ -1,8 +1,9 @@
1
- // C64 platform reference data and decoders for the c64-memory-map script.
1
+ // C64 platform reference data and decoders: the c64-memory-map script, and the
2
+ // platform names that c64-source uses (D52).
2
3
  // Names follow "Mapping the Commodore 64". Only platform facts live here:
3
4
  // an address without one belongs to the application.
4
5
 
5
- import { d018Offsets, vicBankBase } from "#src/c64.ts";
6
+ import { d018Offsets, vicBankBase } from "./c64.ts";
6
7
 
7
8
  export interface Entry {
8
9
  start: number;
@@ -340,6 +341,21 @@ export function lookup(address: number): Lookup {
340
341
  return { address, kind: area.kind, area: area.area, meaning: area.meaning };
341
342
  }
342
343
 
344
+ /**
345
+ * The platform name of each named address (D52): system variables, ROM
346
+ * entries and I/O registers, not mirrors, in address order. Only names that
347
+ * an assembler can use as a symbol, and each name once, at its lowest address.
348
+ */
349
+ export function platformNames(): Array<{ address: number; name: string; meaning: string }> {
350
+ const io = IO.flatMap((block) => block.registers);
351
+ const seen = new Set<string>();
352
+ return [...SYSTEM, ...ROM, ...io]
353
+ .map((entry) => ({ address: entry.start, name: entry.name, meaning: entry.meaning }))
354
+ .filter((entry) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.name))
355
+ .sort((a, b) => a.address - b.address)
356
+ .filter((entry) => !seen.has(entry.name) && seen.add(entry.name) !== undefined);
357
+ }
358
+
343
359
  export type Decoded = Record<string, unknown>;
344
360
 
345
361
  const bits = (value: number) => [0, 1, 2, 3, 4, 5, 6, 7].filter((bit) => (value & (1 << bit)) !== 0);
@@ -7,7 +7,7 @@ import type { ViceOperation, ViceOperations } from "./vice.ts";
7
7
  /** Private Host Runtime protocol identifier. */
8
8
  export const HOST_PROTOCOL_ID = "c64-re-tools-host" as const;
9
9
  /** Bumped on any incompatible change; a mismatch fails the handshake. */
10
- export const HOST_PROTOCOL_VERSION = 2;
10
+ export const HOST_PROTOCOL_VERSION = 3;
11
11
  /** After ready, the client pings this often, on every connection. */
12
12
  export const HEARTBEAT_INTERVAL_MS = 10_000;
13
13
  /** Either side closes a connection that has sent nothing for this long. */
@@ -54,13 +54,8 @@ export interface Hello {
54
54
  role: Role;
55
55
  /** vice-session only; fixed for the session. */
56
56
  videoStandard?: VideoStandard;
57
- /** The shared secret (C64RT_HOST_TOKEN) for a host that listens beyond loopback. */
58
- token?: string;
59
57
  }
60
58
 
61
- /** Longest accepted shared token. */
62
- const MAX_TOKEN_LENGTH = 256;
63
-
64
59
  export interface Ready {
65
60
  type: "ready";
66
61
  }
@@ -111,6 +106,22 @@ export class ProtocolError extends Error {
111
106
  }
112
107
 
113
108
  /** Thrown by parameter/result validators; carries the wire code to answer with. */
109
+ /**
110
+ * The instruction at the end of a refusal that only the user can fix: a
111
+ * missing or broken tool, or a Host Runtime that does not run (D37).
112
+ */
113
+ export const STOP_AND_ASK = "Stop the task. Give this message to the user and wait for the user. Do not make a replacement for the tool or the host runtime.";
114
+
115
+ /** The message with STOP_AND_ASK at its end, once. */
116
+ export function withStopAndAsk(message: string): string {
117
+ return message.endsWith(STOP_AND_ASK) ? message : `${message} ${STOP_AND_ASK}`;
118
+ }
119
+
120
+ /** The message that the agent sees for a refusal: an installation-incomplete one ends with STOP_AND_ASK. */
121
+ export function shownMessage(error: { code: string; message: string }): string {
122
+ return error.code === "installation-incomplete" ? withStopAndAsk(error.message) : error.message;
123
+ }
124
+
114
125
  export class WireFailure extends Error implements WireError {
115
126
  override name = "WireFailure";
116
127
  readonly code: ErrorCode;
@@ -166,12 +177,8 @@ export function parseClientMessage(value: unknown): ReceivedClientMessage {
166
177
  if (value.videoStandard !== undefined && !isOneOf(VIDEO_STANDARDS, value.videoStandard)) {
167
178
  throw new ProtocolError("hello has an unknown video standard");
168
179
  }
169
- if (value.token !== undefined && (typeof value.token !== "string" || value.token.length > MAX_TOKEN_LENGTH)) {
170
- throw new ProtocolError("hello has a malformed token");
171
- }
172
180
  const hello: Hello = { type: "hello", protocol: value.protocol, version: value.version, role: value.role };
173
181
  if (value.videoStandard !== undefined) hello.videoStandard = value.videoStandard;
174
- if (value.token !== undefined) hello.token = value.token;
175
182
  return hello;
176
183
  }
177
184
  if (value.type === "request") {
@@ -94,6 +94,8 @@ export const MAX_WATCH_SIZE = 256;
94
94
  export interface Breakpoint {
95
95
  id: number;
96
96
  address: number;
97
+ /** A range from address: the CPU stops at the first instruction it executes in it (D43). One address when not given. */
98
+ size?: number;
97
99
  space: Space;
98
100
  enabled: boolean;
99
101
  /** The condition given at add, when there was one. */
@@ -112,15 +114,23 @@ export interface Watchpoint {
112
114
  }
113
115
 
114
116
  export type BreakpointParams =
115
- | { action: "add"; address: number; space: Space; condition?: Condition }
116
- | { action: "remove" | "enable" | "disable"; id: number }
117
+ | { action: "add"; address: number; size?: number; space: Space; condition?: Condition }
118
+ | { action: "remove"; id: number }
119
+ | { action: "enable" | "disable"; id: number }
117
120
  | { action: "list" };
118
121
 
119
122
  export type WatchpointParams =
120
123
  | { action: "add"; address: number; size: number; access: WatchAccess; space: Space; condition?: Condition }
121
- | { action: "remove" | "enable" | "disable"; id: number }
124
+ | { action: "remove"; id: number }
125
+ | { action: "enable" | "disable"; id: number }
122
126
  | { action: "list" };
123
127
 
128
+ /** The result of remove: the point is gone, so only its id comes back. */
129
+ export interface PointRemoved {
130
+ removed: true;
131
+ id: number;
132
+ }
133
+
124
134
  // Chip state
125
135
 
126
136
  export const VICII_MODES = ["text", "multicolor-text", "extended-color-text", "bitmap", "multicolor-bitmap", "invalid"] as const;
@@ -223,12 +233,11 @@ export interface ProfileEntry {
223
233
  percent: number;
224
234
  }
225
235
 
236
+ /** Addresses with the same access flags: the coverage byte of src/c64.ts (D40), RAM, ROM and I/O apart. */
226
237
  export interface MemmapRange {
227
238
  start: number;
228
239
  end: number;
229
- execute: boolean;
230
- read: boolean;
231
- write: boolean;
240
+ flags: number;
232
241
  }
233
242
 
234
243
  // Screen baselines and snapshots
@@ -386,6 +395,8 @@ export interface RunUntilResult {
386
395
  export interface ViceOperations {
387
396
  status: { params: Record<string, never>; result: MachineStatus };
388
397
  memoryRead: { params: MemoryReadParams; result: MemoryReadResult };
398
+ /** The I/O registers at $d000-$dfff as the chips hold them, whatever the CPU banks in, without side effects (D43). */
399
+ ioRead: { params: { address: number; size: number }; result: MemoryReadResult };
389
400
  registersGet: { params: { space: Space }; result: Registers };
390
401
  memoryWrite: { params: MemoryWriteParams; result: { address: number; bytesWritten: number } };
391
402
  /** `pattern`: bytes, with null as a wildcard. */
@@ -412,8 +423,8 @@ export interface ViceOperations {
412
423
  /** `data`: PETSCII bytes as lowercase hex. */
413
424
  keyboard: { params: { data: string }; result: { queuedBytes: number } };
414
425
  joystick: { params: JoystickState; result: JoystickState };
415
- breakpoint: { params: BreakpointParams; result: Breakpoint | { breakpoints: Breakpoint[] } };
416
- watchpoint: { params: WatchpointParams; result: Watchpoint | { watchpoints: Watchpoint[] } };
426
+ breakpoint: { params: BreakpointParams; result: Breakpoint | PointRemoved | { breakpoints: Breakpoint[] } };
427
+ watchpoint: { params: WatchpointParams; result: Watchpoint | PointRemoved | { watchpoints: Watchpoint[] } };
417
428
  cpuHistory: { params: { limit: number; space: Space }; result: { entries: HistoryEntry[] } };
418
429
  backtrace: { params: { depth: number; space: Space }; result: { frames: BacktraceFrame[] } };
419
430
  /** `cycles` is a decimal string. */
@@ -421,7 +432,8 @@ export interface ViceOperations {
421
432
  profile: { params: { limit: number }; result: { entries: ProfileEntry[] } };
422
433
  memmap: {
423
434
  params: { action: "read"; start: number; end: number; maxRanges: number } | { action: "clear" };
424
- result: { ranges: MemmapRange[] } | { cleared: boolean };
435
+ /** `next`: more ranges follow from this address; read again from there. */
436
+ result: { ranges: MemmapRange[]; next?: number } | { cleared: boolean };
425
437
  };
426
438
  vicii: { params: Record<string, never>; result: ViciiState };
427
439
  /** `indexes`: unique sprite numbers 0-7, in the order to report them. */
@@ -440,6 +452,14 @@ export interface ViceOperations {
440
452
  params: { action: "save" | "restore" | "discard"; name: string } | { action: "list" };
441
453
  result: { saved: boolean; name: string } | { restored: boolean; state: RunState } | { snapshots: string[] } | { discarded: boolean };
442
454
  };
455
+ /**
456
+ * Snapshot files (D41): export sends the bytes of a named snapshot back as
457
+ * one attachment; restore takes the bytes of a .vsf as one attachment.
458
+ */
459
+ snapshotFile: {
460
+ params: { action: "export"; name: string } | { action: "restore" };
461
+ result: { exported: boolean; bytes: number } | { restored: boolean; state: RunState };
462
+ };
443
463
  screenDiscard: { params: { baseline: string }; result: { discarded: boolean } };
444
464
  warp: { params: { enabled: boolean }; result: { enabled: boolean } };
445
465
  window: { params: { action: (typeof WINDOW_ACTIONS)[number] }; result: WindowResult };
@@ -469,6 +489,8 @@ export const VICE_OPERATIONS = [
469
489
  "screenBaselines",
470
490
  "screenDiscard",
471
491
  "snapshot",
492
+ "snapshotFile",
493
+ "ioRead",
472
494
  "cpuHistory",
473
495
  "backtrace",
474
496
  "timing",
@@ -483,8 +505,9 @@ export const VICE_OPERATIONS = [
483
505
  "window",
484
506
  ] as const satisfies readonly ViceOperation[];
485
507
 
486
- /** How many attachments each operation takes. */
487
- export function attachmentCount(op: ViceOperation): number {
508
+ /** How many attachments an operation takes; a snapshotFile restore takes the .vsf. */
509
+ export function attachmentCount(op: ViceOperation, params?: unknown): number {
510
+ if (op === "snapshotFile") return isObject(params) && params.action === "restore" ? 1 : 0;
488
511
  return op === "programLoad" || op === "autostart" || op === "diskAttach" ? 1 : 0;
489
512
  }
490
513
 
@@ -514,6 +537,15 @@ export function validateViceParams<O extends ViceOperation>(op: O, params: unkno
514
537
  }
515
538
  return { action: params.action, name: validName(params.name, "name") } as ViceOperations[O]["params"];
516
539
  }
540
+ case "snapshotFile": {
541
+ if (params.action === "restore") {
542
+ onlyFields(params, ["action"]);
543
+ return { action: "restore" } as ViceOperations[O]["params"];
544
+ }
545
+ if (params.action !== "export") invalid("action must be export or restore");
546
+ onlyFields(params, ["action", "name"]);
547
+ return { action: "export", name: validName(params.name, "name") } as ViceOperations[O]["params"];
548
+ }
517
549
  case "screenCompare": {
518
550
  onlyFields(params, ["baseline", "maxMismatchRatio", "mask", "includeDiff"]);
519
551
  const ratio = params.maxMismatchRatio;
@@ -609,6 +641,12 @@ export function validateViceParams<O extends ViceOperation>(op: O, params: unkno
609
641
  const result: MemoryReadParams = { address: params.address, size: params.size, space: params.space, view: params.view };
610
642
  return result as ViceOperations[O]["params"];
611
643
  }
644
+ case "ioRead": {
645
+ onlyFields(params, ["address", "size"]);
646
+ if (!isInteger(params.address, 0xd000, 0xdfff)) invalid("address must be in the I/O area $d000-$dfff");
647
+ if (!isInteger(params.size, 1, 0x1000) || params.address + params.size > 0xe000) invalid("the range must stay inside $d000-$dfff");
648
+ return { address: params.address, size: params.size } as ViceOperations[O]["params"];
649
+ }
612
650
  case "registersGet": {
613
651
  onlyFields(params, ["space"]);
614
652
  if (!isOneOf(SPACES, params.space)) invalid("space must be c64 or drive8");
@@ -765,7 +803,7 @@ export function validateViceParams<O extends ViceOperation>(op: O, params: unkno
765
803
  if (!isInteger(params.id, 1, 0xffff_ffff)) invalid("id must be a positive integer");
766
804
  return { action: params.action, id: params.id } as ViceOperations[O]["params"];
767
805
  }
768
- onlyFields(params, op === "watchpoint" ? ["action", "address", "space", "size", "access", "condition"] : ["action", "address", "space", "condition"]);
806
+ onlyFields(params, op === "watchpoint" ? ["action", "address", "space", "size", "access", "condition"] : ["action", "address", "space", "size", "condition"]);
769
807
  if (!isInteger(params.address, 0, 0xffff)) invalid("address must be an integer from 0 to 65535");
770
808
  if (!isOneOf(SPACES, params.space)) invalid("space must be c64 or drive8");
771
809
  const added: Record<string, unknown> = { action: "add", address: params.address, space: params.space };
@@ -775,6 +813,9 @@ export function validateViceParams<O extends ViceOperation>(op: O, params: unkno
775
813
  if (!isOneOf(WATCH_ACCESS, params.access)) invalid("access must be read, write or read-write");
776
814
  added.size = params.size;
777
815
  added.access = params.access;
816
+ } else if (params.size !== undefined) {
817
+ if (!isInteger(params.size, 1, 0x10000) || params.address + params.size > 0x10000) invalid("size must keep the range inside $0000-$ffff");
818
+ added.size = params.size;
778
819
  }
779
820
  if (params.condition !== undefined) added.condition = validateCondition(params.condition);
780
821
  return added as ViceOperations[O]["params"];
@@ -868,11 +909,16 @@ function validateRunTarget(value: unknown): RunTarget {
868
909
  return invalid("target kind must be address, memory or raster");
869
910
  }
870
911
 
912
+ function isRemoved(value: unknown): value is PointRemoved {
913
+ return isObject(value) && value.removed === true && isInteger(value.id, 1, 0xffff_ffff) && Object.keys(value).length === 2;
914
+ }
915
+
871
916
  function isBreakpoint(value: unknown): value is Breakpoint {
872
917
  return (
873
918
  isObject(value) &&
874
919
  isInteger(value.id, 1, 0xffff_ffff) &&
875
920
  isInteger(value.address, 0, 0xffff) &&
921
+ (value.size === undefined || isInteger(value.size, 1, 0x10000)) &&
876
922
  isOneOf(SPACES, value.space) &&
877
923
  typeof value.enabled === "boolean" &&
878
924
  (value.condition === undefined || isCondition(value.condition))
@@ -921,9 +967,10 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
921
967
  if (!ok) throw new ProtocolError("status result is malformed");
922
968
  break;
923
969
  }
924
- case "memoryRead": {
970
+ case "memoryRead":
971
+ case "ioRead": {
925
972
  if (!isInteger(value.address, 0, 0xffff) || !isHexData(value.data)) {
926
- throw new ProtocolError("memoryRead result is malformed");
973
+ throw new ProtocolError(`${op} result is malformed`);
927
974
  }
928
975
  break;
929
976
  }
@@ -1017,12 +1064,12 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1017
1064
  break;
1018
1065
  }
1019
1066
  case "breakpoint": {
1020
- const ok = Array.isArray(value.breakpoints) ? value.breakpoints.every(isBreakpoint) : isBreakpoint(value);
1067
+ const ok = Array.isArray(value.breakpoints) ? value.breakpoints.every(isBreakpoint) : isRemoved(value) || isBreakpoint(value);
1021
1068
  if (!ok) throw new ProtocolError("breakpoint result is malformed");
1022
1069
  break;
1023
1070
  }
1024
1071
  case "watchpoint": {
1025
- const ok = Array.isArray(value.watchpoints) ? value.watchpoints.every(isWatchpoint) : isWatchpoint(value);
1072
+ const ok = Array.isArray(value.watchpoints) ? value.watchpoints.every(isWatchpoint) : isRemoved(value) || isWatchpoint(value);
1026
1073
  if (!ok) throw new ProtocolError("watchpoint result is malformed");
1027
1074
  break;
1028
1075
  }
@@ -1059,6 +1106,11 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1059
1106
  if (!ok) throw new ProtocolError("snapshot result is malformed");
1060
1107
  break;
1061
1108
  }
1109
+ case "snapshotFile": {
1110
+ const ok = (value.exported === true && isInteger(value.bytes, 1, 0xffff_ffff)) || (value.restored === true && isOneOf(RUN_STATES, value.state));
1111
+ if (!ok) throw new ProtocolError("snapshotFile result is malformed");
1112
+ break;
1113
+ }
1062
1114
  case "cpuHistory": {
1063
1115
  const ok =
1064
1116
  Array.isArray(value.entries) &&
@@ -1111,8 +1163,10 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1111
1163
  isObject(range) &&
1112
1164
  isInteger(range.start, 0, 0xffff) &&
1113
1165
  isInteger(range.end, 0, 0xffff) &&
1114
- (["execute", "read", "write"] as const).every((flag) => typeof range[flag] === "boolean");
1115
- const ok = Array.isArray(value.ranges) ? value.ranges.every(isRange) : value.cleared === true;
1166
+ isInteger(range.flags, 1, 0xff);
1167
+ const ok = Array.isArray(value.ranges)
1168
+ ? value.ranges.every(isRange) && (value.next === undefined || isInteger(value.next, 0, 0xffff))
1169
+ : value.cleared === true;
1116
1170
  if (!ok) throw new ProtocolError("memmap result is malformed");
1117
1171
  break;
1118
1172
  }
package/src/script.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // a refusal prints {"error":{code,message}} with exit status 1, and a usage error prints the
3
3
  // usage text as an invalid-input error with exit status 2.
4
4
 
5
- import { WireFailure } from "./protocol/messages.ts";
5
+ import { shownMessage, WireFailure } from "./protocol/messages.ts";
6
6
  import { elapsedMs, startTrace, trace, type TraceFields } from "./trace.ts";
7
7
 
8
8
  /** A wrong command line. The message names the problem; an empty message asks for the usage text alone. */
@@ -38,7 +38,8 @@ export async function runScript(body: () => unknown, options: { usage: string; r
38
38
  result = await body();
39
39
  } catch (error) {
40
40
  if ([WireFailure, ...(options.refusals ?? [])].some((refusal) => error instanceof refusal)) {
41
- const { code, message } = error as Error & { code: string };
41
+ const { code } = error as Error & { code: string };
42
+ const message = shownMessage(error as Error & { code: string });
42
43
  printLine({ error: { code, message } });
43
44
  process.exitCode = 1;
44
45
  end({ error: { code, message } });