@henols/c64-re-tools 1.1.0 → 2.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -37
  3. package/distribution/package.ts +36 -0
  4. package/distribution/plugin.ts +120 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +73 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +103 -0
  8. package/skills/c64-basic/SKILL.md +67 -0
  9. package/skills/c64-basic/scripts/basic.ts +46 -0
  10. package/skills/c64-disk/SKILL.md +79 -0
  11. package/skills/c64-disk/scripts/disk.ts +65 -0
  12. package/skills/c64-emulator/SKILL.md +124 -0
  13. package/skills/c64-knowledge/SKILL.md +102 -0
  14. package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
  15. package/skills/c64-memory-map/SKILL.md +76 -0
  16. package/skills/c64-memory-map/scripts/memmap.ts +81 -0
  17. package/skills/c64-memory-map/scripts/platform.ts +424 -0
  18. package/skills/c64-provenance/SKILL.md +76 -0
  19. package/skills/c64-provenance/scripts/compare.ts +92 -0
  20. package/skills/c64-provenance/scripts/provenance.ts +83 -0
  21. package/skills/c64-reverse-engineering/SKILL.md +90 -0
  22. package/skills/c64-static-analysis/SKILL.md +96 -0
  23. package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
  24. package/skills/c64-static-analysis/scripts/findings.ts +73 -0
  25. package/skills/c64-testing/SKILL.md +79 -0
  26. package/skills/c64-testing/references/scenario-format.md +76 -0
  27. package/skills/c64-testing/scripts/checklist.ts +51 -0
  28. package/skills/c64-testing/scripts/scenario.ts +473 -0
  29. package/skills/c64-testing/scripts/test.ts +55 -0
  30. package/skills/c64-unpacker/SKILL.md +82 -0
  31. package/skills/c64-unpacker/scripts/evidence.ts +135 -0
  32. package/skills/c64-unpacker/scripts/unpack.ts +216 -0
  33. package/src/c64.ts +281 -0
  34. package/src/cli/ap-sdk.ts +44 -0
  35. package/src/cli/cleanup.ts +194 -0
  36. package/src/cli/main.ts +122 -0
  37. package/src/cli/status.ts +34 -0
  38. package/src/host/main.ts +121 -0
  39. package/src/host/server.ts +409 -0
  40. package/src/host/tools/c1541.ts +255 -0
  41. package/src/host/tools/index.ts +31 -0
  42. package/src/host/tools/petcat.ts +70 -0
  43. package/src/host/tools/status.ts +30 -0
  44. package/src/host/tools/version.ts +41 -0
  45. package/src/host/vice/adapter.ts +761 -0
  46. package/src/host/vice/binary-monitor.ts +389 -0
  47. package/src/host/vice/chips.ts +108 -0
  48. package/src/host/vice/connect.ts +34 -0
  49. package/src/host/vice/process.ts +402 -0
  50. package/src/host/vice/screen.ts +183 -0
  51. package/src/host/vice/session.ts +1316 -0
  52. package/src/host/vice/text-monitor.ts +223 -0
  53. package/src/host-client/connect.ts +241 -0
  54. package/src/host-client/tools.ts +89 -0
  55. package/src/host-client/transfer.ts +100 -0
  56. package/src/host-client/vice-session.ts +254 -0
  57. package/src/knowledge/database.ts +132 -0
  58. package/src/knowledge/history.ts +128 -0
  59. package/src/knowledge/import.ts +344 -0
  60. package/src/knowledge/read.ts +191 -0
  61. package/src/knowledge/schema.ts +140 -0
  62. package/src/knowledge/write.ts +348 -0
  63. package/src/mcp/main.ts +109 -0
  64. package/src/mcp/server.ts +307 -0
  65. package/src/mcp/tools/debug.ts +237 -0
  66. package/src/mcp/tools/execution.ts +116 -0
  67. package/src/mcp/tools/input.ts +69 -0
  68. package/src/mcp/tools/machine.ts +97 -0
  69. package/src/mcp/tools/media.ts +106 -0
  70. package/src/mcp/tools/memory.ts +203 -0
  71. package/src/mcp/tools/video.ts +260 -0
  72. package/src/native/acme.ts +116 -0
  73. package/src/native/discover.ts +141 -0
  74. package/src/native/dxa.ts +166 -0
  75. package/src/native/ghidra/analyze.ts +86 -0
  76. package/src/native/ghidra/index.ts +157 -0
  77. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  78. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  79. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  80. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  81. package/src/native/ghidra/scripts/C64Export.java +242 -0
  82. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  83. package/src/native/local.ts +34 -0
  84. package/src/native/processes.ts +350 -0
  85. package/src/native/run.ts +172 -0
  86. package/src/native/staging.ts +83 -0
  87. package/src/native/status.ts +101 -0
  88. package/src/native/types.ts +228 -0
  89. package/src/native/watchdog.ts +78 -0
  90. package/src/project.ts +39 -0
  91. package/src/protocol/framing.ts +132 -0
  92. package/src/protocol/messages.ts +252 -0
  93. package/src/protocol/tools.ts +247 -0
  94. package/src/protocol/vice.ts +1235 -0
  95. package/src/script.ts +59 -0
  96. package/src/time.ts +15 -0
  97. package/src/trace.ts +162 -0
  98. package/THIRD-PARTY-NOTICES.md +0 -26
  99. package/bin/cli.mjs +0 -283
  100. package/skills/acme-build/SKILL.md +0 -260
  101. package/skills/acme-build/scripts/acme.mjs +0 -341
  102. package/skills/acme-build/template.a +0 -53
  103. package/skills/c64-disk-access/SKILL.md +0 -159
  104. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  105. package/skills/c64-memory-mapping/SKILL.md +0 -631
  106. package/skills/c64-memory-mapping/memmap.json +0 -8800
  107. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  108. package/skills/c64-petcat/SKILL.md +0 -87
  109. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  110. package/skills/c64-program-recon/SKILL.md +0 -706
  111. package/skills/c64-program-recon/references/control-flow.md +0 -180
  112. package/skills/c64-program-recon/references/graphics.md +0 -79
  113. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  114. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  115. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  116. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  117. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  118. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  119. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  120. package/skills/c64-provenance-diff/SKILL.md +0 -292
  121. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  122. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  123. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  124. package/skills/c64-ram-capture/SKILL.md +0 -474
  125. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  126. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  127. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  128. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  129. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  130. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  131. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  132. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  133. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  134. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  135. package/skills/c64-ram-capture/transients/README.md +0 -137
  136. package/skills/routine-queue-walker/SKILL.md +0 -394
  137. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  138. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,166 @@
