@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
package/src/mcp/main.ts CHANGED
@@ -28,10 +28,7 @@ the agent, through npx with the latest version.
28
28
  Environment:
29
29
  C64RT_VIDEO pal (default) or ntsc; fixed for the life of the process.
30
30
  C64RT_HOST host:port of the Host Runtime when it is not on 127.0.0.1:6464,
31
- host.docker.internal:6464 or host.containers.internal:6464.
32
- C64RT_HOST_TOKEN
33
- the shared secret of a Host Runtime that listens on an address
34
- that is not loopback. Use the same value as the Host Runtime.`;
31
+ host.docker.internal:6464 or host.containers.internal:6464.`;
35
32
 
36
33
  function log(line: string): void {
37
34
  process.stderr.write(`c64-re-tools-mcp: ${line}\n`);
@@ -0,0 +1,32 @@
1
+ // Tool results written to a project file instead of the context (D39). The
2
+ // MCP server runs on the agent's machine with the project as its working
3
+ // directory, so it writes the file itself; the host never sees the path.
4
+
5
+ import { createHash } from "node:crypto";
6
+
7
+ import { z } from "zod";
8
+
9
+ import { writeProjectFile } from "../host-client/transfer.ts";
10
+
11
+ export const OutInput = z.string().min(1).describe("write the result to this file, relative to the project directory, in place of returning it. An existing file is overwritten");
12
+
13
+ /** What a result says in place of the bytes it wrote. */
14
+ export interface Written {
15
+ out: string;
16
+ bytes: number;
17
+ sha256: string;
18
+ }
19
+
20
+ /** The fields of a result with out, optional in an output schema that also has the inline form. */
21
+ export const WrittenOutput = {
22
+ out: z.string().optional(),
23
+ bytes: z.number().int().min(0).optional(),
24
+ sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
25
+ };
26
+
27
+ /** Writes `data` to the project file `path` and gives what the result shows in its place. */
28
+ export function writeOut(path: string, data: Uint8Array | string, root?: string): Written {
29
+ const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : data;
30
+ writeProjectFile(path, bytes, { what: "out", ...(root === undefined ? {} : { root }) });
31
+ return { out: path, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
32
+ }
package/src/mcp/server.ts CHANGED
@@ -9,7 +9,7 @@ import { z } from "zod";
9
9
 
10
10
  import { formatC64Address, parseC64Address } from "../c64.ts";
11
11
  import type { ViceSessionClient } from "../host-client/vice-session.ts";
12
- import { WireFailure, type WireError } from "../protocol/messages.ts";
12
+ import { shownMessage, WireFailure, type WireError } from "../protocol/messages.ts";
13
13
  import { COMPARISONS, CONDITION_REGISTERS, MEMORY_VIEWS, SPACES, type Condition } from "../protocol/vice.ts";
14
14
  import { elapsedMs, trace, type TraceFields } from "../trace.ts";
15
15
 
@@ -38,6 +38,8 @@ export type ViceSessionApi = Pick<
38
38
  | "screenBaselines"
39
39
  | "screenDiscard"
40
40
  | "snapshot"
41
+ | "snapshotExport"
42
+ | "snapshotRestore"
41
43
  | "cpuHistory"
42
44
  | "backtrace"
43
45
  | "timing"
@@ -206,7 +208,7 @@ export function toolListing(tool: ToolDefinition): McpTool {
206
208
  }
207
209
 
208
210
  function errorResult(error: WireError): CallToolResult {
209
- return { isError: true, content: [{ type: "text", text: JSON.stringify({ code: error.code, message: error.message }) }] };
211
+ return { isError: true, content: [{ type: "text", text: JSON.stringify({ code: error.code, message: shownMessage(error) }) }] };
210
212
  }
211
213
 
212
214
  /** "address: Invalid string: must match ..." style messages, one sentence per problem. */
@@ -3,9 +3,13 @@
3
3
 
4
4
  import { z } from "zod";
5
5
 
6
- import { formatC64Address } from "../../c64.ts";
7
- import { CHECKPOINT_ACTIONS, MAX_BACKTRACE, MAX_HISTORY, MAX_MEMMAP_RANGES, MAX_PROFILE, MAX_WATCH_SIZE, SPACES, TIMING_ACTIONS, WATCH_ACCESS, type Breakpoint, type Watchpoint } from "../../protocol/vice.ts";
8
- import { AddressInput, AddressOutput, Byte, ConditionInput, ConditionOutput, defineTool, HexData, requireFields, showCondition, SpaceInput, toCondition } from "../server.ts";
6
+ import { COVERAGE, executedRamCode, EXECUTE, formatC64Address, READ, WRITE, type CodeRange } from "../../c64.ts";
7
+ import { WireFailure } from "../../protocol/messages.ts";
8
+ import { CHECKPOINT_ACTIONS, MAX_BACKTRACE, MAX_HISTORY, MAX_MEMMAP_RANGES, MAX_MEMORY_READ, MAX_PROFILE, MAX_WATCH_SIZE, SPACES, TIMING_ACTIONS, WATCH_ACCESS, type Breakpoint, type Watchpoint } from "../../protocol/vice.ts";
9
+ import { readCoverage } from "../../host-client/coverage.ts";
10
+ import { OutInput, writeOut, WrittenOutput } from "../output.ts";
11
+ import { AddressInput, AddressOutput, Byte, ConditionInput, ConditionOutput, defineTool, HexData, requireFields, showCondition, SpaceInput, toCondition, type ViceSessionApi } from "../server.ts";
12
+ import { spaced } from "./memory.ts";
9
13
 
10
14
  /** No default here: a filled-in default would count as a field the other actions refuse. */
11
15
  const OptionalSpace = z.enum(SPACES).optional().describe("add only: c64 (default) or drive8, the 1541 disk drive CPU");
@@ -37,7 +41,7 @@ export const c64Breakpoint = defineTool({
37
41
  description:
38
42
  "Manage breakpoints. A breakpoint stops the CPU when it is about to execute the instruction at an address. " +
39
43
  "Action add sets one at address in space c64 or drive8. With a condition, it stops only when the condition is true. " +
40
- "add returns the new id. Actions remove, enable and disable take that id. Action list gives all breakpoints. Each breakpoint shows its condition, when it has one. " +
44
+ "add returns the new id. Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all breakpoints. Each breakpoint shows its condition, when it has one. " +
41
45
  "Breakpoints stay through a reset.",
42
46
  inputSchema: z
43
47
  .object({
@@ -48,7 +52,7 @@ export const c64Breakpoint = defineTool({
48
52
  id: Id.optional().describe("remove, enable and disable only"),
49
53
  })
50
54
  .strict(),
51
- outputSchema: BreakpointOutput.partial().extend({ breakpoints: z.array(BreakpointOutput).optional() }),
55
+ outputSchema: BreakpointOutput.partial().extend({ removed: z.literal(true).optional(), breakpoints: z.array(BreakpointOutput).optional() }),
52
56
  readOnly: false,
53
57
  async run(input, session) {
54
58
  const { action, ...fields } = input;
@@ -59,6 +63,7 @@ export const c64Breakpoint = defineTool({
59
63
  }
60
64
  if (action !== "add") {
61
65
  requireFields(action, fields, ["id"], ["id"]);
66
+ if (action === "remove") return session.breakpoint({ action, id: fields.id! });
62
67
  return showBreakpoint(await session.breakpoint({ action, id: fields.id! }));
63
68
  }
64
69
  requireFields(action, fields, ["address", "space", "condition"], ["address"]);
@@ -79,7 +84,7 @@ export const c64Watchpoint = defineTool({
79
84
  "Manage watchpoints. A watchpoint stops the CPU when an instruction reads or writes memory in a range. " +
80
85
  `Action add sets one at address for size bytes (1 to ${MAX_WATCH_SIZE}, default 1), with access read, write or read-write. ` +
81
86
  "With a condition, it stops only when the condition is true. For a write, a memory condition sees the memory after the write. " +
82
- "add returns the new id. Actions remove, enable and disable take that id. Action list gives all watchpoints. Each watchpoint shows its condition, when it has one.",
87
+ "add returns the new id. Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all watchpoints. Each watchpoint shows its condition, when it has one.",
83
88
  inputSchema: z
84
89
  .object({
85
90
  action: z.enum(CHECKPOINT_ACTIONS),
@@ -91,7 +96,7 @@ export const c64Watchpoint = defineTool({
91
96
  id: Id.optional().describe("remove, enable and disable only"),
92
97
  })
93
98
  .strict(),
94
- outputSchema: WatchpointOutput.partial().extend({ watchpoints: z.array(WatchpointOutput).optional() }),
99
+ outputSchema: WatchpointOutput.partial().extend({ removed: z.literal(true).optional(), watchpoints: z.array(WatchpointOutput).optional() }),
95
100
  readOnly: false,
96
101
  async run(input, session) {
97
102
  const { action, ...fields } = input;
@@ -102,6 +107,7 @@ export const c64Watchpoint = defineTool({
102
107
  }
103
108
  if (action !== "add") {
104
109
  requireFields(action, fields, ["id"], ["id"]);
110
+ if (action === "remove") return session.watchpoint({ action, id: fields.id! });
105
111
  return showWatchpoint(await session.watchpoint({ action, id: fields.id! }));
106
112
  }
107
113
  requireFields(action, fields, ["address", "size", "access", "space", "condition"], ["address", "access"]);
@@ -124,8 +130,10 @@ export const c64CpuHistory = defineTool({
124
130
  "Get the last instructions that the CPU executed, oldest first. " +
125
131
  `limit is the number of instructions (1 to ${MAX_HISTORY}, default 50). ` +
126
132
  "Each entry gives the address, the bytes, the instruction and the registers a, x, y and sp. " +
127
- "In space c64, it also gives the raster line and cycle when the instruction started. This does not change the run state of the machine.",
128
- inputSchema: z.object({ limit: z.number().int().min(1).max(MAX_HISTORY).default(50), space: SpaceInput }).strict(),
133
+ "In space c64, it also gives the raster line and cycle when the instruction started. " +
134
+ "With out, it writes the entries as text lines to that project file, and the result gives the file in place of the entries. " +
135
+ "This does not change the run state of the machine.",
136
+ inputSchema: z.object({ limit: z.number().int().min(1).max(MAX_HISTORY).default(50), space: SpaceInput, out: OutInput.optional() }).strict(),
129
137
  outputSchema: z.object({
130
138
  entries: z.array(
131
139
  z.object({
@@ -139,12 +147,21 @@ export const c64CpuHistory = defineTool({
139
147
  rasterLine: z.number().int().min(0).optional(),
140
148
  rasterCycle: z.number().int().min(0).optional(),
141
149
  }),
142
- ),
150
+ ).optional(),
151
+ count: z.number().int().min(0).optional(),
152
+ ...WrittenOutput,
143
153
  }),
144
- readOnly: true,
154
+ readOnly: false,
145
155
  async run(input, session) {
146
- const { entries } = await session.cpuHistory(input);
147
- return { entries: entries.map((entry) => ({ ...entry, address: formatC64Address(entry.address) })) };
156
+ const { out, ...request } = input;
157
+ const { entries } = await session.cpuHistory(request);
158
+ if (out === undefined) return { entries: entries.map((entry) => ({ ...entry, address: formatC64Address(entry.address) })) };
159
+ const byte = (value: number) => value.toString(16).padStart(2, "0");
160
+ const lines = entries.map((entry) => {
161
+ const raster = entry.rasterLine === undefined ? "" : ` line ${entry.rasterLine} cycle ${entry.rasterCycle ?? 0}`;
162
+ return `${formatC64Address(entry.address)} ${spaced(entry.bytes).padEnd(9)} ${entry.text.padEnd(16)} a=${byte(entry.a)} x=${byte(entry.x)} y=${byte(entry.y)} sp=${byte(entry.sp)}${raster}`;
163
+ });
164
+ return { count: entries.length, ...writeOut(out, `${lines.join("\n")}\n`) };
148
165
  },
149
166
  });
150
167
 
@@ -203,34 +220,103 @@ export const c64Profile = defineTool({
203
220
  },
204
221
  });
205
222
 
223
+ /** The most code ranges a memmap summary lists. */
224
+ const MAX_SUMMARY_RANGES = 64;
225
+
226
+ /** Where an access happened: RAM, ROM or I/O. */
227
+ function sources(flags: number): Array<"ram" | "rom" | "io"> {
228
+ const where: Array<"ram" | "rom" | "io"> = [];
229
+ if (flags & (COVERAGE.ramRead | COVERAGE.ramWrite | COVERAGE.ramExecute)) where.push("ram");
230
+ if (flags & (COVERAGE.romRead | COVERAGE.romExecute)) where.push("rom");
231
+ if (flags & (COVERAGE.ioRead | COVERAGE.ioWrite | COVERAGE.ioExecute)) where.push("io");
232
+ return where;
233
+ }
234
+
235
+ const count = (map: Uint8Array, bit: number) => map.reduce((sum, flags) => sum + ((flags & bit) === 0 ? 0 : 1), 0);
236
+ const RangeOutput = z.object({ start: AddressOutput, end: AddressOutput });
237
+ const showRanges = (ranges: CodeRange[]) => ranges.slice(0, MAX_SUMMARY_RANGES).map((range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) }));
238
+
206
239
  export const c64Memmap = defineTool({
207
240
  name: "c64_memmap",
208
241
  title: "Memory access map",
209
242
  description:
210
- "Get how the C64 CPU used memory since the session started or since the last clear. " +
211
- "Action read gives ranges of addresses with the same access (execute, read, write) from start to end. " +
243
+ "Get how the C64 CPU used memory since the session started or since the last clear. RAM, ROM and I/O are apart. " +
244
+ "Action read gives ranges of addresses with the same access (execute, read, write) from start to end. Each range also tells if the access was in RAM, ROM or I/O. " +
212
245
  `It gives at most maxRanges ranges (1 to ${MAX_MEMMAP_RANGES}, default 256). Addresses the CPU did not touch are not in the result. ` +
246
+ "If more ranges follow, the result also gives truncated: true and next. Read again with start set to next. " +
247
+ "With out, read writes a coverage map to that project file, one byte for each address from start to end. " +
248
+ "Bits 0 to 2 are RAM read, write and execute. Bits 3 and 4 are ROM read and execute. Bits 5 to 7 are I/O read, write and execute. " +
249
+ "Execute is set only on the first byte of each executed instruction. " +
250
+ "Action summary gives the counts for RAM, ROM and I/O. It also gives the executed code in RAM with its operand bytes, and the RAM that was written and executed. " +
251
+ `Each list has at most ${MAX_SUMMARY_RANGES} ranges. ` +
213
252
  "Action clear sets all access records to zero.",
214
253
  inputSchema: z
215
254
  .object({
216
- action: z.enum(["read", "clear"]),
217
- start: AddressInput.optional().describe("read only. Default $0000"),
218
- end: AddressInput.optional().describe("read only. Default $ffff"),
219
- maxRanges: z.number().int().min(1).max(MAX_MEMMAP_RANGES).optional().describe("read only. Default 256"),
255
+ action: z.enum(["read", "summary", "clear"]),
256
+ start: AddressInput.optional().describe("read and summary. Default $0000"),
257
+ end: AddressInput.optional().describe("read and summary. Default $ffff"),
258
+ maxRanges: z.number().int().min(1).max(MAX_MEMMAP_RANGES).optional().describe("read without out only. Default 256"),
259
+ out: OutInput.optional().describe("read only: write a coverage map to this project file, one byte for each address. An existing file is overwritten"),
220
260
  })
221
261
  .strict(),
222
262
  outputSchema: z.object({
223
- ranges: z.array(z.object({ start: AddressOutput, end: AddressOutput, execute: z.boolean(), read: z.boolean(), write: z.boolean() })).optional(),
263
+ ranges: z.array(z.object({ start: AddressOutput, end: AddressOutput, execute: z.boolean(), read: z.boolean(), write: z.boolean(), in: z.array(z.enum(["ram", "rom", "io"])) })).optional(),
264
+ truncated: z.boolean().optional(),
265
+ next: AddressOutput.optional(),
266
+ ram: z.object({ executed: z.number().int(), read: z.number().int(), written: z.number().int() }).optional(),
267
+ rom: z.object({ executed: z.number().int(), read: z.number().int() }).optional(),
268
+ io: z.object({ executed: z.number().int(), read: z.number().int(), written: z.number().int() }).optional(),
269
+ code: z.array(RangeOutput).optional(),
270
+ codeTruncated: z.boolean().optional(),
271
+ writtenAndExecuted: z.array(RangeOutput).optional(),
272
+ writtenAndExecutedTruncated: z.boolean().optional(),
224
273
  cleared: z.boolean().optional(),
274
+ ...WrittenOutput,
225
275
  }),
226
276
  readOnly: false,
227
277
  async run(input, session) {
228
- if (input.action === "clear") {
229
- requireFields("clear", { start: input.start, end: input.end, maxRanges: input.maxRanges }, []);
278
+ const { action, ...fields } = input;
279
+ if (action === "clear") {
280
+ requireFields("clear", fields, []);
230
281
  return session.memmap({ action: "clear" });
231
282
  }
232
- const result = await session.memmap({ action: "read", start: input.start ?? 0x0000, end: input.end ?? 0xffff, maxRanges: input.maxRanges ?? 256 });
233
- return { ranges: result.ranges.map((range) => ({ ...range, start: formatC64Address(range.start), end: formatC64Address(range.end) })) };
283
+ const start = fields.start ?? 0x0000;
284
+ const end = fields.end ?? 0xffff;
285
+ if (end < start) throw new WireFailure("invalid-input", "end must not be before start.");
286
+ if (action === "summary") {
287
+ requireFields("summary", fields, ["start", "end"]);
288
+ const map = await readCoverage(session, start, end);
289
+ // The opcodes give the instruction lengths: RAM as it is now.
290
+ const parts: Buffer[] = [];
291
+ for (let at = 0; at < 0x10000; at += MAX_MEMORY_READ) parts.push(Buffer.from((await session.memoryRead({ address: at, size: MAX_MEMORY_READ, space: "c64", view: "ram" })).data, "hex"));
292
+ const memory = Buffer.concat(parts);
293
+ const code = executedRamCode(map, start, memory);
294
+ const written = executedRamCode(map.map((flags) => ((flags & COVERAGE.ramWrite) === 0 ? 0 : flags)), start, memory);
295
+ return {
296
+ ram: { executed: count(map, COVERAGE.ramExecute), read: count(map, COVERAGE.ramRead), written: count(map, COVERAGE.ramWrite) },
297
+ rom: { executed: count(map, COVERAGE.romExecute), read: count(map, COVERAGE.romRead) },
298
+ io: { executed: count(map, COVERAGE.ioExecute), read: count(map, COVERAGE.ioRead), written: count(map, COVERAGE.ioWrite) },
299
+ code: showRanges(code),
300
+ ...(code.length > MAX_SUMMARY_RANGES ? { codeTruncated: true } : {}),
301
+ writtenAndExecuted: showRanges(written),
302
+ ...(written.length > MAX_SUMMARY_RANGES ? { writtenAndExecutedTruncated: true } : {}),
303
+ };
304
+ }
305
+ if (fields.out !== undefined) {
306
+ requireFields("read", fields, ["start", "end", "out"]);
307
+ return writeOut(fields.out, await readCoverage(session, start, end));
308
+ }
309
+ requireFields("read", fields, ["start", "end", "maxRanges"]);
310
+ const result = await session.memmap({ action: "read", start, end, maxRanges: fields.maxRanges ?? 256 });
311
+ const ranges = result.ranges.map((range) => ({
312
+ start: formatC64Address(range.start),
313
+ end: formatC64Address(range.end),
314
+ execute: (range.flags & EXECUTE) !== 0,
315
+ read: (range.flags & READ) !== 0,
316
+ write: (range.flags & WRITE) !== 0,
317
+ in: sources(range.flags),
318
+ }));
319
+ return result.next === undefined ? { ranges } : { ranges, truncated: true, next: formatC64Address(result.next) };
234
320
  },
235
321
  });
236
322
 
@@ -13,6 +13,7 @@ export const c64Keyboard = defineTool({
13
13
  description:
14
14
  `Type up to ${MAX_KEYBOARD_BYTES} keys into the C64 keyboard queue. The C64 reads them while it runs. ` +
15
15
  "Only a program that reads keys through the KERNAL sees them. A program that reads the keyboard hardware directly does not see them. Many games do that. " +
16
+ "No command presses one key of the keyboard matrix: for one specific key, ask the user to press it in the window. " +
16
17
  'Mode text types text, for example "RUN\\n". Letters of either case type the normal (unshifted) key. A line break types RETURN. ' +
17
18
  "Mode petscii types PETSCII codes 1 to 255 in bytes, for example [147] to clear the screen. " +
18
19
  "This does not change the run state of the machine.",
@@ -5,7 +5,9 @@
5
5
  import { z } from "zod";
6
6
 
7
7
  import { formatC64Address } from "../../c64.ts";
8
+ import { readProjectFile } from "../../host-client/transfer.ts";
8
9
  import { AUTOSTART_TYPES, DISK_TYPES, MAX_SNAPSHOTS, RUN_STATES } from "../../protocol/vice.ts";
10
+ import { OutInput, writeOut, WrittenOutput } from "../output.ts";
9
11
  import { AddressInput, AddressOutput, defineTool, requireFields, TransientName } from "../server.ts";
10
12
 
11
13
  const ProjectPath = z.string().min(1).describe("path relative to the project directory, for example build/game.prg");
@@ -75,12 +77,16 @@ export const c64Snapshot = defineTool({
75
77
  title: "Machine snapshots",
76
78
  description:
77
79
  "Save and restore the whole machine state, attached disks included. Action save keeps a snapshot under a name you choose. " +
78
- "Action restore puts the machine back in that state and stops the CPU. The c64_timing stopwatch keeps its reading. " +
80
+ "With out, save also writes it to that project file as a snapshot file (.vsf), so a later session can use it. " +
81
+ "Action restore puts the machine back in that state and stops the CPU. Give name for a snapshot of this session, or file for a .vsf in the project. " +
82
+ "A file restores only in the emulator version and with the machine settings that saved it. The c64_timing stopwatch keeps its reading. " +
79
83
  `Action list gives the names, and action discard removes one. A session keeps at most ${MAX_SNAPSHOTS} snapshots until it ends.`,
80
84
  inputSchema: z
81
85
  .object({
82
86
  action: z.enum(["save", "restore", "list", "discard"]),
83
- name: TransientName.optional().describe("save, restore and discard: 1 to 64 letters, digits, dots, underscores or hyphens"),
87
+ name: TransientName.optional().describe("save and discard, and restore without file: 1 to 64 letters, digits, dots, underscores or hyphens"),
88
+ out: OutInput.optional().describe("save only: also write the snapshot to this project file, for example snapshots/room0.vsf. An existing file is overwritten"),
89
+ file: ProjectPath.optional().describe("restore only: a .vsf file in the project, in place of name"),
84
90
  })
85
91
  .strict(),
86
92
  outputSchema: z.object({
@@ -90,16 +96,26 @@ export const c64Snapshot = defineTool({
90
96
  state: z.enum(RUN_STATES).optional(),
91
97
  snapshots: z.array(z.string()).optional(),
92
98
  discarded: z.boolean().optional(),
99
+ ...WrittenOutput,
93
100
  }),
94
101
  readOnly: false,
95
102
  async run(input, session) {
96
- const { action, name } = input;
103
+ const { action, ...fields } = input;
97
104
  if (action === "list") {
98
- requireFields(action, { name }, []);
105
+ requireFields(action, fields, []);
99
106
  return session.snapshot({ action });
100
107
  }
101
- requireFields(action, { name }, ["name"], ["name"]);
102
- return session.snapshot({ action, name: name! });
108
+ if (action === "restore" && fields.file !== undefined) {
109
+ requireFields(action, fields, ["file"], ["file"]);
110
+ return session.snapshotRestore(readProjectFile(fields.file).bytes);
111
+ }
112
+ if (action === "save") {
113
+ requireFields(action, fields, ["name", "out"], ["name"]);
114
+ const saved = await session.snapshot({ action, name: fields.name! });
115
+ return fields.out === undefined ? saved : { ...saved, ...writeOut(fields.out, await session.snapshotExport(fields.name!)) };
116
+ }
117
+ requireFields(action, fields, ["name"], ["name"]);
118
+ return session.snapshot({ action, name: fields.name! });
103
119
  },
104
120
  });
105
121
 
@@ -6,28 +6,46 @@ import { z } from "zod";
6
6
  import { formatC64Address } from "../../c64.ts";
7
7
  import { WireFailure } from "../../protocol/messages.ts";
8
8
  import { MAX_COMPARE_DIFFERENCES, MAX_COMPARE_SIZE, MAX_DISASSEMBLE, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_SEARCH_PATTERN, MAX_SEARCH_RESULTS, type RegisterValues } from "../../protocol/vice.ts";
9
+ import { OutInput, writeOut, WrittenOutput } from "../output.ts";
9
10
  import { AddressInput, AddressOutput, Byte, defineTool, HexData, HexDataInput, MemoryViewInput, requireFields, SpaceInput } from "../server.ts";
10
11
 
12
+ /** With out, a read or a disassembly can be larger than one answer of the session: it is read in parts (D39). */
13
+ const MAX_MEMORY_READ_OUT = 0x10000;
14
+ const MAX_DISASSEMBLE_OUT = 32768;
15
+
11
16
  export const c64MemoryRead = defineTool({
12
17
  name: "c64_memory_read",
13
18
  title: "Read C64 memory",
14
19
  description:
15
20
  `Read 1 to ${MAX_MEMORY_READ} bytes of memory, starting at an address. ` +
16
21
  "The range must not go past $ffff. Use space drive8 to read the memory of the 1541 disk drive. " +
22
+ `With out, it reads up to ${MAX_MEMORY_READ_OUT} bytes and writes them as raw bytes to that project file. The result then gives the file, the number of bytes and a SHA-256 in place of data. ` +
17
23
  "This does not change the run state of the machine.",
18
24
  inputSchema: z
19
25
  .object({
20
26
  address: AddressInput,
21
- size: z.number().int().min(1).max(MAX_MEMORY_READ).describe(`number of bytes, 1 to ${MAX_MEMORY_READ}`),
27
+ size: z.number().int().min(1).max(MAX_MEMORY_READ_OUT).describe(`number of bytes, 1 to ${MAX_MEMORY_READ}, or to ${MAX_MEMORY_READ_OUT} with out`),
22
28
  space: SpaceInput,
23
29
  view: MemoryViewInput,
30
+ out: OutInput.optional(),
24
31
  })
25
32
  .strict(),
26
- outputSchema: z.object({ address: AddressOutput, size: z.number().int().min(1).max(MAX_MEMORY_READ), data: HexData }),
27
- readOnly: true,
33
+ outputSchema: z.object({ address: AddressOutput, size: z.number().int().min(1).max(MAX_MEMORY_READ_OUT), data: HexData.optional(), ...WrittenOutput }),
34
+ readOnly: false,
28
35
  async run(input, session) {
29
- const result = await session.memoryRead(input);
30
- return { address: formatC64Address(result.address), size: result.data.length / 2, data: result.data };
36
+ const { out, ...read } = input;
37
+ if (out === undefined) {
38
+ if (read.size > MAX_MEMORY_READ) throw new WireFailure("invalid-input", `size can be at most ${MAX_MEMORY_READ} without out.`);
39
+ const result = await session.memoryRead(read);
40
+ return { address: formatC64Address(result.address), size: result.data.length / 2, data: result.data };
41
+ }
42
+ if (read.address + read.size > 0x10000) throw new WireFailure("invalid-input", "The range must not go past $ffff.");
43
+ const parts: Buffer[] = [];
44
+ for (let at = read.address; at < read.address + read.size; at += MAX_MEMORY_READ) {
45
+ const part = await session.memoryRead({ ...read, address: at, size: Math.min(MAX_MEMORY_READ, read.address + read.size - at) });
46
+ parts.push(Buffer.from(part.data, "hex"));
47
+ }
48
+ return { address: formatC64Address(read.address), size: read.size, ...writeOut(out, Buffer.concat(parts)) };
31
49
  },
32
50
  });
33
51
 
@@ -183,21 +201,50 @@ export const c64Disassemble = defineTool({
183
201
  description:
184
202
  `Disassemble count instructions (1 to ${MAX_DISASSEMBLE}) from an address, as the bytes are in the live machine now. ` +
185
203
  "Use it to read code that a program changed or unpacked at run time. It includes the undocumented opcodes. " +
204
+ `With out, it disassembles up to ${MAX_DISASSEMBLE_OUT} instructions, or to $ffff, and writes them as a listing to that project file. The result then gives the file in place of the instructions. ` +
186
205
  "This does not change the run state of the machine.",
187
206
  inputSchema: z
188
207
  .object({
189
208
  address: AddressInput,
190
- count: z.number().int().min(1).max(MAX_DISASSEMBLE),
209
+ count: z.number().int().min(1).max(MAX_DISASSEMBLE_OUT).describe(`1 to ${MAX_DISASSEMBLE}, or to ${MAX_DISASSEMBLE_OUT} with out`),
191
210
  space: SpaceInput,
192
211
  view: MemoryViewInput,
212
+ out: OutInput.optional(),
193
213
  })
194
214
  .strict(),
195
- outputSchema: z.object({ instructions: z.array(z.object({ address: AddressOutput, bytes: HexData, text: z.string() })) }),
196
- readOnly: true,
215
+ outputSchema: z.object({ instructions: z.array(z.object({ address: AddressOutput, bytes: HexData, text: z.string() })).optional(), count: z.number().int().min(0).optional(), ...WrittenOutput }),
216
+ readOnly: false,
197
217
  async run(input, session) {
198
- const result = await session.disassemble(input);
199
- return { instructions: result.instructions.map((instruction) => ({ ...instruction, address: formatC64Address(instruction.address) })) };
218
+ const { out, ...request } = input;
219
+ if (out === undefined) {
220
+ if (request.count > MAX_DISASSEMBLE) throw new WireFailure("invalid-input", `count can be at most ${MAX_DISASSEMBLE} without out.`);
221
+ const result = await session.disassemble(request);
222
+ return { instructions: result.instructions.map((instruction) => ({ ...instruction, address: formatC64Address(instruction.address) })) };
223
+ }
224
+ const lines: string[] = [];
225
+ let address = request.address;
226
+ // Ends at count, or where the disassembly would wrap past $ffff.
227
+ let wrapped = false;
228
+ while (!wrapped && lines.length < request.count && address <= 0xffff) {
229
+ const { instructions } = await session.disassemble({ ...request, address, count: Math.min(MAX_DISASSEMBLE, request.count - lines.length) });
230
+ if (instructions.length === 0) break;
231
+ for (const instruction of instructions) {
232
+ if (lines.length === request.count) break;
233
+ if (instruction.address < address) {
234
+ wrapped = true;
235
+ break;
236
+ }
237
+ lines.push(`${formatC64Address(instruction.address)} ${spaced(instruction.bytes).padEnd(9)} ${instruction.text}`);
238
+ address = instruction.address + instruction.bytes.length / 2;
239
+ }
240
+ }
241
+ return { count: lines.length, ...writeOut(out, `${lines.join("\n")}\n`) };
200
242
  },
201
243
  });
202
244
 
245
+ /** Hex bytes as pairs with spaces: "a90a" is "a9 0a". */
246
+ export function spaced(hex: string): string {
247
+ return (hex.match(/../g) ?? []).join(" ");
248
+ }
249
+
203
250
  export const memoryTools = [c64MemoryRead, c64MemoryWrite, c64MemorySearch, c64MemoryCompare, c64Registers, c64Disassemble];
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  import { formatC64Address } from "../../c64.ts";
7
7
  import { WireFailure } from "../../protocol/messages.ts";
8
8
  import { CIA_SELECTIONS, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_OBSERVE_BYTES, MAX_OBSERVE_RANGES, SPACES, VICII_MODES, type ObserveParams } from "../../protocol/vice.ts";
9
+ import { OutInput, writeOut, WrittenOutput } from "../output.ts";
9
10
  import { AddressInput, AddressOutput, Byte, defineTool, HexData, MemoryViewInput, requireFields, SpaceInput, ToolOutput, TransientName } from "../server.ts";
10
11
 
11
12
  const Color = z.number().int().min(0).max(15);
@@ -129,6 +130,7 @@ export const c64Screen = defineTool({
129
130
  title: "Capture and compare the C64 screen",
130
131
  description:
131
132
  "Action capture returns the last frame the C64 drew, with its borders, as a PNG image, plus its width and height in pixels. " +
133
+ "With out, capture writes the PNG to that project file, and compare writes its difference image there. The result then has no image. " +
132
134
  "With baseline, the session also keeps that frame under the name. " +
133
135
  "Action compare compares the current frame with a baseline, pixel by pixel. mask lists rectangles to ignore. " +
134
136
  "match is true when the part of compared pixels that differ is at most maxMismatchRatio (default 0). bounds holds every different pixel. " +
@@ -143,6 +145,7 @@ export const c64Screen = defineTool({
143
145
  maxMismatchRatio: z.number().min(0).max(1).optional().describe("compare only: 0 to 1, default 0"),
144
146
  mask: z.array(Rectangle).max(MAX_MASK_RECTANGLES).optional().describe("compare only: rectangles to ignore, in frame pixels"),
145
147
  includeDiff: z.boolean().optional().describe("compare only: also return a difference image"),
148
+ out: OutInput.optional(),
146
149
  })
147
150
  .strict(),
148
151
  outputSchema: z.object({
@@ -155,24 +158,27 @@ export const c64Screen = defineTool({
155
158
  bounds: Rectangle.optional(),
156
159
  baselines: z.array(z.string()).optional(),
157
160
  discarded: z.boolean().optional(),
161
+ ...WrittenOutput,
158
162
  }),
159
163
  readOnly: false,
160
164
  async run(input, session) {
161
165
  const { action, ...fields } = input;
162
166
  if (action === "capture") {
163
- requireFields(action, fields, ["baseline"]);
167
+ requireFields(action, fields, ["baseline", "out"]);
164
168
  const shot = await session.screenCapture(fields.baseline);
165
169
  const result = { width: shot.width, height: shot.height, ...(shot.baseline === undefined ? {} : { baseline: shot.baseline }) };
170
+ if (fields.out !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(shot.png, "base64")) };
166
171
  return new ToolOutput(result, [{ type: "image", data: shot.png, mimeType: "image/png" }]);
167
172
  }
168
173
  if (action === "compare") {
169
- requireFields(action, fields, ["baseline", "maxMismatchRatio", "mask", "includeDiff"], ["baseline"]);
174
+ requireFields(action, fields, ["baseline", "maxMismatchRatio", "mask", "includeDiff", "out"], ["baseline"]);
170
175
  const { diffPng, ...result } = await session.screenCompare({
171
176
  baseline: fields.baseline!,
172
177
  maxMismatchRatio: fields.maxMismatchRatio ?? 0,
173
178
  mask: fields.mask ?? [],
174
- includeDiff: fields.includeDiff ?? false,
179
+ includeDiff: (fields.includeDiff ?? false) || fields.out !== undefined,
175
180
  });
181
+ if (fields.out !== undefined && diffPng !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(diffPng, "base64")) };
176
182
  return diffPng === undefined ? result : new ToolOutput(result, [{ type: "image", data: diffPng, mimeType: "image/png" }]);
177
183
  }
178
184
  if (action === "list") {
@@ -7,6 +7,9 @@ import { isAbsolute, join, posix, win32 } from "node:path";
7
7
 
8
8
  import { WireFailure } from "../protocol/messages.ts";
9
9
 
10
+ /** Where the user finds the install steps of the native tools for each system (D38). */
11
+ export const INSTALL_STEPS = "Steps for each system: https://github.com/henols/c64-re-tools/blob/main/docs/install-tools.md";
12
+
10
13
  export interface ToolSpec {
11
14
  /** Human name, for messages. */
12
15
  name: string;
@@ -25,7 +28,7 @@ export const ACME: ToolSpec = {
25
28
  envVar: "C64RT_ACME",
26
29
  binaries: ["acme"],
27
30
  where: "on this machine",
28
- remedy: "Install the ACME cross-assembler on the machine that runs the skill scripts so that acme is on PATH, or set C64RT_ACME to its full path.",
31
+ remedy: `Install the ACME cross-assembler on the machine that runs the skill scripts so that acme is on PATH, or set C64RT_ACME to its full path. ${INSTALL_STEPS}`,
29
32
  };
30
33
 
31
34
  export const C1541: ToolSpec = {
@@ -49,7 +52,7 @@ export const DXA: ToolSpec = {
49
52
  envVar: "C64RT_DXA",
50
53
  binaries: ["dxa"],
51
54
  where: "on this machine",
52
- remedy: "Install dxa (from the xa package, https://www.floodgap.com/retrotech/xa/) on the machine that runs the skill scripts so that dxa is on PATH, or set C64RT_DXA to its full path.",
55
+ remedy: `Install dxa 0.1.5 on the machine that runs the skill scripts so that dxa is on PATH, or set C64RT_DXA to its full path. ${INSTALL_STEPS}`,
53
56
  };
54
57
 
55
58
  export const VICE: ToolSpec = {
@@ -10,7 +10,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
 
12
12
  import { WireFailure } from "../../protocol/messages.ts";
13
- import { findOnPath, isExecutableFile } from "../discover.ts";
13
+ import { findOnPath, INSTALL_STEPS, isExecutableFile } from "../discover.ts";
14
14
  import type { ProcessSupervisor } from "../processes.ts";
15
15
  import type { Workspace } from "../staging.ts";
16
16
  import { outputTail, runToolOrFail, type ToolRun } from "../run.ts";
@@ -22,7 +22,7 @@ export const SCRIPT_DIRECTORY = fileURLToPath(new URL("./scripts/", import.meta.
22
22
 
23
23
  const REMEDY =
24
24
  "Install Ghidra on the machine that runs the skill scripts and set C64RT_GHIDRA to its installation directory " +
25
- "(the directory that holds support/analyzeHeadless), or put analyzeHeadless on PATH.";
25
+ `(the directory that holds support/analyzeHeadless), or put analyzeHeadless on PATH. ${INSTALL_STEPS}`;
26
26
 
27
27
  export interface GhidraInstallation {
28
28
  /** The installation directory. */
@@ -31,6 +31,8 @@ export interface GhidraInstallation {
31
31
  /** For example ghidra_12.1.3_PUBLIC: the name of Ghidra's settings directory. */
32
32
  settingsName: string;
33
33
  version: string;
34
+ /** The lowest Java version this Ghidra runs on (application.java.min), when it names one. */
35
+ javaMin?: string;
34
36
  }
35
37
 
36
38
  /** Ghidra's launcher is a shell script, and a batch file on Windows. */
@@ -49,7 +51,8 @@ function installationAt(root: string, source: string): GhidraInstallation {
49
51
  const version = value("application.version");
50
52
  const release = value("application.release.name");
51
53
  if (version === undefined || release === undefined) throw new WireFailure("installation-incomplete", `The Ghidra installation at ${source} has no version. ${REMEDY}`);
52
- return { root, analyzeHeadless, settingsName: `ghidra_${version}_${release}`, version };
54
+ const javaMin = value("application.java.min");
55
+ return { root, analyzeHeadless, settingsName: `ghidra_${version}_${release}`, version, ...(javaMin === undefined || javaMin === "" ? {} : { javaMin }) };
53
56
  }
54
57
 
55
58
  /** The version of a Ghidra installation as a result and the tool status name it, for example "Ghidra 12.1.3". */
@@ -123,6 +126,18 @@ export interface HeadlessOptions {
123
126
  env?: NodeJS.ProcessEnv;
124
127
  }
125
128
 
129
+ /** What Ghidra's launcher (launch.sh, launch.bat) prints when it finds no Java that it can use. */
130
+ export const NO_USABLE_JDK = /The 'java' command could not be found in your PATH or with JAVA_HOME|Unable to prompt user for JDK path, no TTY detected|Failed to find a supported JDK/;
131
+
132
+ /** A Ghidra without a usable JDK is an incomplete installation that names the Java version it needs (D38). */
133
+ export function noJdk(ghidra: GhidraInstallation): WireFailure {
134
+ const java = ghidra.javaMin === undefined ? "a JDK" : `JDK ${ghidra.javaMin} or later`;
135
+ return new WireFailure(
136
+ "installation-incomplete",
137
+ `${ghidraVersion(ghidra)} found no Java that it can use. It needs ${java}. Install it on the machine that runs the skill scripts and put its java on PATH, or set JAVA_HOME to it. ${INSTALL_STEPS}`,
138
+ );
139
+ }
140
+
126
141
  /** Imports one file into a disposable project and runs the scripts. Fails on timeout, abort or a Ghidra error exit. */
127
142
  export async function runHeadless(options: HeadlessOptions): Promise<ToolRun> {
128
143
  const env = prepareSettings(options.workspace, options.ghidra, options.env);
@@ -148,6 +163,7 @@ export async function runHeadless(options: HeadlessOptions): Promise<ToolRun> {
148
163
  for (const script of options.preScripts ?? []) argv.push("-preScript", script.name, ...script.args);
149
164
  for (const script of options.postScripts ?? []) argv.push("-postScript", script.name, ...script.args);
150
165
  const run = await runToolOrFail("Ghidra", "The Ghidra analysis", { argv, cwd: options.workspace.root, timeoutMs: options.timeoutMs }, { supervisor: options.supervisor, signal: options.signal, env });
166
+ if (run.code !== 0 && NO_USABLE_JDK.test(run.stdout + run.stderr)) throw noJdk(options.ghidra);
151
167
  if (run.code !== 0 || /^ERROR REPORT SCRIPT ERROR|Exception in thread "main"/m.test(run.stdout + run.stderr)) {
152
168
  // Ghidra's own error lines tell the user what to fix.
153
169
  const tail = outputTail("Ghidra", run, options.workspace.root, /ERROR|Exception|Error:/);