1
+ // The DXA adapter. DXA prints an assembler listing only; with
2
+ // "-a dump" each line starts with its address and bytes, so the adapter can
3
+ // tell code from data line by line. The whole listing is checked before the
4
+ // script imports anything: lines in address order from the load address to
5
+ // the end, each line as long as its bytes or data items, so a truncated or
6
+ // changed listing is refused instead of half imported.
7
+
8
+ import { writeFileSync } from "node:fs";
9
+
10
+ import { SYMBOL_NAME } from "../c64.ts";
11
+ import { describeError, WireFailure } from "../protocol/messages.ts";
12
+ import { checkDxaResult, checkRequestBounds, imageRange, seedsInside, type DxaParams, type DxaResult } from "./types.ts";
13
+ import { Workspace } from "./staging.ts";
14
+ import { DXA, findTool } from "./discover.ts";
15
+ import { outputTail, runTool, runToolOrFail, type ToolContext } from "./run.ts";
16
+ import { DXA_VERSION, versionLine } from "./status.ts";
17
+
18
+ const TIMEOUT_MS = 120_000;
19
+ /** The listing is returned as an attachment; larger output is refused. */
20
+ const MAX_LISTING = 8 * 1024 * 1024;
21
+ const VERSION_TIMEOUT_MS = 20_000;
22
+ const MAX_VERSION_OUTPUT = 64 * 1024;
23
+
24
+ const hex = (value: number) => value.toString(16).padStart(4, "0");
25
+
26
+ /**
27
+ * The options for one run; every value comes from the validated request. A
28
+ * seed file is passed only when it has lines: dxa refuses an empty one.
29
+ */
30
+ export function dxaArguments(executable: string, params: DxaParams, files: { image: string; routines?: string; blocks?: string; labels?: string }): string[] {
31
+ const argv = [executable, "-a", "dump", "-p", "all-nmos6502", "-d", "skip-scanning"];
32
+ if (files.routines !== undefined) argv.push("-R", files.routines);
33
+ if (files.blocks !== undefined) argv.push("-B", files.blocks);
34
+ if (files.labels !== undefined) argv.push("-l", files.labels);
35
+ // A PRG gives its load address and may start with BASIC (-U finds its SYS);
36
+ // 64 KiB of memory starts at $0000 and has no load address.
37
+ if (params.imageKind === "prg") argv.push("-U");
38
+ else argv.push("-g", "0000", "-q");
39
+ argv.push(files.image);
40
+ return argv;
41
+ }
42
+
43
+ /** Number of data bytes in a .byt or .word line, or undefined for an instruction. */
44
+ function dataSize(operation: string): number | undefined {
45
+ const match = /^\.(byt|word)\s+(.+)$/.exec(operation.trim());
46
+ if (match === null) return undefined;
47
+ const items = match[2]!.split(",").length;
48
+ return match[1] === "word" ? items * 2 : items;
49
+ }
50
+
51
+ /**
52
+ * Parses a "-a dump" listing into regions and labels for [start, end].
53
+ * Throws an Error when the listing is not a complete, consistent listing of
54
+ * exactly that range.
55
+ */
56
+ export function parseListing(listing: string, start: number, end: number): Pick<DxaResult, "regions" | "labels"> {
57
+ const statements: Array<{ address: number; dumped: number; data: number | undefined }> = [];
58
+ const labels: DxaResult["labels"] = [];
59
+ for (const line of listing.split(/\r?\n/)) {
60
+ // dxa prints a seed label as it is, also a local name that starts with a dot.
61
+ const label = /^([0-9a-f]{4})\s+(\.?[A-Za-z_]\w*):\s*$/.exec(line);
62
+ if (label !== null) {
63
+ const address = Number.parseInt(label[1]!, 16);
64
+ if (address < start || address > end || !SYMBOL_NAME.test(label[2]!)) throw new Error(`label ${label[2]} at $${label[1]} is not usable`);
65
+ labels.push({ address, name: label[2]! });
66
+ continue;
67
+ }
68
+ // dxa pads the bytes column with a tab, but a two-byte .word line with spaces only.
69
+ const statement = /^([0-9a-f]{4}) ((?:[0-9a-f]{2} ?){1,3})\s+(\S.*)$/.exec(line);
70
+ if (statement === null) continue;
71
+ statements.push({ address: Number.parseInt(statement[1]!, 16), dumped: statement[2]!.trim().split(/\s+/).length, data: dataSize(statement[3]!) });
72
+ }
73
+ const first = statements[0];
74
+ const last = statements.at(-1);
75
+ if (first === undefined || last === undefined) throw new Error("the listing has no statements");
76
+ if (first.address !== start) throw new Error(`the listing starts at $${hex(first.address)}, not at $${hex(start)}`);
77
+ if (last.address > end) throw new Error("the listing runs past the end of the program");
78
+ const regions: DxaResult["regions"] = [];
79
+ statements.forEach((statement, index) => {
80
+ const next = statements[index + 1]?.address ?? end + 1;
81
+ const size = next - statement.address;
82
+ if (size <= 0) throw new Error(`the listing goes back at $${hex(next)}`);
83
+ // Data lines count their items; an instruction line dumps exactly its bytes.
84
+ const expected = statement.data ?? statement.dumped;
85
+ if (expected !== size) throw new Error(`the line at $${hex(statement.address)} holds ${expected} bytes, but the next line is ${size} bytes later`);
86
+ const classification = statement.data === undefined ? "code" : "data";
87
+ const last = regions.at(-1);
88
+ if (last?.classification === classification) last.end = next - 1;
89
+ else regions.push({ start: statement.address, end: next - 1, classification });
90
+ });
91
+ return { regions, labels };
92
+ }
93
+
94
+ /**
95
+ * The version line of dxa, for example "dxa v0.1.5 -- symbolic 65xx
96
+ * disassembler". "dxa -V" prints it before its usage and exits with status 1.
97
+ * On Windows dxa names itself by its full path and ".exe": the line names it
98
+ * "dxa" on every system. Undefined when dxa prints no version line.
99
+ */
100
+ export async function dxaVersion(executable: string, cwd: string, context: ToolContext): Promise<string | undefined> {
101
+ const run = await runTool({ argv: [executable, "-V"], cwd, supervisor: context.supervisor, signal: context.signal, timeoutMs: VERSION_TIMEOUT_MS, outputLimit: MAX_VERSION_OUTPUT, env: context.env });
102
+ return versionLine(`${run.stdout}\n${run.stderr}`, DXA_VERSION)?.replace(/^(?:.*[\\/])?dxa(?:\.exe)?/i, "dxa");
103
+ }
104
+
105
+ export async function analyze(
106
+ params: DxaParams,
107
+ image: Buffer,
108
+ context: ToolContext,
109
+ ): Promise<{ result: DxaResult; attachments: Buffer[] }> {
110
+ checkRequestBounds(params);
111
+ const executable = findTool(DXA, context.env);
112
+ const { start, end } = imageRange(params.imageKind, image);
113
+ const { entryPoints, dataRanges } = seedsInside(params, start, end);
114
+
115
+ const workspace = Workspace.create(context.supervisor);
116
+ try {
117
+ // A cancelled version run gives no version; the analysis run below then refuses the cancelled request.
118
+ const toolVersion = await dxaVersion(executable, workspace.root, context);
119
+ workspace.materialize("input", [{ path: "image.bin", size: image.length }], [image]);
120
+ const files: Parameters<typeof dxaArguments>[2] = { image: "input/image.bin" };
121
+ const seed = (name: "routines" | "blocks" | "labels", lines: string[]) => {
122
+ if (lines.length === 0) return;
123
+ writeFileSync(workspace.path(`${name}.txt`), lines.join(""));
124
+ files[name] = `${name}.txt`;
125
+ };
126
+ seed("routines", entryPoints.map((address) => `${hex(address)}\n`));
127
+ seed("blocks", dataRanges.map((range) => `${hex(range.start)}-${hex(range.end)}\n`));
128
+ // The xa label file format: name, address, flags. dxa reads the name up to the first blank, so a leading dot stays.
129
+ seed("labels", params.labels.filter((label) => SYMBOL_NAME.test(label.name)).map((label) => `${label.name}, 0x${hex(label.address)}, 0x0000\n`));
130
+ const run = await runToolOrFail(
131
+ "dxa",
132
+ "The DXA analysis",
133
+ {
134
+ argv: dxaArguments(executable, params, files),
135
+ cwd: workspace.root,
136
+ timeoutMs: TIMEOUT_MS,
137
+ outputLimit: MAX_LISTING,
138
+ truncated: `dxa printed a listing that is larger than ${MAX_LISTING / 1024 / 1024} MiB. Nothing was imported.`,
139
+ },
140
+ context,
141
+ );
142
+ if (run.code !== 0) {
143
+ context.log?.(`dxa exited ${run.code}: ${run.stderr}`);
144
+ const tail = outputTail("dxa", run, workspace.root);
145
+ throw new WireFailure("operation-failed", `dxa could not analyze the program${tail === "" ? ` (exit ${run.code}).` : `.${tail}`}`);
146
+ }
147
+ const listing = Buffer.from(run.stdout, "utf8");
148
+ let result: DxaResult;
149
+ try {
150
+ const parsed = parseListing(run.stdout, start, end);
151
+ // DXA classifies every byte, so its regions are complete; it names only referenced addresses.
152
+ result = checkDxaResult(
153
+ { coverage: [{ start, end }], ...parsed, listingBytes: listing.length, completeness: { regions: true, labels: false }, ...(toolVersion === undefined ? {} : { toolVersion }) },
154
+ [listing],
155
+ );
156
+ } catch (error) {
157
+ context.log?.(`dxa listing rejected: ${describeError(error)}`);
158
+ // The listing is on stdout: only dxa's messages on stderr are quoted.
159
+ const tail = outputTail("dxa", { stdout: "", stderr: run.stderr }, workspace.root);
160
+ throw new WireFailure("operation-failed", `dxa returned an incomplete or inconsistent listing. Nothing was imported. The check found: ${describeError(error)}.${tail}`);
161
+ }
162
+ return { result, attachments: [listing] };
163
+ } finally {
164
+ workspace.remove();
165
+ }
166
+ }
@@ -0,0 +1,86 @@
1
+ // The Ghidra analysis: one disposable headless run with the NMOS
2
+ // 6510 language, knowledge seeds before analysis, and the structural export
3
+ // after it. The result is checked completely before the script imports it.
4
+
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+
7
+ import { describeError, isObject, WireFailure } from "../../protocol/messages.ts";
8
+ import { checkGhidraResult, checkRequestBounds, GHIDRA_LIMITS, imageRange, seedsInside, type GhidraParams, type GhidraResult } from "../types.ts";
9
+ import { outputTail, type ToolContext } from "../run.ts";
10
+ import { Workspace } from "../staging.ts";
11
+ import { findGhidra, ghidraVersion, runHeadless, SCRIPT_DIRECTORY } from "./index.ts";
12
+
13
+ const TIMEOUT_MS = 600_000;
14
+ /** The part of the run that the decompilations leave for the export, the project removal and the end of Ghidra. */
15
+ const AFTER_DECOMPILE_MS = 30_000;
16
+
17
+ export async function analyze(
18
+ params: GhidraParams,
19
+ image: Buffer,
20
+ context: ToolContext,
21
+ ): Promise<{ result: GhidraResult }> {
22
+ checkRequestBounds(params);
23
+ const ghidra = findGhidra(context.env);
24
+ const { start, end, body } = imageRange(params.imageKind, image);
25
+ const base = start;
26
+
27
+ // Ghidra disassembles and defines data only in the loaded bytes; labels may name any address.
28
+ const { entryPoints, dataRanges } = seedsInside(params, start, end);
29
+ if (entryPoints.length === 0) {
30
+ throw new WireFailure("invalid-input", "Give at least one entry point inside the program, for example the SYS address of its BASIC loader.");
31
+ }
32
+
33
+ const workspace = Workspace.create(context.supervisor);
34
+ try {
35
+ workspace.materialize("input", [{ path: "image.bin", size: body.length }], [body]);
36
+ writeFileSync(workspace.path("seeds.json"), JSON.stringify({ start, end, entryPoints, dataRanges, labels: params.labels }));
37
+ writeFileSync(
38
+ workspace.path("request.json"),
39
+ JSON.stringify({
40
+ start,
41
+ end,
42
+ decompile: params.decompile,
43
+ maxChars: GHIDRA_LIMITS.decompiledChars,
44
+ maxTotalChars: GHIDRA_LIMITS.decompiledTotalChars,
45
+ decompileDeadline: Date.now() + TIMEOUT_MS - AFTER_DECOMPILE_MS,
46
+ }),
47
+ );
48
+ const run = await runHeadless({
49
+ ghidra,
50
+ workspace,
51
+ file: "input/image.bin",
52
+ baseAddress: base,
53
+ scriptDirectories: [SCRIPT_DIRECTORY],
54
+ preScripts: [{ name: "C64Prepare.java", args: [workspace.path("seeds.json")] }],
55
+ postScripts: [{ name: "C64Export.java", args: [workspace.path("request.json"), workspace.path("out.json")] }],
56
+ analyze: true,
57
+ timeoutMs: TIMEOUT_MS,
58
+ supervisor: context.supervisor,
59
+ signal: context.signal,
60
+ ...(context.env === undefined ? {} : { env: context.env }),
61
+ }).catch((error: unknown) => {
62
+ context.log?.(`Ghidra failed: ${describeError(error)}`);
63
+ throw error;
64
+ });
65
+ const outPath = workspace.path("out.json");
66
+ if (!existsSync(outPath)) {
67
+ context.log?.(`Ghidra wrote no result:\n${run.stdout.slice(-4000)}\n${run.stderr.slice(-4000)}`);
68
+ // Ghidra's own warnings and errors say why its export script wrote nothing.
69
+ throw new WireFailure("operation-failed", `Ghidra finished without a result.${outputTail("Ghidra", run, workspace.root, /ERROR|WARN|Exception|Error:|SCRIPT/)}`);
70
+ }
71
+ let result: GhidraResult;
72
+ try {
73
+ const exported: unknown = JSON.parse(readFileSync(outPath, "utf8"));
74
+ // The version comes from the installation's application.properties, which findGhidra read.
75
+ result = checkGhidraResult(isObject(exported) ? { ...exported, toolVersion: ghidraVersion(ghidra) } : exported);
76
+ const [range] = result.coverage;
77
+ if (result.coverage.length !== 1 || range === undefined || range.start !== start || range.end !== end) throw new Error("the coverage is not the loaded program");
78
+ } catch (error) {
79
+ context.log?.(`Ghidra result rejected: ${describeError(error)}`);
80
+ throw new WireFailure("operation-failed", `Ghidra returned an incomplete or inconsistent result. Nothing was imported. The check found: ${describeError(error)}.`);
81
+ }
82
+ return { result };
83
+ } finally {
84
+ workspace.remove();
85
+ }
86
+ }
@@ -0,0 +1,157 @@
1
+ // Headless Ghidra, run by the c64-static-analysis script. Ghidra runs with a settings
2
+ // directory of its own per request: the c64-re-tools NMOS 6510 language goes
3
+ // in there as an extension, so the user's Ghidra installation and settings
4
+ // never change. Ghidra compiles the language from its source on first load.
5
+ // Projects are disposable and live in the request workspace.
6
+
7
+ import { cpSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
8
+ import { userInfo } from "node:os";
9
+ import { dirname, isAbsolute, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import { WireFailure } from "../../protocol/messages.ts";
13
+ import { findOnPath, isExecutableFile } from "../discover.ts";
14
+ import type { ProcessSupervisor } from "../processes.ts";
15
+ import type { Workspace } from "../staging.ts";
16
+ import { outputTail, runToolOrFail, type ToolRun } from "../run.ts";
17
+
18
+ export const GHIDRA_LANGUAGE = "C64RT_6510:LE:16:nmos";
19
+ // Next to this module. New URL literals, so an installed skill gets the directories too.
20
+ export const LANGUAGE_DIRECTORY = fileURLToPath(new URL("./language/", import.meta.url));
21
+ export const SCRIPT_DIRECTORY = fileURLToPath(new URL("./scripts/", import.meta.url));
22
+
23
+ const REMEDY =
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.";
26
+
27
+ export interface GhidraInstallation {
28
+ /** The installation directory. */
29
+ root: string;
30
+ analyzeHeadless: string;
31
+ /** For example ghidra_12.1.3_PUBLIC: the name of Ghidra's settings directory. */
32
+ settingsName: string;
33
+ version: string;
34
+ }
35
+
36
+ /** Ghidra's launcher is a shell script, and a batch file on Windows. */
37
+ const LAUNCHER_SUFFIX = process.platform === "win32" ? ".bat" : "";
38
+
39
+ function installationAt(root: string, source: string): GhidraInstallation {
40
+ const analyzeHeadless = join(root, "support", `analyzeHeadless${LAUNCHER_SUFFIX}`);
41
+ let properties: string;
42
+ try {
43
+ if (!isExecutableFile(analyzeHeadless)) throw new Error("no analyzeHeadless");
44
+ properties = readFileSync(join(root, "Ghidra", "application.properties"), "utf8");
45
+ } catch {
46
+ throw new WireFailure("installation-incomplete", `${source} does not point to a Ghidra installation. ${REMEDY}`);
47
+ }
48
+ const value = (key: string) => new RegExp(`^${key.replace(/\./g, "\\.")}=(.*)$`, "m").exec(properties)?.[1]?.trim();
49
+ const version = value("application.version");
50
+ const release = value("application.release.name");
51
+ 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 };
53
+ }
54
+
55
+ /** The version of a Ghidra installation as a result and the tool status name it, for example "Ghidra 12.1.3". */
56
+ export function ghidraVersion(ghidra: GhidraInstallation): string {
57
+ return `Ghidra ${ghidra.version}`;
58
+ }
59
+
60
+ /** Finds Ghidra: C64RT_GHIDRA names the installation directory, else analyzeHeadless on PATH. */
61
+ export function findGhidra(env: NodeJS.ProcessEnv = process.env): GhidraInstallation {
62
+ const configured = env.C64RT_GHIDRA;
63
+ if (configured !== undefined && configured !== "") {
64
+ if (!isAbsolute(configured)) throw new WireFailure("installation-incomplete", `C64RT_GHIDRA on this machine is not an absolute path. ${REMEDY}`);
65
+ return installationAt(configured, "C64RT_GHIDRA");
66
+ }
67
+ const found = findOnPath(["analyzeHeadless"], env, [LAUNCHER_SUFFIX]);
68
+ if (found !== undefined) return installationAt(resolve(dirname(realpathSync(found)), ".."), "analyzeHeadless on PATH");
69
+ throw new WireFailure("installation-incomplete", `Ghidra is not installed on this machine. ${REMEDY}`);
70
+ }
71
+
72
+ /**
73
+ * The user name in the name of Ghidra's settings directory: Java's user.name
74
+ * without spaces and without a domain before the last backslash or slash. Java gives "?"
75
+ * for a user without an entry in the password database, where Node throws.
76
+ */
77
+ export function ghidraUserName(lookup: () => string = () => userInfo().username): string {
78
+ let name: string;
79
+ try {
80
+ name = lookup();
81
+ } catch {
82
+ name = "?";
83
+ }
84
+ name = name.split(" ").join("");
85
+ return name.slice(Math.max(name.lastIndexOf("\\"), name.lastIndexOf("/")) + 1);
86
+ }
87
+
88
+ /**
89
+ * Makes a Ghidra settings directory inside the workspace with the NMOS 6510
90
+ * language installed as an extension. Returns the environment that makes
91
+ * Ghidra use it.
92
+ */
93
+ export function prepareSettings(workspace: Workspace, ghidra: GhidraInstallation, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
94
+ const config = workspace.directory("ghidra-config");
95
+ const cache = workspace.directory("ghidra-cache");
96
+ // With XDG_CONFIG_HOME set, Ghidra keeps its settings in [<user>-]ghidra/<settings name> below it: with
97
+ // the user name only when that directory lies outside the user's home. The temporary directory is
98
+ // outside it on Linux and macOS but inside it on Windows (in AppData), so both names get it.
99
+ for (const application of [`${ghidraUserName()}-ghidra`, "ghidra"]) {
100
+ const extension = join(config, application, ghidra.settingsName, "Extensions", "C64RT");
101
+ mkdirSync(join(extension, "data"), { recursive: true });
102
+ cpSync(LANGUAGE_DIRECTORY, join(extension, "data", "languages"), { recursive: true });
103
+ writeFileSync(join(extension, "extension.properties"), `name=C64RT\ndescription=c64-re-tools NMOS 6510 language\nauthor=c64-re-tools\ncreatedOn=\nversion=${ghidra.version}\n`);
104
+ writeFileSync(join(extension, "Module.manifest"), "");
105
+ }
106
+ return { ...env, XDG_CONFIG_HOME: config, XDG_CACHE_HOME: cache };
107
+ }
108
+
109
+ export interface HeadlessOptions {
110
+ ghidra: GhidraInstallation;
111
+ workspace: Workspace;
112
+ /** Workspace-relative path of the file to import. */
113
+ file: string;
114
+ baseAddress: number;
115
+ /** Scripts from these directories, before and after analysis, with their arguments. */
116
+ scriptDirectories: string[];
117
+ preScripts?: Array<{ name: string; args: string[] }>;
118
+ postScripts?: Array<{ name: string; args: string[] }>;
119
+ analyze: boolean;
120
+ timeoutMs: number;
121
+ supervisor: ProcessSupervisor;
122
+ signal: AbortSignal;
123
+ env?: NodeJS.ProcessEnv;
124
+ }
125
+
126
+ /** Imports one file into a disposable project and runs the scripts. Fails on timeout, abort or a Ghidra error exit. */
127
+ export async function runHeadless(options: HeadlessOptions): Promise<ToolRun> {
128
+ const env = prepareSettings(options.workspace, options.ghidra, options.env);
129
+ const project = options.workspace.directory("ghidra-project");
130
+ const argv = [
131
+ options.ghidra.analyzeHeadless,
132
+ project,
133
+ "request",
134
+ "-import",
135
+ options.workspace.path(options.file),
136
+ "-processor",
137
+ GHIDRA_LANGUAGE,
138
+ "-loader",
139
+ "BinaryLoader",
140
+ "-loader-baseAddr",
141
+ `0x${options.baseAddress.toString(16)}`,
142
+ "-scriptPath",
143
+ options.scriptDirectories.join(";"),
144
+ "-deleteProject",
145
+ ];
146
+ if (!options.analyze) argv.push("-noanalysis");
147
+ else argv.push("-analysisTimeoutPerFile", String(Math.ceil(options.timeoutMs / 1000)));
148
+ for (const script of options.preScripts ?? []) argv.push("-preScript", script.name, ...script.args);
149
+ for (const script of options.postScripts ?? []) argv.push("-postScript", script.name, ...script.args);
150
+ const run = await runToolOrFail("Ghidra", "The Ghidra analysis", { argv, cwd: options.workspace.root, timeoutMs: options.timeoutMs }, { supervisor: options.supervisor, signal: options.signal, env });
151
+ if (run.code !== 0 || /^ERROR REPORT SCRIPT ERROR|Exception in thread "main"/m.test(run.stdout + run.stderr)) {
152
+ // Ghidra's own error lines tell the user what to fix.
153
+ const tail = outputTail("Ghidra", run, options.workspace.root, /ERROR|Exception|Error:/);
154
+ throw new WireFailure("operation-failed", `Ghidra stopped with an error${tail === "" ? ` (exit ${run.code}).` : `.${tail}`}`);
155
+ }
156
+ return run;
157
+ }
@@ -0,0 +1,34 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!-- Calls pass values in A, X and Y and return them in A. -->
3
+ <compiler_spec>
4
+ <global>
5
+ <range space="RAM"/>
6
+ </global>
7
+ <stackpointer register="SP" space="RAM" growth="negative"/>
8
+ <returnaddress>
9
+ <varnode space="stack" offset="1" size="2"/>
10
+ </returnaddress>
11
+ <default_proto>
12
+ <prototype name="__stdcall" extrapop="2" stackshift="2" strategy="register">
13
+ <input>
14
+ <pentry minsize="1" maxsize="1">
15
+ <register name="A"/>
16
+ </pentry>
17
+ <pentry minsize="1" maxsize="1">
18
+ <register name="X"/>
19
+ </pentry>
20
+ <pentry minsize="1" maxsize="1">
21
+ <register name="Y"/>
22
+ </pentry>
23
+ </input>
24
+ <output>
25
+ <pentry minsize="1" maxsize="1">
26
+ <register name="A"/>
27
+ </pentry>
28
+ </output>
29
+ <unaffected>
30
+ <register name="SP"/>
31
+ </unaffected>
32
+ </prototype>
33
+ </default_proto>
34
+ </compiler_spec>
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!-- The c64-re-tools NMOS 6510 language: all 256 opcodes of the C64 CPU. -->
3
+ <language_definitions>
4
+ <language processor="C64RT_6510"
5
+ endian="little"
6
+ size="16"
7
+ variant="nmos"
8
+ version="1.0"
9
+ slafile="c64rt_6510.sla"
10
+ processorspec="c64rt_6510.pspec"
11
+ id="C64RT_6510:LE:16:nmos">
12
+ <description>NMOS 6510 (C64) with all undocumented opcodes</description>
13
+ <compiler name="default" spec="c64rt_6510.cspec" id="default"/>
14
+ </language>
15
+ </language_definitions>
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!--
3
+ No default memory blocks or vector symbols: the importer makes the blocks
4
+ for the loaded program and the rest of the 64 KiB address space.
5
+ D is tracked as 0 at function entry, so the decimal path of ADC and SBC
6
+ disappears from decompiled code that never sets decimal mode.
7
+ -->
8
+ <processor_spec>
9
+ <programcounter register="PC"/>
10
+ <context_data>
11
+ <tracked_set space="RAM">
12
+ <set name="D" val="0"/>
13
+ </tracked_set>
14
+ </context_data>
15
+ </processor_spec>